commit 7cc7e6b4f35917846a8bde22d10327f8ec2b62fc Author: upstream-mirror Date: Fri Sep 18 04:19:15 2026 +0500 snapshot of https://github.com/espocrm/espocrm.git (master, 2026-09-18; blobs > 131072B omitted) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4447198 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = crlf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[{package.json,package-lock.json}] +indent_size = 2 + +[.github/**/*.yml] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a214b04 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto +*.php text eol=crlf +*.js text eol=crlf +*.json text eol=crlf +*.md text eol=crlf +*.less text eol=crlf +*.css text eol=crlf +*.tpl text eol=crlf +*.html text eol=crlf + +bin/command text eol=lf + +.gitattributes text eol=crlf +.gitignore text eol=crlf + +*.png binary diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..a9fb19b --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,54 @@ +## Issues + +#### 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. + +#### No support + +Note that we don't provide developer help or any kind of support on GitHub. +For this, please use our [forum](https://forum.espocrm.com). + +#### Forum + +If you are very new to EspoCRM, it's probable that an issue you ran into is not a bug. +Consider creating a topic on our [forum](https://forum.espocrm.com/forum/general) instead. + +#### Upgrade issues + +For upgrade-related issues, create a topic on the forum in the appropriate [section](https://forum.espocrm.com/forum/installation-and-upgrade-help). Upgrade-related issues posted on GitHub are removed. + +#### Project's best interest + +The issue tracker is for the benefit of the EspoCRM project. The project maintainers are going to handle issues in the project's best interest. +The maintainers have right to close issues without explanation. + +#### Off-topic + +As we reference GitHub issues in release notes, we strive to keep them free of off-topic comments. + +#### Code-level issues + +If an issue is not reproducible by actions made via the UI but only by coding, the reporter is expected to have a clear +understanding of its cause and to provide an explanation of it — unless the cause is obvious from the context. + +Otherwise, it's advisable to post on the forum in the Developer Help section. + +## Pull Requests + +We are open for contributions that are bug fixes and small improvements. If you would like to contribute something +that is not a small fix, please reach out to maintainers before submitting your PR +(by creating a GitHub issue or a forum post). + +Before we can merge your pull request, you need to accept our CLA [here](https://github.com/espocrm/cla). + +It's desirable that one PR solves one specific problem. Do not include code style changes to PRs +(unless the main purpose of the PR is a code style fix). + +Branches: + +* *master* – the develop branch; new features should be pushed to here; +* *fix* – the upcoming maintenance release; small fixes should be pushed to here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4e8bba8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a bug report. Not to be used for help requests or server configuration issues. Only for issues related to open source EspoCRM. Issues related to extensions should not to be posted here. +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce (MANDATORY, DO NOT REMOVE)** +Explicit steps to reproduce the behavior: +1. ... +2. ... +3. ... +... + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**EspoCRM version** +Specify the EspoCRM version (or versions) on which the bug occurs. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/code_mistake.md b/.github/ISSUE_TEMPLATE/code_mistake.md new file mode 100644 index 0000000..eee86d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/code_mistake.md @@ -0,0 +1,20 @@ +--- +name: Mistake or typo +about: Report a typo or mistake in the code. +title: '' +labels: '' +assignees: '' + +--- + +**Describe the mistake** +A clear and concise description of the mistake or typo. + +**Location** +Path of the file where the mistake is found. Paste the incorrect line or snippet if helpful. + +**Suggested correction** +How it should be fixed. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..529a904 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Upgrade issue + url: https://forum.espocrm.com/forum/installation-and-upgrade-help + about: "If something went wrong with an upgrade." + - name: Feature request + url: https://forum.espocrm.com/forum/feature-requests + about: "Use our forum for feature requests." + - name: Forum + url: https://forum.espocrm.com/ + about: "Use our forum for help requests and questions. We don't provide support on GitHub." diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..6c3df89 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request for framework +about: For low-level features. For high-level features, create feature requests on our forum. +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..540be48 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Reporting a vulnerability + +If you believe you have discovered a vulnerability in EspoCRM, please contact us via [this](https://www.espocrm.com/contacts/) or [this](https://www.espocrm.com/support/) forms. Or create a private vulnerability report on GitHub. + +What reports we do not accept: + +- Executing PHP code by an extension, during extension installation or upgrade process. +- Exposing contacts through a target list, campaign or mass email features, considering the user has access to these features. +- SSRF in IMAP/SMTP with TOCTOU. + +Submitting multiple unverified reports without a proper proof of concept +(for example, by simply copy-pasting LLM-generated output) may be considered abuse of the reporting process +and may result in the reporting account being blocked. diff --git a/.github/workflows/test-integration-pg.yml b/.github/workflows/test-integration-pg.yml new file mode 100644 index 0000000..f2ee58a --- /dev/null +++ b/.github/workflows/test-integration-pg.yml @@ -0,0 +1,64 @@ +name: Test Integration on PostgreSQL + +on: + schedule: + - cron: '0 11 * * *' + +jobs: + test: + name: Test on PHP ${{ matrix.php-versions }} ${{ matrix.branches }} + runs-on: ubuntu-latest + env: + TEST_DATABASE_HOST: '127.0.0.1' + TEST_DATABASE_PLATFORM: 'Postgresql' + TEST_DATABASE_CHARSET: 'utf8' + TEST_DATABASE_PORT: '8888' + TEST_DATABASE_NAME: integration_test + TEST_DATABASE_USER: postgres + TEST_DATABASE_PASSWORD: password + services: + postgres: + image: postgres:17.9 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: integration_test + ports: + - '8888:5432' + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + fail-fast: false + matrix: + php-versions: ['8.4'] + branches: ['master', 'fix'] + + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ matrix.branches }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Setup PHP with Composer + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer:v2 + ini-values: memory_limit=1024M + + - name: NPM install + run: npm install + + - name: Build + run: grunt test + + - name: Integration testing + run: vendor/bin/phpunit --testsuite integration-pg diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 0000000..caab62b --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,64 @@ +name: Test Integration on MySQL + +on: + schedule: + - cron: '0 10 * * *' + +jobs: + test: + name: Test on PHP ${{ matrix.php-versions }} + runs-on: ubuntu-latest + env: + TEST_DATABASE_HOST: '127.0.0.1' + TEST_DATABASE_PORT: '8888' + TEST_DATABASE_NAME: integration_test + TEST_DATABASE_USER: root + TEST_DATABASE_PASSWORD: password + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: false + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: integration_test + ports: + - '8888:3306' + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + strategy: + fail-fast: false + matrix: + php-versions: ['8.3', '8.5'] + branches: ['master'] + + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ matrix.branches }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Setup PHP with Composer + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer:v2 + ini-values: memory_limit=1024M + + - name: Start MySQL service + run: sudo systemctl start mysql + + - name: Create DB table + run: mysql -h 127.0.0.1 --port 8888 -u root -ppassword -e 'CREATE DATABASE IF NOT EXISTS integration_test;' + + - name: NPM install + run: npm install + + - name: Build + run: grunt test + + - name: Integration testing + run: vendor/bin/phpunit tests/integration diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ec646af --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,57 @@ +name: Test + +on: + push: + branches: + - 'master' + - 'fix' + paths: + - '**.php' + - '**.json' + - '**.yml' + - 'composer.lock' + - 'phpstan.neon' + + pull_request: + paths: + - '**.php' + - '**.json' + - '**.yml' + - 'composer.lock' + - 'phpstan.neon' + - '.github/workflows/test.yml' + +jobs: + test: + name: Test on PHP ${{ matrix.php-versions }} + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ['8.3', '8.4', '8.5'] + + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP with Composer + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer:v2 + ini-values: memory_limit=1024M + + - name: Cache Vendor + id: cache-vendor + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-vendor-${{ hashFiles('**/composer.lock') }} + + - name: Composer install + if: steps.cache-vendor.outputs.cache-hit != 'true' + run: composer install + + - name: Static analysis + run: vendor/bin/phpstan + + - name: Unit testing + run: vendor/bin/phpunit tests/unit diff --git a/.github/workflows/ts-check.yml b/.github/workflows/ts-check.yml new file mode 100644 index 0000000..96f4fff --- /dev/null +++ b/.github/workflows/ts-check.yml @@ -0,0 +1,40 @@ +name: TypeScript static analysis + +on: + push: + branches: + - 'master' + - 'fix' + paths: + - '**.ts' + - 'package-lock.json' + - 'tsconfig.json' + - '.github/workflows/ts-check.yml' + + pull_request: + paths: + - '**.ts' + - 'package-lock.json' + - 'tsconfig.json' + - '.github/workflows/ts-check.yml' + +jobs: + typescript-check: + name: TypeScript check + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run TypeScript check + run: npx tsc --noEmit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dc78dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +/data/logs/* +/data/cache/* +/data/upload/* +/data/preferences/* +/data/.backup/* +/data/config.php +/data/config-internal.php +/data/config-override.php +/data/config-internal-override.php +/data/state.php +/data/tmp/* +/build +/node_modules +/npm-debug.log +/test.php +/main.html + +/tests/unit/testData/cache/* +!/tests/unit/testData/cache/.data +/tests/integration/config.php +.phpunit.result.cache +/composer.phar +/vendor/ +/custom/Espo/Custom/* +/custom/Espo/Modules/* +!/custom/Espo/Custom/.htaccess +!/custom/Espo/Modules/.htaccess +/install/config.php + +client/css/espo/* +client/lib/* +client/modules/crm/lib/* diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..d7d9e86 --- /dev/null +++ b/.htaccess @@ -0,0 +1,39 @@ + + Header always set Access-Control-Allow-Methods "POST, GET, PUT, PATCH, DELETE" + + +DirectoryIndex index.php + +Options -Indexes + + + RewriteEngine On + + # Forbid access. Not actual as redirect to `public` is applied. + # An extra security measure if redirect not fired. + RewriteRule ^/?data/ - [F] + RewriteRule ^/?application/ - [F] + RewriteRule ^/?custom/ - [F] + RewriteRule ^/?vendor/ - [F] + RewriteRule /?web\.config - [F] + + # Forbid `public` dir. + RewriteCond %{ENV:REDIRECT_STATUS} !=200 + RewriteRule ^/?public/? - [F,L] + + # Skip redirect for `client` dir. + RewriteRule ^client/ - [L] + + # Store base path. + RewriteCond %{REQUEST_URI}::$1 ^(.*?/)(.*)::\2$ + RewriteRule ^(.*)$ - [E=BASE:%1] + + # Add trailing slash. + RewriteCond %{DOCUMENT_ROOT}/%{ENV:BASE}/public/$1 -d + RewriteRule ^(.*?[^/])$ %{REQUEST_URI}/ [L,R=301,NE] + + # Rewrite to `public` dir. + RewriteRule ^((?!public/).*)$ %{ENV:BASE}/public/$1 [L,NC] + + RewriteRule .* - [E=HTTP_ESPO_CGI_AUTH:%{HTTP:Authorization}] + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..6f1e320 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,7 @@ +* +!.gitignore +!/codeStyles +!/fileTemplates +!/inspectionProfiles +!misc.xml +!jsonSchemas.xml diff --git a/.idea/jsonSchemas.xml b/.idea/jsonSchemas.xml new file mode 100644 index 0000000..05f120b --- /dev/null +++ b/.idea/jsonSchemas.xml @@ -0,0 +1,1601 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..6a76b39 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,8 @@ + + + + + + @implements + + \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d7f749e --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +tag-version-prefix = "" diff --git a/.vscode/.gitignore b/.vscode/.gitignore new file mode 100644 index 0000000..8aab94e --- /dev/null +++ b/.vscode/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cceef91 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,494 @@ +{ + "json.schemas": [ + { + "fileMatch": [ + "*/Resources/autoload.json" + ], + "url": "./schema/autoload.json" + }, + { + "fileMatch": [ + "*/Resources/routes.json" + ], + "url": "./schema/routes.json" + }, + { + "fileMatch": [ + "*/Resources/module.json" + ], + "url": "./schema/module.json" + }, + { + "fileMatch": [ + "*/Resources/layouts/*/detail.json", + "*/Resources/layouts/*/detailSmall.json", + "*/Resources/layouts/*/detailConvert.json" + ], + "url": "./schema/layouts/detail.json" + }, + { + "fileMatch": [ + "*/Resources/layouts/*/list.json", + "*/Resources/layouts/*/listSmall.json", + "*/Resources/layouts/*/listFor*.json" + ], + "url": "./schema/layouts/list.json" + }, + { + "fileMatch": [ + "*/metadata/aclDefs/*.json" + ], + "url": "./schema/metadata/aclDefs.json" + }, + { + "fileMatch": [ + "*/metadata/authenticationMethods/*.json" + ], + "url": "./schema/metadata/authenticationMethods.json" + }, + { + "fileMatch": [ + "*/metadata/clientDefs/*.json" + ], + "url": "./schema/metadata/clientDefs.json" + }, + { + "fileMatch": [ + "*/metadata/dashlets/*.json" + ], + "url": "./schema/metadata/dashlets.json" + }, + { + "fileMatch": [ + "*/metadata/entityAcl/*.json" + ], + "url": "./schema/metadata/entityAcl.json" + }, + { + "fileMatch": [ + "*/metadata/entityDefs/*.json" + ], + "url": "./schema/metadata/entityDefs.json" + }, + { + "fileMatch": [ + "*/metadata/fields/*.json" + ], + "url": "./schema/metadata/fields.json" + }, + { + "fileMatch": [ + "*/metadata/integrations/*.json" + ], + "url": "./schema/metadata/integrations.json" + }, + { + "fileMatch": [ + "*/metadata/logicDefs/*.json" + ], + "url": "./schema/metadata/logicDefs.json" + }, + { + "fileMatch": [ + "*/metadata/notificationDefs/*.json" + ], + "url": "./schema/metadata/notificationDefs.json" + }, + { + "fileMatch": [ + "*/metadata/pdfDefs/*.json" + ], + "url": "./schema/metadata/pdfDefs.json" + }, + { + "fileMatch": [ + "*/metadata/recordDefs/*.json" + ], + "url": "./schema/metadata/recordDefs.json" + }, + { + "fileMatch": [ + "*/metadata/scopes/*.json" + ], + "url": "./schema/metadata/scopes.json" + }, + { + "fileMatch": [ + "*/metadata/selectDefs/*.json" + ], + "url": "./schema/metadata/selectDefs.json" + }, + { + "fileMatch": [ + "*/metadata/streamDefs/*.json" + ], + "url": "./schema/metadata/streamDefs.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/acl.json" + ], + "url": "./schema/metadata/app/acl.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/aclPortal.json" + ], + "url": "./schema/metadata/app/aclPortal.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/actions.json" + ], + "url": "./schema/metadata/app/actions.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/addressFormats.json" + ], + "url": "./schema/metadata/app/addressFormats.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/adminPanel.json" + ], + "url": "./schema/metadata/app/adminPanel.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/api.json" + ], + "url": "./schema/metadata/app/api.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/appParams.json" + ], + "url": "./schema/metadata/app/appParams.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/authentication.json" + ], + "url": "./schema/metadata/app/authentication.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/authentication2FAMethods.json" + ], + "url": "./schema/metadata/app/authentication2FAMethods.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/cleanup.json" + ], + "url": "./schema/metadata/app/cleanup.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/client.json" + ], + "url": "./schema/metadata/app/client.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/clientIcons.json" + ], + "url": "./schema/metadata/app/clientIcons.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/clientRecord.json" + ], + "url": "./schema/metadata/app/clientRecord.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/clientRoutes.json" + ], + "url": "./schema/metadata/app/clientRoutes.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/complexExpression.json" + ], + "url": "./schema/metadata/app/complexExpression.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/config.json" + ], + "url": "./schema/metadata/app/config.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/consoleCommands.json" + ], + "url": "./schema/metadata/app/consoleCommands.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/containerServices.json" + ], + "url": "./schema/metadata/app/containerServices.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/currency.json" + ], + "url": "./schema/metadata/app/currency.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/currencyConversion.json" + ], + "url": "./schema/metadata/app/currencyConversion.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/databasePlatforms.json" + ], + "url": "./schema/metadata/app/databasePlatforms.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/dateTime.json" + ], + "url": "./schema/metadata/app/dateTime.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/defaultDashboardLayouts.json" + ], + "url": "./schema/metadata/app/defaultDashboardLayouts.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/defaultDashboardOptions.json" + ], + "url": "./schema/metadata/app/defaultDashboardOptions.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/emailTemplate.json" + ], + "url": "./schema/metadata/app/emailTemplate.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/entityManagerParams.json" + ], + "url": "./schema/metadata/app/entityManagerParams.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/entityTemplateList.json" + ], + "url": "./schema/metadata/app/entityTemplateList.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/entityTemplates.json" + ], + "url": "./schema/metadata/app/entityTemplates.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/entryPoints.json" + ], + "url": "./schema/metadata/app/entryPoints.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/export.json" + ], + "url": "./schema/metadata/app/export.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/fieldProcessing.json" + ], + "url": "./schema/metadata/app/fieldProcessing.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/file.json" + ], + "url": "./schema/metadata/app/file.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/fileStorage.json" + ], + "url": "./schema/metadata/app/fileStorage.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/formula.json" + ], + "url": "./schema/metadata/app/formula.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/hook.json" + ], + "url": "./schema/metadata/app/hook.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/image.json" + ], + "url": "./schema/metadata/app/image.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/jsLibs.json" + ], + "url": "./schema/metadata/app/jsLibs.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/language.json" + ], + "url": "./schema/metadata/app/language.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/layouts.json" + ], + "url": "./schema/metadata/app/layouts.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/linkManager.json" + ], + "url": "./schema/metadata/app/linkManager.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/entityManager.json" + ], + "url": "./schema/metadata/app/entityManager.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/mapProviders.json" + ], + "url": "./schema/metadata/app/mapProviders.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/massActions.json" + ], + "url": "./schema/metadata/app/massActions.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/metadata.json" + ], + "url": "./schema/metadata/app/metadata.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/clientNavbar.json" + ], + "url": "./schema/metadata/app/clientNavbar.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/orm.json" + ], + "url": "./schema/metadata/app/orm.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/pdfEngines.json" + ], + "url": "./schema/metadata/app/pdfEngines.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/popupNotifications.json" + ], + "url": "./schema/metadata/app/popupNotifications.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/portalContainerServices.json" + ], + "url": "./schema/metadata/app/portalContainerServices.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/reactions.json" + ], + "url": "./schema/metadata/app/reactions.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/rebuild.json" + ], + "url": "./schema/metadata/app/rebuild.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/record.json" + ], + "url": "./schema/metadata/app/record.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/recordId.json" + ], + "url": "./schema/metadata/app/recordId.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/regExpPatterns.json" + ], + "url": "./schema/metadata/app/regExpPatterns.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/relationships.json" + ], + "url": "./schema/metadata/app/relationships.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/scheduledJobs.json" + ], + "url": "./schema/metadata/app/scheduledJobs.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/select.json" + ], + "url": "./schema/metadata/app/select.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/smsProviders.json" + ], + "url": "./schema/metadata/app/smsProviders.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/templateHelpers.json" + ], + "url": "./schema/metadata/app/templateHelpers.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/templates.json" + ], + "url": "./schema/metadata/app/templates.json" + }, + { + "fileMatch": [ + "*/Resources/metadata/app/webSocket.json" + ], + "url": "./schema/metadata/app/webSocket.json" + } + ] +} diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..e9d3ed7 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,606 @@ +/************************************************************************ + * 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 . + * + * 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. + ************************************************************************/ + +/** +* * `grunt` - full build; +* * `grunt dev` - build for development; +* * `grunt offline` - build but skip *composer install*; +* * `grunt internal` - build only libs and css; +* * `grunt release` - full build zipped with upgrade packages`; +* * `grunt test` - build for tests running; +* * `grunt run-tests` - build and run unit and integration tests. +*/ + +const fs = require('fs'); +const cp = require('child_process'); +const path = require('path'); +const buildUtils = require('./js/build-utils'); +const {TemplateBundler, Bundler} = require('espo-frontend-build-tools'); +const LayoutTypeBundler = require('./js/layout-template-bundler'); + +const bundleConfig = require('./frontend/bundle-config.json'); +const libs = require('./frontend/libs.json'); + +module.exports = grunt => { + + const pkg = grunt.file.readJSON('package.json'); + + const originalLibDir = 'client/lib/original'; + + const libsBundleFileList = [ + 'client/src/namespace.js', + 'client/src/loader.js', + ...buildUtils.getPreparedBundleLibList(libs), + ]; + + const bundleFileMap = {'client/lib/espo.js': libsBundleFileList}; + + for (const name in bundleConfig.chunks) { + const namePart = 'espo-' + name; + + bundleFileMap[`client/lib/${namePart}.js`] = originalLibDir + `/${namePart}.js` + } + + const copyJsFileList = buildUtils.getCopyLibDataList(libs); + + const minifyLibFileList = copyJsFileList + .filter(item => item.minify) + .map(item => { + return { + dest: item.dest, + src: item.originalDest, + }; + }); + + const currentPath = path.dirname(fs.realpathSync(__filename)); + + const themeList = []; + + fs.readdirSync('application/Espo/Resources/metadata/themes').forEach(file => { + themeList.push(file.substring(0, file.length - 5)); + }); + + const cssminFilesData = {}; + const lessData = {}; + + themeList.forEach(theme => { + const name = buildUtils.camelCaseToHyphen(theme); + + const files = {}; + + files['client/css/espo/'+name+'.css'] = 'frontend/less/'+name+'/main.less'; + files['client/css/espo/'+name+'-iframe.css'] = 'frontend/less/'+name+'/iframe/main.less'; + + cssminFilesData['client/css/espo/'+name+'.css'] = 'client/css/espo/'+name+'.css'; + cssminFilesData['client/css/espo/'+name+'-iframe.css'] = 'client/css/espo/'+name+'-iframe.css'; + + lessData[theme] = { + options: { + yuicompress: true, + }, + files: files, + }; + }); + + const cleanupBeforeFinal = [ + 'build/tmp/custom/Espo/Custom/*', + '!build/tmp/custom/Espo/Custom/.htaccess', + '!build/tmp/custom/Espo/Modules', + 'build/tmp/custom/Espo/Modules/*', + '!build/tmp/custom/Espo/Modules/.htaccess', + 'build/tmp/install/config.php', + 'build/tmp/vendor/*/*/.git', + 'build/tmp/client/custom/*', + '!build/tmp/client/custom/modules', + 'build/tmp/client/custom/modules/*', + '!build/tmp/client/custom/modules/dummy.txt', + 'build/tmp/client/modules/crm/src', + 'build/tmp/client/lib/original/*', + 'build/tmp/client/modules/crm/lib/original', + '!build/tmp/client/lib/original/espo.js', + '!build/tmp/client/lib/original/espo-*.js', + '!build/tmp/client/lib/original/espo-funnel-chart.js', + 'build/tmp/client/lib/transpiled', + ]; + + const cleanupBeforeFinalTest = cleanupBeforeFinal.filter(it => { + return ![ + '!build/tmp/custom/Espo/Modules', + 'build/tmp/custom/Espo/Modules/*', + '!build/tmp/custom/Espo/Modules/.htaccess', + '!build/tmp/client/custom/modules', + 'build/tmp/client/custom/modules/*', + '!build/tmp/client/custom/modules/dummy.txt', + ].includes(it) + }) + + grunt.initConfig({ + pkg: pkg, + + mkdir: { + tmp: { + options: { + mode: 0o755, + create: [ + 'build/tmp', + ], + } + } + }, + + clean: { + transpiled: [ + 'client/lib/transpiled/**', + ], + start: [ + 'build/EspoCRM-*', + 'client/lib/*', + 'client/modules/crm/lib/*', + 'client/css/espo/*', + ], + final: ['build/tmp'], + release: ['build/EspoCRM-' + pkg.version], + beforeFinal: {src: cleanupBeforeFinal}, + beforeFinalTest: {src: cleanupBeforeFinalTest}, + }, + + less: lessData, + + cssmin: { + themes: { + files: cssminFilesData, + }, + }, + + uglify: { + options: { + sourceMap: true, + output: { + comments: /^!/, + }, + beautify: false, + compress: { + sequences: false, + collapse_vars: false, + keep_fargs: true, + webkit: true, + }, + }, + bundle: { + options: { + banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n', + }, + files: bundleFileMap, + }, + lib: { + files: minifyLibFileList, + }, + }, + + copy: { + options: { + mode: true, + }, + frontend: { + expand: true, + cwd: 'client', + src: [ + 'res/**', + 'fonts/**', + 'modules/**', + 'img/**', + 'css/**', + 'sounds/**', + 'custom/**', + 'lib/**', + ], + dest: 'build/tmp/client', + }, + frontendLib: { + files: copyJsFileList, + }, + backend: { + expand: true, + dot: true, + src: [ + 'application/**', + 'custom/**', + 'data/.data', + 'vendor/**', + 'html/**', + 'public/**', + 'install/**', + 'bin/**', + 'bootstrap.php', + 'cron.php', + 'daemon.php', + 'rebuild.php', + 'clear_cache.php', + 'preload.php', + 'upgrade.php', + 'extension.php', + 'websocket.php', + 'command.php', + 'index.php', + 'LICENSE.txt', + '.htaccess', + 'web.config', + ], + dest: 'build/tmp/', + }, + final: { + expand: true, + dot: true, + src: '**', + cwd: 'build/tmp', + dest: 'build/EspoCRM-<%= pkg.version %>/', + }, + }, + + replace: { + version: { + options: { + patterns: [ + { + match: 'version', + replacement: '<%= pkg.version %>', + } + ] + }, + files: [ + { + src: 'build/tmp/application/Espo/Resources/defaults/config.php', + dest: 'build/tmp/application/Espo/Resources/defaults/config.php', + } + ], + }, + dev: { + options: { + patterns: [ + { + match: /# \{#dev}(.*)\{\/dev}/gs, + replacement: '', + } + ] + }, + files: [ + { + src: 'build/tmp/.htaccess', + dest: 'build/tmp/.htaccess', + } + ], + }, + }, + }); + + const writeOriginalLib = (name, contents) => { + if (!fs.existsSync(originalLibDir)) { + fs.mkdirSync(originalLibDir); + } + + const file = originalLibDir + `/${name}.js`; + + fs.writeFileSync(file, contents, 'utf8'); + }; + + grunt.registerTask('bundle', () => { + const bundler = new Bundler(bundleConfig, libs); + + const result = bundler.bundle(); + + for (const name in result) { + let contents = result[name]; + + const key = 'espo-' + name; + + if (name === 'main') { + contents += '\n' + (new LayoutTypeBundler()).bundle(); + } + + writeOriginalLib(key, contents); + } + }); + + grunt.registerTask('bundle-templates', () => { + const templateBundler = new TemplateBundler({ + dirs: [ + 'client/res/templates', + 'client/modules/crm/res/templates', + ], + }); + + templateBundler.process(); + }); + + grunt.registerTask('prepare-lib-original', () => { + // Even though `npm ci` runs the same script, 'clean:start' deletes files. + cp.execSync("node js/scripts/prepare-lib-original"); + }); + + grunt.registerTask('prepare-lib', () => { + cp.execSync("node js/scripts/prepare-lib"); + }); + + grunt.registerTask('transpile', () => { + cp.execSync("node js/transpile"); + }); + + grunt.registerTask('chmod-folders', () => { + cp.execSync( + "find . -type d -exec chmod 755 {} +", + {cwd: 'build/EspoCRM-' + pkg.version} + ); + }); + + grunt.registerTask('chmod-multiple', () => { + const dirPath = 'build/EspoCRM-' + pkg.version; + + const fileList = [ + { + name: '*.php', + }, + { + name: '*.json', + }, + { + name: '*.config', + }, + { + name: '.htaccess', + }, + { + name: '*.html', + }, + { + name: '*.txt', + }, + { + name: '*.js', + folder: 'client', + }, + { + name: '*.css', + folder: 'client', + }, + { + name: '*.tpl', + folder: 'client', + }, + ]; + + const dirReadableList = [ + 'public/install', + 'public/portal', + 'public/api', + 'public/api/v1', + 'public/api/v1/portal-access', + '.', + ]; + + const dirWritableList = [ + 'data', + 'custom', + 'custom/Espo', + 'custom/Espo/Custom', + 'client/custom', + 'client/modules', + 'application/Espo/Modules', + ]; + + fileList.forEach(item => { + const path = item.folder || '.'; + const name = item.name; + + cp.execSync( + `find ${path} -type f -iname "${name}" -exec chmod 644 {} +`, + { + cwd: dirPath, + } + ); + }); + + dirReadableList.forEach(item => { + cp.execSync( + `chmod 755 ${item}`, + { + cwd: dirPath, + } + ); + }); + + dirWritableList.forEach(item => { + cp.execSync( + `chmod 775 ${item}`, + { + cwd: dirPath, + } + ); + }); + + cp.execSync( + `find bin -type f -exec chmod 755 {} +`, + { + cwd: dirPath, + } + ); + }); + + grunt.registerTask('composer-install', () => { + cp.execSync("composer install --no-dev", {stdio: 'ignore'}); + }); + + grunt.registerTask('composer-install-dev', () => { + cp.execSync("composer install", {stdio: 'ignore'}); + }); + + grunt.registerTask('upgrade', () => { + cp.execSync("node diff --closest", {stdio: 'inherit'}); + }); + + grunt.registerTask('unit-tests-run', () => { + cp.execSync("vendor/bin/phpunit ./tests/unit", {stdio: 'inherit'}); + }); + + grunt.registerTask('integration-tests-run', () => { + cp.execSync("vendor/bin/phpunit ./tests/integration", {stdio: 'inherit'}); + }); + + grunt.registerTask('set-config-params', () => { + cp.execSync("composer run-script setConfigParams", {stdio: 'ignore'}); + }); + + grunt.registerTask('zip', function () { // Don't change to arrow-function. + const archiver = require('archiver'); + + const resolve = this.async(); + + const folder = 'EspoCRM-' + pkg.version; + const zipPath = 'build/' + folder + '.zip'; + + if (fs.existsSync(zipPath)) { + fs.unlinkSync(zipPath); + } + + const archive = archiver('zip'); + + archive.on('error', err => { + grunt.fail.warn(err); + }); + + const zipOutput = fs.createWriteStream(zipPath); + + zipOutput.on('close', () => { + console.log("EspoCRM package has been built."); + + resolve(); + }); + + archive + .directory(currentPath + '/build/' + folder, folder) + .pipe(zipOutput); + + archive.finalize(); + }); + + grunt.registerTask('npm-install', () => { + cp.execSync("npm ci", {stdio: ['ignore', 'ignore', 'pipe']}); + }); + + grunt.loadNpmTasks('grunt-contrib-clean'); + grunt.loadNpmTasks('grunt-mkdir'); + grunt.loadNpmTasks('grunt-contrib-less'); + grunt.loadNpmTasks('grunt-contrib-cssmin'); + grunt.loadNpmTasks('grunt-contrib-uglify'); + grunt.loadNpmTasks('grunt-contrib-copy'); + grunt.loadNpmTasks('grunt-replace'); + + grunt.registerTask('internal', [ + 'less', + 'cssmin', + 'prepare-lib-original', + 'clean:transpiled', + 'transpile', + 'bundle', + 'bundle-templates', + 'uglify:bundle', + 'copy:frontendLib', + 'prepare-lib', + 'uglify:lib', + ]); + + const offline = [ + 'clean:start', + 'mkdir:tmp', + 'internal', + 'copy:frontend', + 'copy:backend', + 'replace', + 'clean:beforeFinal', + 'copy:final', + 'chmod-folders', + 'chmod-multiple', + 'clean:final', + ]; + + const offlineTest = offline.map(it => { + if (it === 'clean:beforeFinal') { + return 'clean:beforeFinalTest' + } + + return it; + }) + + grunt.registerTask('offline', offline); + grunt.registerTask('offline-test', offlineTest); + + grunt.registerTask('build', [ + 'composer-install', + 'npm-install', + 'set-config-params', + 'offline', + ]); + + grunt.registerTask('default', [ + 'build', + ]); + + grunt.registerTask('release', [ + 'build', + 'upgrade', + 'zip', + 'clean:release', + ]); + + grunt.registerTask('run-tests', [ + 'test', + 'unit-tests-run', + 'integration-tests-run', + ]); + + grunt.registerTask('run-unit-tests', [ + 'composer-install-dev', + 'unit-tests-run', + ]); + + grunt.registerTask('run-integration-tests', [ + 'test', + 'integration-tests-run', + ]); + + grunt.registerTask('dev', [ + 'composer-install-dev', + 'npm-install', + 'internal', + ]); + + grunt.registerTask('test', [ + 'composer-install-dev', + 'npm-install', + 'offline-test', + ]); +}; diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..bae94e1 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + 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 . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8db5d57 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +## EspoCRM + +[![PHPStan level 8](https://img.shields.io/badge/PHPStan-level%208-brightgreen)](#espocrm) + +[EspoCRM](https://www.espocrm.com) is a free, open-source CRM platform designed to help organizations build and maintain strong customer relationships. +It provides a wide range of tools to store, organize, and manage leads, contacts, sales opportunities, marketing campaigns, +support cases, and more – all business information in a simple and intuitive interface. + +![Screenshot](https://github.com/user-attachments/assets/d0806394-3691-43a1-83a5-16ad2e7314e2) + + +### Architecture + +EspoCRM is a web application with a frontend designed as a single-page application and a REST API +backend written in PHP. + +### Demo + +You can try the CRM on an online [demo](https://www.espocrm.com/demo/). + +### Requirements + +* PHP 8.3 - 8.5; +* MySQL 8.0 (and later), or MariaDB 10.3 (and later); +* PostgreSQL 15 (and later). + +For more information about server configuration, see [this article](https://docs.espocrm.com/administration/server-configuration/). + +### Download + +[Download](https://www.espocrm.com/download/) the latest release from our website or from GitHub [releases](https://github.com/espocrm/espocrm/releases). + +### Release notes + +Release notes are available at GitHub [releases](https://github.com/espocrm/espocrm/releases). + +### Documentation + +See the [documentation](https://docs.espocrm.com) for administrators, users and developers. + +### Why EspoCRM? + +* Open-source transparency. EspoCRM's source code is open and accessible, so anyone can inspect it and see how data is being managed within the CRM. +* Customization freedom. You can develop features, create custom entities, fields, relationships, buttons to make the system fit your specific needs. EspoCRM is more than a CRM – it's a platform for building custom business applications. +* Clean user interface. EspoCRM offers an uncluttered, minimalist, and fast user interface, which is easy to navigate and has a short learning curve. +* Straightforward REST API. It can be easily integrated with other applications using a REST API. + +### Who is EspoCRM for? + +* From startups, small & medium-sized businesses to larger organizations. A flexible, fully customizable solution that scales with your needs. +* Developers & tech enthusiasts. You can extend functionalities, build extensions, and create custom integrations. +* Anyone seeking a free or on-premise CRM. + +### Installing stable version + +See installation instructions: + +* [Manual installation](https://docs.espocrm.com/administration/installation/) +* [Installation by script](https://docs.espocrm.com/administration/installation-by-script/) +* [Installation with Docker](https://docs.espocrm.com/administration/docker/installation/) +* [Installation with Traefik](https://docs.espocrm.com/administration/docker/traefik/) + +### Bug reporting + +Create a [GitHub issue](https://github.com/espocrm/espocrm/issues/new/choose) or post on our [forum](https://forum.espocrm.com/forum/bug-reports). + +### Development + +See the [developer documentation](https://docs.espocrm.com/development/). + +We highly recommend using an IDE for development. The backend codebase adheres to SOLID principles, utilizes interfaces, static typing and generics. We recommend to start learning EspoCRM from the Dependency Injection article in the documentation. + +Metadata plays an integral role in the EspoCRM application. All possible parameters are described with a JSON Schema, meaning you will have autocompletion in the IDE. You can also find the full metadata reference in the documentation. + +The frontend is an SPA built on a custom framework. It utilizes nested views and service DI, with the core partially written in TypeScript. Developers primarily work with existing form and field view implementations. + +### Community & Support + +If you have a question regarding some features, need help or customizations, want to get in touch with other EspoCRM users, or add a feature request, please use our [community forum](https://forum.espocrm.com/). We believe that using the forum to ask for help and share experience allows everyone in the community to contribute and use this knowledge later. + +### License + +EspoCRM is an open-source project licensed under [GNU AGPLv3](https://raw.githubusercontent.com/espocrm/espocrm/master/LICENSE.txt). + +### Contributing + +Before we can merge your pull request, you need to accept our CLA [here](https://github.com/espocrm/cla). See the [contributing guidelines](https://github.com/espocrm/espocrm/blob/master/.github/CONTRIBUTING.md). + +Branches: + +* *fix* – upcoming maintenance release; minor fixes should be pushed to this branch; +* *master* – develop branch; new features should be pushed to this branch; +* *stable* – last stable release. + +### Language + +If you want to improve existing translation or add a language that is not available yet, you can contribute on our [POEditor](https://poeditor.com/join/project/gLDKZtUF4i) project. See instructions [here](https://www.espocrm.com/blog/how-to-use-poeditor-to-translate-espocrm/). It may be reasonable to let us know about your intention to join the POEditor project by posting on our forum or via the contact form on our website. + +Changes on POEditor are usually merged to the GitHub repository before minor releases. diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php new file mode 100644 index 0000000..83cc556 --- /dev/null +++ b/application/Espo/Binding.php @@ -0,0 +1,497 @@ +. + * + * 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; + +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Binding\BindingProcessor; +use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\QualifiedClassKey; +use Espo\Core\Container; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Cache\DataCacheAccess; +use Espo\Core\Utils\Cache\DataCacheServiceName as DataCacheServiceName; +use Espo\Core\Utils\Cache\FileCacheItemPool; +use Espo\Core\Utils\DataCache; +use Psr\Cache\CacheItemPoolInterface; + +/** + * Default binding for the dependency injection framework. Custom binding should be set up in + * `Espo\Modules\{ModuleName}\Binding` or `Espo\Custom\Binding`. + * + * @link https://docs.espocrm.com/development/di/#binding. + */ +class Binding implements BindingProcessor +{ + public function process(Binder $binder): void + { + $this->bindServices($binder); + $this->bindCore($binder); + $this->bindMisc($binder); + $this->bindAcl($binder); + $this->bindWebSocket($binder); + $this->bindEmailAccount($binder); + } + + private function bindServices(Binder $binder): void + { + $binder->bindService( + 'Espo\\Core\\Application\\ApplicationParams', + 'applicationParams' + ); + + $binder->bindService( + 'Espo\\Core\\InjectableFactory', + 'injectableFactory' + ); + + $binder->bindService( + 'Espo\\Core\\Container', + 'container' + ); + + $binder->bindService( + 'Psr\\Container\\ContainerInterface', + 'container' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Module', + 'module' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Config', + 'config' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\File\\Manager', + 'fileManager' + ); + + $this->bindDataCacheServices($binder); + + $binder->bindService( + 'Espo\\ORM\\EntityManager', + 'entityManager' + ); + + $binder->bindService( + 'Espo\\Core\\ORM\\EntityManager', + 'entityManager' + ); + + $binder->bindService( + 'Espo\\ORM\\Defs', + 'ormDefs' + ); + + $binder->bindService( + 'Espo\\Core\\DataManager', + 'dataManager' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Metadata', + 'metadata' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Log', + 'log' + ); + + $binder->bindService( + 'Espo\\Core\\ApplicationState', + 'applicationState' + ); + + $binder->bindService( + 'Espo\\Core\\ApplicationUser', + 'applicationUser' + ); + + $binder->bindService( + 'Espo\\Core\\Authentication\\AuthToken\\Manager', + 'authTokenManager' + ); + + $binder->bindService( + 'Espo\\Core\\ServiceFactory', + 'serviceFactory' + ); + + $binder->bindService( + 'Espo\\Core\\Record\\ServiceContainer', + 'recordServiceContainer' + ); + + $binder->bindService( + 'Espo\\Core\\Hook\\DataProvider', + 'hookDataProvider' + ); + + $binder->bindService( + 'Espo\\Core\\HookManager', + 'hookManager' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\NumberUtil', + 'number' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\DateTime', + 'dateTime' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\FieldUtil', + 'fieldUtil' + ); + + $binder->bindService( + 'Espo\\Core\\Mail\\EmailSender', + 'emailSender' + ); + + $binder->bindService( + NamedClassKey::create('Espo\\Core\\Utils\\Language', 'baseLanguage'), + 'baseLanguage' + ); + + $binder->bindService( + QualifiedClassKey::create('Espo\\Core\\Utils\\Language', 'base'), + 'baseLanguage' + ); + + $binder->bindService( + NamedClassKey::create('Espo\\Core\\Utils\\Language', 'defaultLanguage'), + 'defaultLanguage' + ); + + $binder->bindService( + QualifiedClassKey::create('Espo\\Core\\Utils\\Language', 'default'), + 'defaultLanguage' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Language', + 'language' + ); + + $binder->bindService( + QualifiedClassKey::create('Espo\\Core\\Utils\\Language', 'user'), + 'language' + ); + + $binder->bindService( + 'Espo\\Core\\Formula\\Manager', + 'formulaManager' + ); + + $binder->bindService( + NamedClassKey::create('Espo\\Core\\AclManager', 'internalAclManager'), + 'internalAclManager' + ); + + $binder->bindService( + 'Espo\\Core\\AclManager', + 'aclManager' + ); + + $binder->bindService( + 'Espo\\Core\\Acl', + 'acl' + ); + + $binder->bindService( + 'Espo\\Entities\\Preferences', + 'preferences' + ); + + $binder->bindService( + 'Espo\\Entities\\User', + 'user' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\ClientManager', + 'clientManager' + ); + + $binder->bindService( + 'Espo\\Core\\ExternalAccount\\ClientManager', + 'externalAccountClientManager' + ); + + $binder->bindService( + 'Espo\\Core\\WebSocket\\Submission', + 'webSocketSubmission' + ); + + $binder->bindService( + 'Espo\\Tools\\Stream\\Service', + 'streamService' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Config\\SystemConfig', + 'systemConfig' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Config\\ApplicationConfig', + 'applicationConfig' + ); + + $binder->bindService( + 'Espo\\Core\\Session\\Session', + 'session' + ); + + $binder->bindService( + 'Espo\\Core\\Job\\JobManager', + 'jobManager' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\System\\SystemState', + 'systemState' + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Event\\Configuration', + 'eventDispatcherConfiguration', + ); + + $binder->bindService( + 'Espo\\Core\\Utils\\Event\\EventTransport', + 'eventTransport' + ); + + $binder->bindService( + 'Espo\\Tools\\Pipeline\\PipelineDataProvider', + 'pipelineDataProvider' + ); + } + + private function bindCore(Binder $binder): void + { + $binder->bindImplementation( + 'Espo\\ORM\\PDO\\PDOProvider', + 'Espo\\ORM\\PDO\\DefaultPDOProvider' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Utils\\Database\\ConfigDataProvider', + 'Espo\\Core\\Utils\\Database\\DefaultConfigDataProvider' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Job\\JobScheduler\\Creator', + 'Espo\\Core\\Job\\JobScheduler\\Creators\\EntityCreator', + ); + + $binder->inContext('Espo\\Core\\Job\\JobManager', function ($binder) { + $binder->bindImplementation( + 'Espo\\Core\\Job\\QueueProcessor', + 'Espo\\Core\\Job\\QueueProcessor\\QueueProcessors\\DefaultQueueProcessor', + ); + }); + + $binder->inContext('Espo\\Core\\Job\\Processing\\EnqueueDaemon', function ($binder) { + $binder->bindImplementation( + 'Espo\\Core\\Job\\Processing\\Publisher', + 'Espo\\Core\\Job\\Processing\\RabbitMq\\Publisher', + ); + }); + + $binder->inContext('Espo\\Core\\Job\\Processing\\WorkerDaemon', function ($binder) { + $binder->bindImplementation( + 'Espo\\Core\\Job\\Processing\\Consumer', + 'Espo\\Core\\Job\\Processing\\RabbitMq\\Consumer', + ); + }); + + $binder->bindImplementation( + CacheItemPoolInterface::class, + FileCacheItemPool::class + ); + } + + private function bindMisc(Binder $binder): void + { + $binder->bindImplementation( + 'Espo\\Core\\Utils\\DateTime\\Clock', + 'Espo\\Core\\Utils\\DateTime\\SystemClock' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Utils\\Id\\RecordIdGenerator', + 'Espo\\Core\\Utils\\Id\\DefaultRecordIdGenerator' + ); + + $binder->bindFactory( + 'Espo\\Core\\Sms\\Sender', + 'Espo\\Core\\Sms\\SenderFactory' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Authentication\\Jwt\\KeyFactory', + 'Espo\\Core\\Authentication\\Jwt\\DefaultKeyFactory' + ); + + $binder + ->for('Espo\\Core\\Authentication\\Oidc\\TokenValidator') + ->bindImplementation( + 'Espo\\Core\\Authentication\\Jwt\\SignatureVerifierFactory', + 'Espo\\Core\\Authentication\\Oidc\\DefaultSignatureVerifierFactory' + ); + + $binder + ->for('Espo\\Core\\Authentication\\Oidc\\Login') + ->bindImplementation( + 'Espo\\Core\\Authentication\\Oidc\\UserProvider', + 'Espo\\Core\\Authentication\\Oidc\\UserProvider\\DefaultUserProvider' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Authentication\\Oidc\\UserProvider\\UserInfoPopulator', + 'Espo\\Core\\Authentication\\Oidc\\UserProvider\\DefaultUserInfoPopulator' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Mail\\Importer\\ParentFinder', + 'Espo\\Core\\Mail\\Importer\\DefaultParentFinder' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Mail\\Importer\\DuplicateFinder', + 'Espo\\Core\\Mail\\Importer\\DefaultDuplicateFinder' + ); + + $binder->bindImplementation( + 'Espo\\Tools\\Api\\Cors\\Helper', + 'Espo\\Tools\\Api\\Cors\\DefaultHelper' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Record\\ActionHistory\\ActionLogger', + 'Espo\\Core\\Record\\ActionHistory\\DefaultActionLogger' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Mail\\Importer', + 'Espo\\Core\\Mail\\Importer\\DefaultImporter' + ); + + $binder->bindImplementation( + 'Espo\\Core\\Mail\\Importer\\AutoReplyDetector', + 'Espo\\Core\\Mail\\Importer\\DefaultAutoReplyDetector' + ); + } + + private function bindAcl(Binder $binder): void + { + $binder->bindImplementation( + 'Espo\\Core\\Acl\\Table\\TableFactory', + 'Espo\\Core\\Acl\\Table\\DefaultTableFactory' + ); + } + + private function bindWebSocket(Binder $binder): void + { + $binder->bindFactory( + 'Espo\\Core\\WebSocket\\Subscriber', + 'Espo\\Core\\WebSocket\\SubscriberFactory' + ); + + $binder->bindFactory( + 'Espo\\Core\\WebSocket\\Sender', + 'Espo\\Core\\WebSocket\\SenderFactory' + ); + } + + private function bindEmailAccount(Binder $binder): void + { + $binder + ->for('Espo\\Core\\Mail\\Account\\PersonalAccount\\Service') + ->bindFactory( + 'Espo\\Core\\Mail\\Account\\Fetcher', + 'Espo\\Core\\Mail\\Account\\PersonalAccount\\FetcherFactory' + ) + ->bindImplementation( + 'Espo\\Core\\Mail\\Account\\StorageFactory', + 'Espo\\Core\\Mail\\Account\\PersonalAccount\\StorageFactory' + ); + + $binder + ->for('Espo\\Core\\Mail\\Account\\GroupAccount\\Service') + ->bindFactory( + 'Espo\\Core\\Mail\\Account\\Fetcher', + 'Espo\\Core\\Mail\\Account\\GroupAccount\\FetcherFactory' + ) + ->bindImplementation( + 'Espo\\Core\\Mail\\Account\\StorageFactory', + 'Espo\\Core\\Mail\\Account\\GroupAccount\\StorageFactory' + ); + } + + private function bindDataCacheServices(Binder $binder): void + { + $binder->bindService( + DataCache::class, + DataCacheServiceName::APPLICATION + ); + + $binder->bindService( + QualifiedClassKey::create(DataCache::class, DataCache::QUALIFIER_SYSTEM), + DataCacheServiceName::SYSTEM + ); + + $binder->bindCallback( + QualifiedClassKey::create(DataCacheAccess::class, DataCache::QUALIFIER_SYSTEM), + function (InjectableFactory $injectableFactory, Container $container) { + return $injectableFactory->createWithBinding( + DataCacheAccess::class, + BindingContainerBuilder::create() + ->bindInstance( + DataCache::class, + $container->get(DataCacheServiceName::SYSTEM) + ) + ->build() + ); + } + ); + } +} diff --git a/application/Espo/Classes/Acl/ActionHistoryRecord/OwnershipChecker.php b/application/Espo/Classes/Acl/ActionHistoryRecord/OwnershipChecker.php new file mode 100644 index 0000000..39449b1 --- /dev/null +++ b/application/Espo/Classes/Acl/ActionHistoryRecord/OwnershipChecker.php @@ -0,0 +1,46 @@ +. + * + * 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\Acl\ActionHistoryRecord; + +use Espo\Entities\ActionHistoryRecord; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + public function checkOwn(User $user, Entity $entity): bool + { + return $entity->get('userId') === $user->getId(); + } +} diff --git a/application/Espo/Classes/Acl/Attachment/AccessChecker.php b/application/Espo/Classes/Acl/Attachment/AccessChecker.php new file mode 100644 index 0000000..e40d046 --- /dev/null +++ b/application/Espo/Classes/Acl/Attachment/AccessChecker.php @@ -0,0 +1,158 @@ +. + * + * 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\Acl\Attachment; + +use Espo\Core\Name\Field; +use Espo\Entities\Attachment; +use Espo\Entities\Note; +use Espo\Entities\Settings; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; +use Espo\Core\AclManager; +use Espo\Core\ORM\EntityManager; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + use DefaultAccessCheckerDependency; + + public function __construct( + DefaultAccessChecker $defaultAccessChecker, + private AclManager $aclManager, + private EntityManager $entityManager + ) { + $this->defaultAccessChecker = $defaultAccessChecker; + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if ($entity->getParentType() === Settings::ENTITY_TYPE) { + // Allow the logo. + return true; + } + + $parent = null; + + $parentType = $entity->getParentType(); + $parentId = $entity->getParent()?->getId(); + + $relatedType = $entity->getRelatedType(); + $relatedId = $entity->getRelated()?->getId(); + + if ($parentId && $parentType) { + $parent = $this->entityManager->getEntityById($parentType, $parentId); + } else if ($relatedId && $relatedType) { + $parent = $this->entityManager->getEntityById($relatedType, $relatedId); + } + + if (!$parent) { + if ($this->defaultAccessChecker->checkEntityRead($user, $entity, $data)) { + return true; + } + + return false; + } + + if ($parent->getEntityType() === Note::ENTITY_TYPE) { + /** @var Note $parent */ + $result = $this->checkEntityReadNoteParent($user, $parent); + + if ($result !== null) { + return $result; + } + } else if ($this->aclManager->checkEntity($user, $parent)) { + if ( + $entity->getTargetField() && + !$this->aclManager->checkField($user, $parent->getEntityType(), $entity->getTargetField()) + ) { + return false; + } + + return true; + } + + if ($this->defaultAccessChecker->checkEntityRead($user, $entity, $data)) { + return true; + } + + return false; + } + + private function checkEntityReadNoteParent(User $user, Note $note): ?bool + { + if ($note->getTargetType() === Note::TARGET_TEAMS) { + $intersect = array_intersect( + $note->getLinkMultipleIdList(Field::TEAMS), + $user->getLinkMultipleIdList(Field::TEAMS) + ); + + if (count($intersect)) { + return true; + } + + return null; + } + + if ($note->getTargetType() === Note::TARGET_USERS) { + $isRelated = $this->entityManager + ->getRDBRepository(Note::ENTITY_TYPE) + ->getRelation($note, 'users') + ->isRelated($user); + + if ($isRelated) { + return true; + } + + return null; + } + + if ($note->getTargetType() === Note::TARGET_ALL) { + return true; + } + + if (!$note->getParentId() || !$note->getParentType()) { + return null; + } + + $parent = $this->entityManager->getEntityById($note->getParentType(), $note->getParentId()); + + if ($parent && $this->aclManager->checkEntity($user, $parent)) { + return true; + } + + return null; + } +} diff --git a/application/Espo/Classes/Acl/Attachment/OwnershipChecker.php b/application/Espo/Classes/Acl/Attachment/OwnershipChecker.php new file mode 100644 index 0000000..ed29aad --- /dev/null +++ b/application/Espo/Classes/Acl/Attachment/OwnershipChecker.php @@ -0,0 +1,52 @@ +. + * + * 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\Acl\Attachment; + +use Espo\Entities\Attachment; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + private const ATTR_CREATED_BY_ID = 'createdById'; + + public function checkOwn(User $user, Entity $entity): bool + { + if ($user->getId() === $entity->get(self::ATTR_CREATED_BY_ID)) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/Acl/AuthToken/AccessChecker.php b/application/Espo/Classes/Acl/AuthToken/AccessChecker.php new file mode 100644 index 0000000..2735d27 --- /dev/null +++ b/application/Espo/Classes/Acl/AuthToken/AccessChecker.php @@ -0,0 +1,55 @@ +. + * + * 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\Acl\AuthToken; + +use Espo\Entities\AuthToken; +use Espo\Entities\User; +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + use DefaultAccessCheckerDependency; + + public function __construct(DefaultAccessChecker $defaultAccessChecker) + { + $this->defaultAccessChecker = $defaultAccessChecker; + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return false; + } +} diff --git a/application/Espo/Classes/Acl/Common/Pipeline/PipelineLinkChecker.php b/application/Espo/Classes/Acl/Common/Pipeline/PipelineLinkChecker.php new file mode 100644 index 0000000..1a2e172 --- /dev/null +++ b/application/Espo/Classes/Acl/Common/Pipeline/PipelineLinkChecker.php @@ -0,0 +1,58 @@ +. + * + * 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\Acl\Common\Pipeline; + +use Espo\Core\Acl\LinkChecker; +use Espo\Entities\Pipeline; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements LinkChecker + */ +class PipelineLinkChecker implements LinkChecker +{ + public function check(User $user, Entity $entity, Entity $foreignEntity): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + if ($foreignEntity->isAvailableForAll()) { + return true; + } + + if (!$user->isPortal()) { + return false; + } + + return array_intersect($user->getTeamIdList(), $foreignEntity->getTeams()->getIdList()) !== []; + } +} diff --git a/application/Espo/Classes/Acl/Common/Pipeline/PipelineStageLinkChecker.php b/application/Espo/Classes/Acl/Common/Pipeline/PipelineStageLinkChecker.php new file mode 100644 index 0000000..bbe5b5b --- /dev/null +++ b/application/Espo/Classes/Acl/Common/Pipeline/PipelineStageLinkChecker.php @@ -0,0 +1,57 @@ +. + * + * 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\Acl\Common\Pipeline; + +use Espo\Core\Acl\LinkChecker; +use Espo\Core\Name\Field; +use Espo\Entities\PipelineStage; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements LinkChecker + */ +class PipelineStageLinkChecker implements LinkChecker +{ + public function check(User $user, Entity $entity, Entity $foreignEntity): bool + { + $pipelineId = $entity->get(Field::PIPELINE . 'Id'); + + if (!$pipelineId) { + return false; + } + + if ($pipelineId !== $foreignEntity->getPipeline()->getId()) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Classes/Acl/CurrencyRecordRate/AccessChecker.php b/application/Espo/Classes/Acl/CurrencyRecordRate/AccessChecker.php new file mode 100644 index 0000000..8cf0c65 --- /dev/null +++ b/application/Espo/Classes/Acl/CurrencyRecordRate/AccessChecker.php @@ -0,0 +1,79 @@ +. + * + * 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\Acl\CurrencyRecordRate; + +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Table; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; +use Espo\Entities\CurrencyRecordRate; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + use DefaultAccessCheckerDependency; + + public function __construct( + private DefaultAccessChecker $defaultAccessChecker, + ) {} + + public function checkCreate(User $user, ScopeData $data): bool + { + if ($data->getEdit() === Table::LEVEL_YES) { + return true; + } + + return false; + } + + public function checkDelete(User $user, ScopeData $data): bool + { + if ($data->getEdit() === Table::LEVEL_YES) { + return true; + } + + return false; + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkCreate($user, $data); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkDelete($user, $data); + } +} diff --git a/application/Espo/Classes/Acl/Email/AccessChecker.php b/application/Espo/Classes/Acl/Email/AccessChecker.php new file mode 100644 index 0000000..40c20d8 --- /dev/null +++ b/application/Espo/Classes/Acl/Email/AccessChecker.php @@ -0,0 +1,170 @@ +. + * + * 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\Acl\Email; + +use Espo\Core\Name\Field; +use Espo\Entities\User; +use Espo\Entities\Email; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessEntityCREDSChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Table; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; + +/** + * @implements AccessEntityCREDSChecker + */ +class AccessChecker implements AccessEntityCREDSChecker +{ + use DefaultAccessCheckerDependency; + + public function __construct(DefaultAccessChecker $defaultAccessChecker) + { + $this->defaultAccessChecker = $defaultAccessChecker; + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + /** @var Email $entity */ + + if ($this->defaultAccessChecker->checkEntityRead($user, $entity, $data)) { + return true; + } + + if ($data->isFalse()) { + return false; + } + + if ($data->getRead() === Table::LEVEL_NO) { + return false; + } + + if (!$entity->has('usersIds')) { + $entity->loadLinkMultipleField('users'); + } + + $userIdList = $entity->get('usersIds'); + + if (is_array($userIdList) && in_array($user->getId(), $userIdList)) { + return true; + } + + return false; + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + /** @var Email $entity */ + + if ($user->isEffectiveAdmin()) { + return true; + } + + if ($data->isFalse()) { + return false; + } + + if ($data->getDelete() === Table::LEVEL_OWN) { + if ($user->getId() === $entity->get('assignedUserId')) { + return true; + } + + if ($user->getId() === $entity->get('createdById')) { + return true; + } + + $assignedUserIdList = $entity->getLinkMultipleIdList(Field::ASSIGNED_USERS); + + if ( + count($assignedUserIdList) === 1 && + $entity->hasLinkMultipleId(Field::ASSIGNED_USERS, $user->getId()) + ) { + return true; + } + + return false; + } + + if ($this->defaultAccessChecker->checkEntityDelete($user, $entity, $data)) { + return true; + } + + if ($data->getEdit() === Table::LEVEL_NO && $data->getCreate() === Table::LEVEL_NO) { + return false; + } + + if ($entity->get('createdById') !== $user->getId()) { + return false; + } + + if ( + $entity->getStatus() !== Email::STATUS_SENT && + $entity->getStatus() !== Email::STATUS_ARCHIVED + ) { + return true; + } + + return false; + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + /** @var Email $entity */ + + if ( + $entity->getStatus() === Email::STATUS_DRAFT && + $entity->getCreatedBy() && + $entity->getCreatedBy()->getId() === $user->getId() + ) { + return true; + } + + return $this->defaultAccessChecker->checkEntityEdit($user, $entity, $data); + } + + public function checkEdit(User $user, ScopeData $data): bool + { + if ($data->getCreate() === Table::LEVEL_YES) { + return true; + } + + return $this->defaultAccessChecker->checkEdit($user, $data); + } + + public function checkDelete(User $user, ScopeData $data): bool + { + if ($data->getCreate() === Table::LEVEL_YES) { + return true; + } + + return $this->defaultAccessChecker->checkDelete($user, $data); + } +} diff --git a/application/Espo/Classes/Acl/Email/AssignmentChecker.php b/application/Espo/Classes/Acl/Email/AssignmentChecker.php new file mode 100644 index 0000000..6b2c9c1 --- /dev/null +++ b/application/Espo/Classes/Acl/Email/AssignmentChecker.php @@ -0,0 +1,58 @@ +. + * + * 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\Acl\Email; + +use Espo\Core\Acl\AssignmentChecker as AssignmentCheckerInterface; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements AssignmentCheckerInterface + */ +class AssignmentChecker implements AssignmentCheckerInterface +{ + public function __construct( + private AssignmentCheckerInterface\Helper $helper, + ) {} + + public function check(User $user, Entity $entity): bool + { + if ($entity->getAssignedUser() && !$this->helper->checkAssignedUser($user, $entity)) { + return false; + } + + if ($entity->getTeams()->getIdList() !== [] && !$this->helper->checkTeams($user, $entity)) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Classes/Acl/Email/LinkCheckers/ParentLinkChecker.php b/application/Espo/Classes/Acl/Email/LinkCheckers/ParentLinkChecker.php new file mode 100644 index 0000000..f6b80ea --- /dev/null +++ b/application/Espo/Classes/Acl/Email/LinkCheckers/ParentLinkChecker.php @@ -0,0 +1,72 @@ +. + * + * 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\Acl\Email\LinkCheckers; + +use Espo\Core\Acl\LinkChecker; +use Espo\Core\AclManager; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements LinkChecker + * @noinspection PhpUnused + */ +class ParentLinkChecker implements LinkChecker +{ + public function __construct( + private AclManager $aclManager + ) {} + + public function check(User $user, Entity $entity, Entity $foreignEntity): bool + { + if ($this->aclManager->checkEntityRead($user, $foreignEntity)) { + return true; + } + + $replied = $entity->getReplied(); + + if (!$replied) { + return false; + } + + $parent = $replied->getParent(); + + if ( + !$parent || + $parent->getId() !== $foreignEntity->getId() || + $parent->getEntityType() !== $foreignEntity->getEntityType() + ) { + return false; + } + + return $this->aclManager->checkEntityRead($user, $replied); + } +} diff --git a/application/Espo/Classes/Acl/Email/LinkCheckers/TeamsLinkChecker.php b/application/Espo/Classes/Acl/Email/LinkCheckers/TeamsLinkChecker.php new file mode 100644 index 0000000..b9068e4 --- /dev/null +++ b/application/Espo/Classes/Acl/Email/LinkCheckers/TeamsLinkChecker.php @@ -0,0 +1,67 @@ +. + * + * 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\Acl\Email\LinkCheckers; + +use Espo\Core\Acl\LinkChecker; +use Espo\Core\AclManager; +use Espo\Entities\Email; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements LinkChecker + * @noinspection PhpUnused + */ +class TeamsLinkChecker implements LinkChecker +{ + public function __construct( + private AclManager $aclManager + ) {} + + public function check(User $user, Entity $entity, Entity $foreignEntity): bool + { + if ($this->aclManager->checkEntityRead($user, $foreignEntity)) { + return true; + } + + $replied = $entity->getReplied(); + + if (!$replied) { + return false; + } + + if ($replied->getTeams()->hasId($foreignEntity->getId())) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/Acl/Email/OwnershipChecker.php b/application/Espo/Classes/Acl/Email/OwnershipChecker.php new file mode 100644 index 0000000..4c5e444 --- /dev/null +++ b/application/Espo/Classes/Acl/Email/OwnershipChecker.php @@ -0,0 +1,69 @@ +. + * + * 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\Acl\Email; + +use Espo\Entities\User; +use Espo\Entities\Email; +use Espo\ORM\Entity; +use Espo\Core\Acl\DefaultOwnershipChecker; +use Espo\Core\Acl\OwnershipOwnChecker; +use Espo\Core\Acl\OwnershipTeamChecker; + +/** + * @implements OwnershipOwnChecker + * @implements OwnershipTeamChecker + */ +class OwnershipChecker implements OwnershipOwnChecker, OwnershipTeamChecker +{ + public function __construct(private DefaultOwnershipChecker $defaultOwnershipChecker) + {} + + public function checkOwn(User $user, Entity $entity): bool + { + if ($user->getId() === $entity->getAssignedUser()?->getId()) { + return true; + } + + if ($user->getId() === $entity->getCreatedBy()?->getId()) { + return true; + } + + if ($entity->getAssignedUsers()->hasId($user->getId())) { + return true; + } + + return false; + } + + public function checkTeam(User $user, Entity $entity): bool + { + return $this->defaultOwnershipChecker->checkTeam($user, $entity); + } +} diff --git a/application/Espo/Classes/Acl/EmailFilter/OwnershipChecker.php b/application/Espo/Classes/Acl/EmailFilter/OwnershipChecker.php new file mode 100644 index 0000000..4dcc4dd --- /dev/null +++ b/application/Espo/Classes/Acl/EmailFilter/OwnershipChecker.php @@ -0,0 +1,87 @@ +. + * + * 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\Acl\EmailFilter; + +use Espo\Entities\EmailAccount; +use Espo\Entities\User; +use Espo\Entities\EmailFilter; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; +use Espo\Core\ORM\EntityManager; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + private EntityManager $entityManager; + + public function __construct(EntityManager $entityManager) + { + $this->entityManager = $entityManager; + } + + /** + * @param EmailFilter $entity + */ + public function checkOwn(User $user, Entity $entity): bool + { + if ($entity->isGlobal()) { + return false; + } + + $parentType = $entity->getParentType(); + $parentId = $entity->getParentId(); + + if (!$parentType || !$parentId) { + return false; + } + + $parent = $this->entityManager->getEntityById($parentType, $parentId); + + if (!$parent) { + return false; + } + + if ($parent->getEntityType() === User::ENTITY_TYPE) { + return $parent->getId() === $user->getId(); + } + + if ( + $parent instanceof EmailAccount && + $parent->has('assignedUserId') && + $parent->get('assignedUserId') === $user->getId() + ) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/Acl/Import/AccessChecker.php b/application/Espo/Classes/Acl/Import/AccessChecker.php new file mode 100644 index 0000000..60985fc --- /dev/null +++ b/application/Espo/Classes/Acl/Import/AccessChecker.php @@ -0,0 +1,85 @@ +. + * + * 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\Acl\Import; + +use Espo\Entities\Import; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessEntityDeleteChecker; +use Espo\Core\Acl\AccessEntityReadChecker; +use Espo\Core\Acl\ScopeData; + +/** + * @implements AccessEntityReadChecker + * @implements AccessEntityDeleteChecker + */ +class AccessChecker implements AccessEntityReadChecker, AccessEntityDeleteChecker +{ + public function check(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkRead(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkDelete(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + if ($user->getId() === $entity->get('createdById')) { + return true; + } + + return false; + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + if ($user->getId() === $entity->get('createdById')) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/Acl/ImportEml/AccessChecker.php b/application/Espo/Classes/Acl/ImportEml/AccessChecker.php new file mode 100644 index 0000000..8dde1fd --- /dev/null +++ b/application/Espo/Classes/Acl/ImportEml/AccessChecker.php @@ -0,0 +1,47 @@ +. + * + * 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\Acl\ImportEml; + +use Espo\Core\Acl\AccessCreateChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Entities\User; + +class AccessChecker implements AccessCreateChecker +{ + public function check(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } +} diff --git a/application/Espo/Classes/Acl/Note/AccessChecker.php b/application/Espo/Classes/Acl/Note/AccessChecker.php new file mode 100644 index 0000000..ee28660 --- /dev/null +++ b/application/Espo/Classes/Acl/Note/AccessChecker.php @@ -0,0 +1,235 @@ +. + * + * 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\Acl\Note; + +use Espo\Core\Acl\Permission; +use Espo\Core\Acl\Table; +use Espo\Core\Name\Field; +use Espo\Entities\Note; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; +use Espo\Core\AclManager; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Config; + +use DateTime; +use Exception; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + use DefaultAccessCheckerDependency; + + private const EDIT_PERIOD = '7 days'; + private const DELETE_PERIOD = '1 month'; + + private DefaultAccessChecker $defaultAccessChecker; + private AclManager $aclManager; + private EntityManager $entityManager; + private Config $config; + + public function __construct( + DefaultAccessChecker $defaultAccessChecker, + AclManager $aclManager, + EntityManager $entityManager, + Config $config + ) { + $this->defaultAccessChecker = $defaultAccessChecker; + $this->aclManager = $aclManager; + $this->entityManager = $entityManager; + $this->config = $config; + } + + /** + * @param Note $entity + */ + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + $parentId = $entity->get('parentId'); + $parentType = $entity->get('parentType'); + + if (!$parentId || !$parentType) { + return true; + } + + $parent = $this->entityManager->getEntityById($parentType, $parentId); + + if ($parent && $this->aclManager->checkEntityStream($user, $parent)) { + return true; + } + + return false; + } + + /** + * @param Note $entity + */ + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + $parentId = $entity->getParentId(); + $parentType = $entity->getParentType(); + + if ($parentId && $parentType) { + $parent = $this->entityManager->getEntityById($parentType, $parentId); + + if (!$parent) { + return false; + } + + return $this->aclManager->checkEntityStream($user, $parent); + } + + if ($entity->getType() !== Note::TYPE_POST) { + return false; + } + + if ($entity->getCreatedById() === $user->getId()) { + return true; + } + + if ($entity->getTargetType() === Note::TARGET_ALL) { + return true; + } + + if ($entity->getTargetType() === Note::TARGET_TEAMS) { + $targetTeamIdList = $entity->getLinkMultipleIdList(Field::TEAMS); + + foreach ($user->getTeamIdList() as $teamId) { + if (in_array($teamId, $targetTeamIdList)) { + return true; + } + } + + return false; + } + + if ($entity->getTargetType() === Note::TARGET_USERS) { + return in_array($user->getId(), $entity->getLinkMultipleIdList('users')); + } + + if ($entity->getTargetType() === Note::TARGET_PORTALS) { + return $this->aclManager->getPermissionLevel($user, Permission::PORTAL) === Table::LEVEL_YES; + } + + return false; + } + + /** + * @param Note $entity + */ + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + if (!$this->defaultAccessChecker->checkEntityEdit($user, $entity, $data)) { + return false; + } + + if (!$this->aclManager->checkOwnershipOwn($user, $entity)) { + return false; + } + + $createdAt = $entity->get(Field::CREATED_AT); + + if (!$createdAt) { + return true; + } + + $noteEditThresholdPeriod = + '-' . $this->config->get('noteEditThresholdPeriod', self::EDIT_PERIOD); + + $dt = new DateTime(); + + $dt->modify($noteEditThresholdPeriod); + + try { + if ($dt->format('U') > (new DateTime($createdAt))->format('U')) { + return false; + } + } catch (Exception $e) { + return false; + } + + return true; + } + + /** + * @param Note $entity + */ + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + if (!$this->defaultAccessChecker->checkEntityDelete($user, $entity, $data)) { + return false; + } + + if (!$this->aclManager->checkOwnershipOwn($user, $entity)) { + return false; + } + + $createdAt = $entity->get(Field::CREATED_AT); + + if (!$createdAt) { + return true; + } + + $deleteThresholdPeriod = + '-' . $this->config->get('noteDeleteThresholdPeriod', self::DELETE_PERIOD); + + $dt = new DateTime(); + + $dt->modify($deleteThresholdPeriod); + + try { + if ($dt->format('U') > (new DateTime($createdAt))->format('U')) { + return false; + } + } catch (Exception $e) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Classes/Acl/Note/OwnershipChecker.php b/application/Espo/Classes/Acl/Note/OwnershipChecker.php new file mode 100644 index 0000000..9ed0a3c --- /dev/null +++ b/application/Espo/Classes/Acl/Note/OwnershipChecker.php @@ -0,0 +1,53 @@ +. + * + * 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\Acl\Note; + +use Espo\Entities\Note; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + /** + * @param Note $entity + */ + public function checkOwn(User $user, Entity $entity): bool + { + if ($entity->getType() === Note::TYPE_POST && $user->getId() === $entity->getCreatedById()) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/Acl/Notification/OwnershipChecker.php b/application/Espo/Classes/Acl/Notification/OwnershipChecker.php new file mode 100644 index 0000000..ae88bc6 --- /dev/null +++ b/application/Espo/Classes/Acl/Notification/OwnershipChecker.php @@ -0,0 +1,50 @@ +. + * + * 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\Acl\Notification; + +use Espo\Entities\Notification; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + public function checkOwn(User $user, Entity $entity): bool + { + if ($user->getId() === $entity->get('userId')) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/Acl/Pipeline/AccessChecker.php b/application/Espo/Classes/Acl/Pipeline/AccessChecker.php new file mode 100644 index 0000000..6e15ab5 --- /dev/null +++ b/application/Espo/Classes/Acl/Pipeline/AccessChecker.php @@ -0,0 +1,108 @@ +. + * + * 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\Acl\Pipeline; + +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\AclManager; +use Espo\Entities\Pipeline; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + public function __construct( + private AclManager $aclManager, + ) {} + + public function check(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkRead(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkEdit(User $user, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkDelete(User $user, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + if (!$data->isTrue()) { + return false; + } + + if (!$this->aclManager->checkScope($user, $entity->getTargetEntityType())) { + return false; + } + + if ($entity->isAvailableForAll()) { + return true; + } + + return array_intersect($user->getTeamIdList(), $entity->getTeams()->getIdList()) !== []; + } +} diff --git a/application/Espo/Classes/Acl/PipelineStage/AccessChecker.php b/application/Espo/Classes/Acl/PipelineStage/AccessChecker.php new file mode 100644 index 0000000..bbed1d1 --- /dev/null +++ b/application/Espo/Classes/Acl/PipelineStage/AccessChecker.php @@ -0,0 +1,110 @@ +. + * + * 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\Acl\PipelineStage; + +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\AclManager; +use Espo\Entities\PipelineStage; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + public function __construct( + private AclManager $aclManager, + ) {} + + public function check(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkRead(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkEdit(User $user, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkDelete(User $user, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + return $user->isEffectiveAdmin(); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + if (!$data->isTrue()) { + return false; + } + + $pipeline = $entity->getPipeline(); + + if (!$this->aclManager->checkScope($user, $pipeline->getTargetEntityType())) { + return false; + } + + if ($pipeline->isAvailableForAll()) { + return true; + } + + return array_intersect($user->getTeamIdList(), $pipeline->getTeams()->getIdList()) !== []; + } +} diff --git a/application/Espo/Classes/Acl/Portal/AccessChecker.php b/application/Espo/Classes/Acl/Portal/AccessChecker.php new file mode 100644 index 0000000..e9fd36c --- /dev/null +++ b/application/Espo/Classes/Acl/Portal/AccessChecker.php @@ -0,0 +1,58 @@ +. + * + * 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\Acl\Portal; + +use Espo\Core\Acl\Permission; +use Espo\Entities\Portal; +use Espo\Entities\User; +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Table; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; +use Espo\Core\AclManager; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + use DefaultAccessCheckerDependency; + + public function __construct(private DefaultAccessChecker $defaultAccessChecker, private AclManager $aclManager) + {} + + public function check(User $user, ScopeData $data): bool + { + $level = $this->aclManager->getPermissionLevel($user, Permission::PORTAL); + + return $level === Table::LEVEL_YES; + } +} diff --git a/application/Espo/Classes/Acl/ScheduledJob/AccessChecker.php b/application/Espo/Classes/Acl/ScheduledJob/AccessChecker.php new file mode 100644 index 0000000..0bef57e --- /dev/null +++ b/application/Espo/Classes/Acl/ScheduledJob/AccessChecker.php @@ -0,0 +1,89 @@ +. + * + * 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\Acl\ScheduledJob; + +use Espo\Entities\ScheduledJob; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + use DefaultAccessCheckerDependency; + + private DefaultAccessChecker $defaultAccessChecker; + + public function __construct(DefaultAccessChecker $defaultAccessChecker) + { + $this->defaultAccessChecker = $defaultAccessChecker; + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + if ($entity->get('isInternal')) { + return false; + } + + return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if ($entity->get('isInternal')) { + return false; + } + + return $this->defaultAccessChecker->checkEntityRead($user, $entity, $data); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + if ($entity->get('isInternal')) { + return false; + } + + return $this->defaultAccessChecker->checkEntityEdit($user, $entity, $data); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + if ($entity->get('isInternal')) { + return false; + } + + return $this->defaultAccessChecker->checkEntityDelete($user, $entity, $data); + } +} diff --git a/application/Espo/Classes/Acl/Team/OwnershipChecker.php b/application/Espo/Classes/Acl/Team/OwnershipChecker.php new file mode 100644 index 0000000..4669ca9 --- /dev/null +++ b/application/Espo/Classes/Acl/Team/OwnershipChecker.php @@ -0,0 +1,49 @@ +. + * + * 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\Acl\Team; + +use Espo\Core\Name\Field; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + public function checkOwn(User $user, Entity $entity): bool + { + $userTeamIdList = $user->getLinkMultipleIdList(Field::TEAMS); + + return in_array($entity->getId(), $userTeamIdList); + } +} diff --git a/application/Espo/Classes/Acl/User/AccessChecker.php b/application/Espo/Classes/Acl/User/AccessChecker.php new file mode 100644 index 0000000..c98be6f --- /dev/null +++ b/application/Espo/Classes/Acl/User/AccessChecker.php @@ -0,0 +1,129 @@ +. + * + * 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\Acl\User; + +use Espo\Core\Acl\Permission; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessEntityCREDSChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Table; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; +use Espo\Core\AclManager; + +/** + * @implements AccessEntityCREDSChecker + */ +class AccessChecker implements AccessEntityCREDSChecker +{ + use DefaultAccessCheckerDependency; + + public function __construct( + private DefaultAccessChecker $defaultAccessChecker, + private AclManager $aclManager, + ) {} + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + if (!$user->isEffectiveAdmin()) { + return false; + } + + if ($entity->isSuperAdmin() && !$user->isSuperAdmin()) { + return false; + } + + return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if (!$user->isEffectiveAdmin() && !$entity->isActive()) { + return false; + } + + if ($entity->isSuperAdmin() && !$user->isSuperAdmin()) { + return false; + } + + if ($entity->isSystem()) { + return false; + } + + if ($entity->isPortal()) { + return $this->aclManager->getPermissionLevel($user, Permission::PORTAL) === Table::LEVEL_YES; + } + + return $this->defaultAccessChecker->checkEntityRead($user, $entity, $data); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + if ($entity->isSystem()) { + return false; + } + + if (!$user->isEffectiveAdmin()) { + if ($user->getId() !== $entity->getId()) { + return false; + } + } + + if ($entity->isSuperAdmin() && !$user->isSuperAdmin()) { + return false; + } + + return $this->defaultAccessChecker->checkEntityEdit($user, $entity, $data); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + if (!$user->isEffectiveAdmin()) { + return false; + } + + if ($entity->isSystem()) { + return false; + } + + if ($entity->isSuperAdmin() && !$user->isSuperAdmin()) { + return false; + } + + return $this->defaultAccessChecker->checkEntityDelete($user, $entity, $data); + } + + public function checkEntityStream(User $user, Entity $entity, ScopeData $data): bool + { + /** @noinspection PhpRedundantOptionalArgumentInspection */ + return $this->aclManager->checkUserPermission($user, $entity, Permission::USER); + } +} diff --git a/application/Espo/Classes/Acl/User/OwnershipChecker.php b/application/Espo/Classes/Acl/User/OwnershipChecker.php new file mode 100644 index 0000000..8dac60a --- /dev/null +++ b/application/Espo/Classes/Acl/User/OwnershipChecker.php @@ -0,0 +1,65 @@ +. + * + * 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\Acl\User; + +use Espo\Core\Name\Field; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Acl\OwnershipOwnChecker; +use Espo\Core\Acl\OwnershipTeamChecker; + +/** + * @implements OwnershipOwnChecker + * @implements OwnershipTeamChecker + */ +class OwnershipChecker implements OwnershipOwnChecker, OwnershipTeamChecker +{ + public function checkOwn(User $user, Entity $entity): bool + { + return $user->getId() === $entity->getId(); + } + + public function checkTeam(User $user, Entity $entity): bool + { + assert($entity instanceof CoreEntity); + + $intersect = array_intersect( + $user->getLinkMultipleIdList(Field::TEAMS), + $entity->getLinkMultipleIdList(Field::TEAMS) + ); + + if (count($intersect)) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/Acl/Webhook/AccessChecker.php b/application/Espo/Classes/Acl/Webhook/AccessChecker.php new file mode 100644 index 0000000..4d2db0b --- /dev/null +++ b/application/Espo/Classes/Acl/Webhook/AccessChecker.php @@ -0,0 +1,105 @@ +. + * + * 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\Acl\Webhook; + +use Espo\Entities\User; +use Espo\Entities\Webhook; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + use DefaultAccessCheckerDependency; + + public function __construct(DefaultAccessChecker $defaultAccessChecker) + { + $this->defaultAccessChecker = $defaultAccessChecker; + } + + public function check(User $user, ScopeData $data): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + if (!$user->isApi()) { + return false; + } + + if ($data->isFalse()) { + return false; + } + + return true; + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntityInternal($user, $entity, $data); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntityInternal($user, $entity, $data); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntityInternal($user, $entity, $data); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntityInternal($user, $entity, $data); + } + + private function checkEntityInternal(User $user, Entity $entity, ScopeData $data): bool + { + if ($user->isEffectiveAdmin()) { + return true; + } + + if ($data->isFalse()) { + return false; + } + + if ($user->isApi() && $user->getId() === $entity->get('userId')) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/Acl/Webhook/OwnershipChecker.php b/application/Espo/Classes/Acl/Webhook/OwnershipChecker.php new file mode 100644 index 0000000..0dd1ee6 --- /dev/null +++ b/application/Espo/Classes/Acl/Webhook/OwnershipChecker.php @@ -0,0 +1,46 @@ +. + * + * 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\Acl\Webhook; + +use Espo\Entities\User; +use Espo\ORM\Entity; + +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker<\Espo\Entities\Webhook> + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + public function checkOwn(User $user, Entity $entity): bool + { + return $user->getId() === $entity->get('userId') && $user->isApi(); + } +} diff --git a/application/Espo/Classes/Acl/WorkingTimeRange/AssignmentChecker.php b/application/Espo/Classes/Acl/WorkingTimeRange/AssignmentChecker.php new file mode 100644 index 0000000..1afdbf0 --- /dev/null +++ b/application/Espo/Classes/Acl/WorkingTimeRange/AssignmentChecker.php @@ -0,0 +1,90 @@ +. + * + * 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\Acl\WorkingTimeRange; + +use Espo\Core\Acl\AssignmentChecker as AssignmentCheckerInterface; +use Espo\Core\Acl\DefaultAssignmentChecker; +use Espo\Core\AclManager; +use Espo\Entities\User; +use Espo\Entities\WorkingTimeRange; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +/** + * @implements AssignmentCheckerInterface + */ +class AssignmentChecker implements AssignmentCheckerInterface +{ + private DefaultAssignmentChecker $defaultAssignmentChecker; + private AclManager $aclManager; + private EntityManager $entityManager; + + public function __construct( + DefaultAssignmentChecker $defaultAssignmentChecker, + AclManager $aclManager, + EntityManager $entityManager + ) { + $this->defaultAssignmentChecker = $defaultAssignmentChecker; + $this->aclManager = $aclManager; + $this->entityManager = $entityManager; + } + + /** + * @param WorkingTimeRange $entity + */ + public function check(User $user, Entity $entity): bool + { + $result = $this->defaultAssignmentChecker->check($user, $entity); + + if (!$result) { + return false; + } + + if (!$entity->isAttributeChanged('usersIds')) { + return true; + } + + $users = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([Attribute::ID => $entity->getUsers()->getIdList()]) + ->find(); + + foreach ($users as $targetUser) { + $accessToUser = $this->aclManager->check($user, $targetUser); + + if (!$accessToUser) { + return false; + } + } + + return true; + } +} diff --git a/application/Espo/Classes/AclPortal/Attachment/AccessChecker.php b/application/Espo/Classes/AclPortal/Attachment/AccessChecker.php new file mode 100644 index 0000000..74df71a --- /dev/null +++ b/application/Espo/Classes/AclPortal/Attachment/AccessChecker.php @@ -0,0 +1,165 @@ +. + * + * 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\AclPortal\Attachment; + +use Espo\Entities\Attachment; +use Espo\Entities\Note; +use Espo\Entities\Settings; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Portal\Acl\DefaultAccessChecker; +use Espo\Core\Portal\Acl\Traits\DefaultAccessCheckerDependency; +use Espo\Core\Portal\AclManager; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + use DefaultAccessCheckerDependency; + + private DefaultAccessChecker $defaultAccessChecker; + private AclManager $aclManager; + private EntityManager $entityManager; + + public function __construct( + DefaultAccessChecker $defaultAccessChecker, + AclManager $aclManager, + EntityManager $entityManager + ) { + $this->defaultAccessChecker = $defaultAccessChecker; + $this->aclManager = $aclManager; + $this->entityManager = $entityManager; + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + /** @var Attachment $entity */ + + if ($entity->get('parentType') === Settings::ENTITY_TYPE) { + // Allow the logo. + return true; + } + + $parent = null; + + $parentType = $entity->get('parentType'); + $parentId = $entity->get('parentId'); + + $relatedType = $entity->get('relatedType'); + $relatedId = $entity->get('relatedId'); + + if ($parentId && $parentType) { + $parent = $this->entityManager->getEntityById($parentType, $parentId); + } else if ($relatedId && $relatedType) { + $parent = $this->entityManager->getEntityById($relatedType, $relatedId); + } + + if (!$parent) { + if ($entity->get('createdById') === $user->getId()) { + return true; + } + + return false; + } + + if ($parent->getEntityType() === Note::ENTITY_TYPE) { + /** @var Note $parent */ + $result = $this->checkEntityReadNoteParent($user, $parent); + + if ($result !== null) { + return $result; + } + } else if ($this->aclManager->checkEntity($user, $parent)) { + if ( + $entity->getTargetField() && + !$this->aclManager->checkField($user, $parent->getEntityType(), $entity->getTargetField()) + ) { + return false; + } + + return true; + } + + if ($this->defaultAccessChecker->checkEntityRead($user, $entity, $data)) { + return true; + } + + return false; + } + + private function checkEntityReadNoteParent(User $user, Note $note): ?bool + { + if ($note->isInternal()) { + return false; + } + + if ($note->getTargetType() === Note::TARGET_PORTALS) { + $intersect = array_intersect( + $note->getLinkMultipleIdList('portals'), + $user->getLinkMultipleIdList('portals') + ); + + if (count($intersect)) { + return true; + } + + return false; + } + + if ($note->getTargetType() === Note::TARGET_USERS) { + $isRelated = $this->entityManager + ->getRDBRepository(Note::ENTITY_TYPE) + ->getRelation($note, 'users') + ->isRelated($user); + + if ($isRelated) { + return true; + } + + return false; + } + + if (!$note->getParentId() || !$note->getParentType()) { + return null; + } + + $parent = $this->entityManager->getEntityById($note->getParentType(), $note->getParentId()); + + if ($parent && $this->aclManager->checkEntity($user, $parent)) { + return true; + } + + return null; + } +} diff --git a/application/Espo/Classes/AclPortal/Attachment/OwnershipChecker.php b/application/Espo/Classes/AclPortal/Attachment/OwnershipChecker.php new file mode 100644 index 0000000..9bb44b2 --- /dev/null +++ b/application/Espo/Classes/AclPortal/Attachment/OwnershipChecker.php @@ -0,0 +1,52 @@ +. + * + * 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\AclPortal\Attachment; + +use Espo\Entities\Attachment; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + private const ATTR_CREATED_BY_ID = 'createdById'; + + public function checkOwn(User $user, Entity $entity): bool + { + if ($user->getId() === $entity->get(self::ATTR_CREATED_BY_ID)) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/AclPortal/Email/AccessChecker.php b/application/Espo/Classes/AclPortal/Email/AccessChecker.php new file mode 100644 index 0000000..7b831c3 --- /dev/null +++ b/application/Espo/Classes/AclPortal/Email/AccessChecker.php @@ -0,0 +1,79 @@ +. + * + * 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\AclPortal\Email; + +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Acl\AccessEntityCREDSChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Table; +use Espo\Core\Portal\Acl\DefaultAccessChecker; +use Espo\Core\Portal\Acl\Traits\DefaultAccessCheckerDependency; + +/** + * @implements AccessEntityCREDSChecker + */ +class AccessChecker implements AccessEntityCREDSChecker +{ + use DefaultAccessCheckerDependency; + + public function __construct( + DefaultAccessChecker $defaultAccessChecker + ) { + $this->defaultAccessChecker = $defaultAccessChecker; + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if ($this->defaultAccessChecker->checkEntityRead($user, $entity, $data)) { + return true; + } + + if ($data->isFalse()) { + return false; + } + + if ($data->getRead() === Table::LEVEL_NO) { + return false; + } + + assert($entity instanceof CoreEntity); + + $userIdList = $entity->getLinkMultipleIdList('users'); + + if (in_array($user->getId(), $userIdList)) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/AclPortal/Email/OwnershipChecker.php b/application/Espo/Classes/AclPortal/Email/OwnershipChecker.php new file mode 100644 index 0000000..c998fc3 --- /dev/null +++ b/application/Espo/Classes/AclPortal/Email/OwnershipChecker.php @@ -0,0 +1,50 @@ +. + * + * 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\AclPortal\Email; + +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + public function checkOwn(User $user, Entity $entity): bool + { + if ($user->getId() === $entity->get('createdById')) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/AclPortal/Note/AccessChecker.php b/application/Espo/Classes/AclPortal/Note/AccessChecker.php new file mode 100644 index 0000000..c7fb17b --- /dev/null +++ b/application/Espo/Classes/AclPortal/Note/AccessChecker.php @@ -0,0 +1,205 @@ +. + * + * 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\AclPortal\Note; + +use Espo\Core\Name\Field; +use Espo\Entities\Note; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Portal\Acl\DefaultAccessChecker; +use Espo\Core\Portal\Acl\Traits\DefaultAccessCheckerDependency; +use Espo\Core\Portal\AclManager; +use Espo\Core\Utils\Config; + +use DateTime; +use Exception; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + use DefaultAccessCheckerDependency; + + private const EDIT_PERIOD = '7 days'; + private const DELETE_PERIOD = '1 month'; + + private DefaultAccessChecker $defaultAccessChecker; + private AclManager $aclManager; + private EntityManager $entityManager; + private Config $config; + + public function __construct( + DefaultAccessChecker $defaultAccessChecker, + AclManager $aclManager, + EntityManager $entityManager, + Config $config + ) { + $this->defaultAccessChecker = $defaultAccessChecker; + $this->aclManager = $aclManager; + $this->entityManager = $entityManager; + $this->config = $config; + } + + /** + * @param Note $entity + */ + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + $parentId = $entity->getParentId(); + $parentType = $entity->getParentType(); + + if (!$parentId || !$parentType) { + return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data); + } + + $parent = $this->entityManager->getEntityById($parentType, $parentId); + + if ($parent && $this->aclManager->checkEntityStream($user, $parent)) { + return true; + } + + return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data); + } + + /** + * @param Note $entity + */ + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + $parentId = $entity->getParentId(); + $parentType = $entity->getParentType(); + + if ($entity->isInternal()) { + return false; + } + + if ($parentId && $parentType) { + $parent = $this->entityManager->getEntityById($parentType, $parentId); + + if (!$parent) { + return false; + } + + return $this->aclManager->checkEntityStream($user, $parent); + } + + if ($entity->getType() !== Note::TYPE_POST) { + return false; + } + + if ($entity->getCreatedById() === $user->getId()) { + return true; + } + + if ($entity->getTargetType() === Note::TARGET_PORTALS) { + return in_array($user->getPortalId(), $entity->getLinkMultipleIdList('portals')); + } + + return false; + } + + /** + * @param Note $entity + */ + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + if (!$this->defaultAccessChecker->checkEntityEdit($user, $entity, $data)) { + return false; + } + + if (!$this->aclManager->checkOwnershipOwn($user, $entity)) { + return false; + } + + $createdAt = $entity->get(Field::CREATED_AT); + + if (!$createdAt) { + return true; + } + + $noteEditThresholdPeriod = + '-' . $this->config->get('noteEditThresholdPeriod', self::EDIT_PERIOD); + + $dt = new DateTime(); + + $dt->modify($noteEditThresholdPeriod); + + try { + if ($dt->format('U') > (new DateTime($createdAt))->format('U')) { + return false; + } + } catch (Exception $e) { + return false; + } + + return true; + } + + /** + * @param Note $entity + */ + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + if (!$this->defaultAccessChecker->checkEntityDelete($user, $entity, $data)) { + return false; + } + + if (!$this->aclManager->checkOwnershipOwn($user, $entity)) { + return false; + } + + $createdAt = $entity->get(Field::CREATED_AT); + + if (!$createdAt) { + return true; + } + + $deleteThresholdPeriod = + '-' . $this->config->get('noteDeleteThresholdPeriod', self::DELETE_PERIOD); + + $dt = new DateTime(); + + $dt->modify($deleteThresholdPeriod); + + try { + if ($dt->format('U') > (new DateTime($createdAt))->format('U')) { + return false; + } + } catch (Exception $e) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Classes/AclPortal/Note/OwnershipChecker.php b/application/Espo/Classes/AclPortal/Note/OwnershipChecker.php new file mode 100644 index 0000000..0f96de8 --- /dev/null +++ b/application/Espo/Classes/AclPortal/Note/OwnershipChecker.php @@ -0,0 +1,53 @@ +. + * + * 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\AclPortal\Note; + +use Espo\Entities\Note; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + /** + * @param Note $entity + */ + public function checkOwn(User $user, Entity $entity): bool + { + if ($entity->getType() === Note::TYPE_POST && $user->getId() === $entity->getCreatedById()) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/AclPortal/Notification/OwnershipChecker.php b/application/Espo/Classes/AclPortal/Notification/OwnershipChecker.php new file mode 100644 index 0000000..9b0dfa1 --- /dev/null +++ b/application/Espo/Classes/AclPortal/Notification/OwnershipChecker.php @@ -0,0 +1,50 @@ +. + * + * 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\AclPortal\Notification; + +use Espo\Entities\Notification; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + public function checkOwn(User $user, Entity $entity): bool + { + if ($user->getId() === $entity->get('userId')) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/AclPortal/Pipeline/AccessChecker.php b/application/Espo/Classes/AclPortal/Pipeline/AccessChecker.php new file mode 100644 index 0000000..bb4e0a4 --- /dev/null +++ b/application/Espo/Classes/AclPortal/Pipeline/AccessChecker.php @@ -0,0 +1,104 @@ +. + * + * 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\AclPortal\Pipeline; + +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Portal\AclManager as PortalAclManager; +use Espo\Entities\Pipeline; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + public function __construct( + private PortalAclManager $aclManager + ) {} + + public function check(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return false; + } + + public function checkRead(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkEdit(User $user, ScopeData $data): bool + { + return false; + } + + public function checkDelete(User $user, ScopeData $data): bool + { + return false; + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return false; + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + return false; + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + return false; + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if (!$data->isFalse()) { + return false; + } + + if (!$this->aclManager->checkScope($user, $entity->getTargetEntityType())) { + return false; + } + + if ($entity->isAvailableForAll()) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/AclPortal/PipelineStage/AccessChecker.php b/application/Espo/Classes/AclPortal/PipelineStage/AccessChecker.php new file mode 100644 index 0000000..f461f47 --- /dev/null +++ b/application/Espo/Classes/AclPortal/PipelineStage/AccessChecker.php @@ -0,0 +1,106 @@ +. + * + * 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\AclPortal\PipelineStage; + +use Espo\Core\Acl\AccessEntityCREDChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Portal\AclManager as PortalAclManager; +use Espo\Entities\PipelineStage; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements AccessEntityCREDChecker + */ +class AccessChecker implements AccessEntityCREDChecker +{ + public function __construct( + private PortalAclManager $aclManager + ) {} + + public function check(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return false; + } + + public function checkRead(User $user, ScopeData $data): bool + { + return $data->isTrue(); + } + + public function checkEdit(User $user, ScopeData $data): bool + { + return false; + } + + public function checkDelete(User $user, ScopeData $data): bool + { + return false; + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return false; + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + return false; + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + return false; + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + if (!$data->isFalse()) { + return false; + } + + $pipeline = $entity->getPipeline(); + + if (!$this->aclManager->checkScope($user, $pipeline->getTargetEntityType())) { + return false; + } + + if ($pipeline->isAvailableForAll()) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/AclPortal/User/OwnershipChecker.php b/application/Espo/Classes/AclPortal/User/OwnershipChecker.php new file mode 100644 index 0000000..4092181 --- /dev/null +++ b/application/Espo/Classes/AclPortal/User/OwnershipChecker.php @@ -0,0 +1,45 @@ +. + * + * 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\AclPortal\User; + +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\OwnershipOwnChecker; + +/** + * @implements OwnershipOwnChecker + */ +class OwnershipChecker implements OwnershipOwnChecker +{ + public function checkOwn(User $user, Entity $entity): bool + { + return $user->getId() === $entity->getId(); + } +} diff --git a/application/Espo/Classes/AddressFormatters/Formatter1.php b/application/Espo/Classes/AddressFormatters/Formatter1.php new file mode 100644 index 0000000..194620e --- /dev/null +++ b/application/Espo/Classes/AddressFormatters/Formatter1.php @@ -0,0 +1,87 @@ +. + * + * 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\AddressFormatters; + +use Espo\Core\Field\Address; +use Espo\Core\Field\Address\AddressFormatter; + +class Formatter1 implements AddressFormatter +{ + public function format(Address $address): string + { + $result = ''; + + $street = $address->getStreet(); + $city = $address->getCity(); + $country = $address->getCountry(); + $state = $address->getState(); + $postalCode = $address->getPostalCode(); + + if ($street) { + $result .= $street; + } + + if ($city || $state || $postalCode) { + if ($result) { + $result .= "\n"; + } + + if ($city) { + $result .= $city; + } + + if ($state && $city) { + $result .= ', '; + } + + if ($state) { + $result .= $state; + } + + if ($postalCode && ($state || $city)) { + $result .= ' '; + } + + if ($postalCode) { + $result .= $postalCode; + } + } + + if ($country) { + if ($result) { + $result .= "\n"; + } + + $result .= $country; + } + + return $result; + } +} diff --git a/application/Espo/Classes/AddressFormatters/Formatter2.php b/application/Espo/Classes/AddressFormatters/Formatter2.php new file mode 100644 index 0000000..e8f47bb --- /dev/null +++ b/application/Espo/Classes/AddressFormatters/Formatter2.php @@ -0,0 +1,89 @@ +. + * + * 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\AddressFormatters; + +use Espo\Core\Field\Address; +use Espo\Core\Field\Address\AddressFormatter; + +class Formatter2 implements AddressFormatter +{ + public function format(Address $address): string + { + $result = ''; + + $street = $address->getStreet(); + $city = $address->getCity(); + $country = $address->getCountry(); + $state = $address->getState(); + $postalCode = $address->getPostalCode(); + + if ($street) { + $result .= $street; + } + + if ($city || $postalCode) { + if ($result) { + $result .= "\n"; + } + + if ($postalCode) { + $result .= $postalCode; + } + + if ($postalCode && $city) { + $result .= ' '; + } + + if ($city) { + $result .= $city; + } + } + + if ($state || $country) { + if ($result) { + $result .= "\n"; + } + + if ($state) { + $result .= $state; + } + + if ($state && $country) { + $result .= ' '; + } + + if ($country) { + $result .= $country; + } + } + + return $result; + } +} diff --git a/application/Espo/Classes/AddressFormatters/Formatter3.php b/application/Espo/Classes/AddressFormatters/Formatter3.php new file mode 100644 index 0000000..fad76bc --- /dev/null +++ b/application/Espo/Classes/AddressFormatters/Formatter3.php @@ -0,0 +1,87 @@ +. + * + * 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\AddressFormatters; + +use Espo\Core\Field\Address; +use Espo\Core\Field\Address\AddressFormatter; + +class Formatter3 implements AddressFormatter +{ + public function format(Address $address): string + { + $result = ''; + + $street = $address->getStreet(); + $city = $address->getCity(); + $country = $address->getCountry(); + $state = $address->getState(); + $postalCode = $address->getPostalCode(); + + if ($country) { + $result .= $country; + } + + if ($city || $state || $postalCode) { + if ($result) { + $result .= "\n"; + } + + if ($state) { + $result .= $state; + } + + if ($state && $postalCode) { + $result .= ' '; + } + + if ($postalCode) { + $result .= $postalCode; + } + + if ($city && ($state || $postalCode)) { + $result .= ' '; + } + + if ($city) { + $result .= $city; + } + } + + if ($street) { + if ($result) { + $result .= "\n"; + } + + $result .= $street; + } + + return $result; + } +} diff --git a/application/Espo/Classes/AddressFormatters/Formatter4.php b/application/Espo/Classes/AddressFormatters/Formatter4.php new file mode 100644 index 0000000..7cdf55d --- /dev/null +++ b/application/Espo/Classes/AddressFormatters/Formatter4.php @@ -0,0 +1,87 @@ +. + * + * 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\AddressFormatters; + +use Espo\Core\Field\Address; +use Espo\Core\Field\Address\AddressFormatter; + +class Formatter4 implements AddressFormatter +{ + public function format(Address $address): string + { + $result = ''; + + $street = $address->getStreet(); + $city = $address->getCity(); + $country = $address->getCountry(); + $state = $address->getState(); + $postalCode = $address->getPostalCode(); + + if ($street) { + $result .= $street; + } + + if ($city) { + if ($result) { + $result .= "\n"; + } + + $result .= $city; + } + + if ($country || $state || $postalCode) { + if ($result) { + $result .= "\n"; + } + + if ($country) { + $result .= $country; + } + + if ($state && $country) { + $result .= ' - '; + } + + if ($state) { + $result .= $state; + } + + if ($postalCode && ($state || $country)) { + $result .= ' '; + } + + if ($postalCode) { + $result .= $postalCode; + } + } + + return $result; + } +} diff --git a/application/Espo/Classes/AppInfo/Binding.php b/application/Espo/Classes/AppInfo/Binding.php new file mode 100644 index 0000000..58fc6b4 --- /dev/null +++ b/application/Espo/Classes/AppInfo/Binding.php @@ -0,0 +1,116 @@ +. + * + * 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\AppInfo; + +use Espo\Core\Binding\Binding as BindingItem; +use Espo\Core\Binding\EspoBindingLoader; +use Espo\Core\Console\Command\Params; +use Espo\Core\Utils\Module; +use Espo\Tools\ConsoleAppInfo\InfoProvider; + +class Binding implements InfoProvider +{ + + public function __construct(private Module $module) + {} + + public function get(Params $params): string + { + $result = ''; + + $bindingLoader = new EspoBindingLoader($this->module); + + $data = $bindingLoader->load(); + + $keyList = $data->getGlobalKeyList(); + + $result .= "Global:\n\n"; + + foreach ($keyList as $key) { + $result .= $this->printItem($key, $data->getGlobal($key)); + } + + $contextList = $data->getContextList(); + + foreach ($contextList as $context) { + $result .= "Context: {$context}\n\n"; + + $keyList = $data->getContextKeyList($context); + + foreach ($keyList as $key) { + $result .= $this->printItem($key, $data->getContext($context, $key)); + } + } + + return $result; + } + + private function printItem(string $key, BindingItem $binding): string + { + $result = ''; + + $tab = ' '; + + $result .= $tab . "Key: {$key}\n"; + + $type = $binding->getType(); + $value = $binding->getValue(); + + $typeString = [ + BindingItem::IMPLEMENTATION_CLASS_NAME => 'Implementation', + BindingItem::CONTAINER_SERVICE => 'Service', + BindingItem::VALUE => 'Value', + BindingItem::CALLBACK => 'Callback', + BindingItem::FACTORY_CLASS_NAME => 'Factory', + ][$type]; + + $result .= $tab . "Type: {$typeString}\n"; + + if ($type == BindingItem::IMPLEMENTATION_CLASS_NAME || $type == BindingItem::CONTAINER_SERVICE) { + $result .= $tab . "Value: {$value}\n"; + } + + if ($type == BindingItem::VALUE) { + if (is_string($value) || is_int($value) || is_float($value)) { + $result .= $tab . "Value: {$value}\n"; + } + + if (is_bool($value)) { + $valueString = $value ? 'true' : 'false'; + + $result .= $tab . "Value: {$valueString}\n"; + } + } + + $result .= "\n"; + + return $result; + } +} diff --git a/application/Espo/Classes/AppInfo/Container.php b/application/Espo/Classes/AppInfo/Container.php new file mode 100644 index 0000000..6424b7f --- /dev/null +++ b/application/Espo/Classes/AppInfo/Container.php @@ -0,0 +1,103 @@ +. + * + * 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\AppInfo; + +use Espo\Core\Console\Command\Params; +use Espo\Core\Container as ContainerService; +use Espo\Core\Utils\Metadata; +use Espo\Tools\ConsoleAppInfo\InfoProvider; + +class Container implements InfoProvider +{ + public function __construct(private ContainerService $container, private Metadata $metadata) + {} + + public function get(Params $params): string + { + $nameOnly = $params->hasFlag('nameOnly'); + + $result = ''; + + $serviceList = [ + 'injectableFactory', + 'config', + 'log', + 'fileManager', + 'dataManager', + 'metadata', + 'user', + 'systemDataCache', + ]; + + /** @var string[] $fileList */ + $fileList = scandir('application/Espo/Core/Loaders'); + + if (file_exists('custom/Espo/Custom/Core/Loaders')) { + $fileList = array_merge($fileList, scandir('custom/Espo/Custom/Core/Loaders') ?: []); + } + + foreach ($fileList as $file) { + if (substr($file, -4) === '.php') { + $name = lcfirst(substr($file, 0, -4)); + + if (!in_array($name, $serviceList) && $this->container->has($name)) { + $serviceList[] = $name; + } + } + } + + foreach ($this->metadata->get(['app', 'containerServices']) ?? [] as $name => $data) { + if (!in_array($name, $serviceList)) { + $serviceList[] = $name; + } + } + + sort($serviceList); + + if ($nameOnly) { + foreach ($serviceList as $name) { + $result .= $name . "\n"; + } + + return $result; + } + + foreach ($serviceList as $name) { + $result .= $name . "\n"; + + $obj = $this->container->get($name); + $result .= get_class($obj) . "\n"; + + $result .= "\n"; + } + + return $result; + } +} diff --git a/application/Espo/Classes/AppInfo/CoreVersion.php b/application/Espo/Classes/AppInfo/CoreVersion.php new file mode 100644 index 0000000..9a2c1a4 --- /dev/null +++ b/application/Espo/Classes/AppInfo/CoreVersion.php @@ -0,0 +1,49 @@ +. + * + * 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\AppInfo; + +use Espo\Core\Console\Command\Params; +use Espo\Core\Upgrades\Migration\VersionDataProvider; +use Espo\Tools\ConsoleAppInfo\InfoProvider; + +/** + * @noinspection PhpUnused + */ +class CoreVersion implements InfoProvider +{ + public function __construct( + private VersionDataProvider $versionDataProvider, + ) {} + + public function get(Params $params): string + { + return $this->versionDataProvider->getTargetVersion(); + } +} diff --git a/application/Espo/Classes/AppInfo/Jobs.php b/application/Espo/Classes/AppInfo/Jobs.php new file mode 100644 index 0000000..9937f06 --- /dev/null +++ b/application/Espo/Classes/AppInfo/Jobs.php @@ -0,0 +1,65 @@ +. + * + * 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\AppInfo; + +use Espo\Core\Console\Command\Params; +use Espo\Core\Utils\ClassFinder; +use Espo\Core\Job\MetadataProvider; +use Espo\Tools\ConsoleAppInfo\InfoProvider; + +class Jobs implements InfoProvider +{ + + public function __construct( + private ClassFinder $classFinder, + private MetadataProvider $metadataProvider, + ) {} + + public function get(Params $params): string + { + $result = "Available jobs:\n\n"; + + $list = array_map( + function ($item) { + return ' ' . $item; + }, + array_unique( + array_merge( + array_keys($this->classFinder->getMap('Jobs')), + $this->metadataProvider->getScheduledJobNameList() + ) + ) + ); + + asort($list); + + return $result . implode("\n", $list) . "\n"; + } +} diff --git a/application/Espo/Classes/AppInfo/Version.php b/application/Espo/Classes/AppInfo/Version.php new file mode 100644 index 0000000..a9d56c8 --- /dev/null +++ b/application/Espo/Classes/AppInfo/Version.php @@ -0,0 +1,46 @@ +. + * + * 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\AppInfo; + +use Espo\Core\Console\Command\Params; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Tools\ConsoleAppInfo\InfoProvider; + +class Version implements InfoProvider +{ + public function __construct( + private SystemConfig $systemConfig, + ) {} + + public function get(Params $params): string + { + return $this->systemConfig->getVersion(); + } +} diff --git a/application/Espo/Classes/AppParams/AddressCountryData.php b/application/Espo/Classes/AppParams/AddressCountryData.php new file mode 100644 index 0000000..57248d8 --- /dev/null +++ b/application/Espo/Classes/AppParams/AddressCountryData.php @@ -0,0 +1,48 @@ +. + * + * 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\AppParams; + +use Espo\Core\Utils\Address\CountryDataProvider; +use Espo\Tools\App\AppParam; + +class AddressCountryData implements AppParam +{ + public function __construct( + private CountryDataProvider $provider + ) {} + + /** + * @return array{list: string[], preferredList: string[]} + */ + public function get(): array + { + return $this->provider->get(); + } +} diff --git a/application/Espo/Classes/AppParams/CurrencyRates.php b/application/Espo/Classes/AppParams/CurrencyRates.php new file mode 100644 index 0000000..8392ee9 --- /dev/null +++ b/application/Espo/Classes/AppParams/CurrencyRates.php @@ -0,0 +1,59 @@ +. + * + * 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\AppParams; + +use Espo\Core\Currency\ConfigDataProvider; +use Espo\Core\Utils\NumberUtil; +use Espo\Tools\App\AppParam; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class CurrencyRates implements AppParam +{ + private const int PRECISION = 6; + + public function __construct( + private ConfigDataProvider $configDataProvider, + private NumberUtil $numberUtil, + ) {} + + public function get(): stdClass + { + $rates = $this->configDataProvider->getCurrencyRates()->toAssoc(); + + foreach ($rates as $code => $value) { + $rates[$code] = $this->numberUtil->format($value, self::PRECISION, '.', ''); + } + + return (object) $rates; + } +} diff --git a/application/Espo/Classes/AppParams/Extensions.php b/application/Espo/Classes/AppParams/Extensions.php new file mode 100644 index 0000000..a339ecd --- /dev/null +++ b/application/Espo/Classes/AppParams/Extensions.php @@ -0,0 +1,92 @@ +. + * + * 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\AppParams; + +use Espo\Entities\Extension; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\Tools\App\AppParam; +use stdClass; + +class Extensions implements AppParam +{ + private User $user; + private EntityManager $entityManager; + + public function __construct( + User $user, + EntityManager $entityManager + ) { + $this->user = $user; + $this->entityManager = $entityManager; + } + + /** + * @return stdClass[] + */ + public function get(): array + { + if (!$this->user->isRegular() && !$this->user->isEffectiveAdmin()) { + return []; + } + + $extensionList = $this->entityManager + ->getRDBRepositoryByClass(Extension::class) + ->where([ + 'licenseStatus' => [ + Extension::LICENSE_STATUS_INVALID, + Extension::LICENSE_STATUS_EXPIRED, + Extension::LICENSE_STATUS_SOFT_EXPIRED, + ], + ]) + ->find(); + + $list = []; + + foreach ($extensionList as $extension) { + $list[] = (object) [ + 'name' => $extension->getName(), + 'version' => $extension->getVersion(), + 'licenseStatus' => $extension->getLicenseStatus(), + 'licenseStatusMessage' => $extension->getLicenseStatusMessage(), + 'isInstalled' => $extension->isInstalled(), + 'notify' => in_array( + $extension->getLicenseStatus(), + [ + Extension::LICENSE_STATUS_INVALID, + Extension::LICENSE_STATUS_EXPIRED, + ] + ) + ]; + } + + return $list; + } +} diff --git a/application/Espo/Classes/AppParams/Pipelines.php b/application/Espo/Classes/AppParams/Pipelines.php new file mode 100644 index 0000000..881d1d5 --- /dev/null +++ b/application/Espo/Classes/AppParams/Pipelines.php @@ -0,0 +1,56 @@ +. + * + * 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\AppParams; + +use Espo\Tools\App\AppParam; +use Espo\Tools\Pipeline\Data\PipelineData; +use Espo\Tools\Pipeline\PipelineDataProvider; +use Espo\Tools\Pipeline\PipelineDataUserFilter; + +/** + * @noinspection PhpUnused + */ +class Pipelines implements AppParam +{ + public function __construct( + private PipelineDataProvider $pipelineDataProvider, + private PipelineDataUserFilter $userFilter, + ) {} + + /** + * @return array + */ + public function get(): array + { + $data = $this->pipelineDataProvider->get(); + + return $this->userFilter->filter($data); + } +} diff --git a/application/Espo/Classes/AppParams/TemplateEntityTypeList.php b/application/Espo/Classes/AppParams/TemplateEntityTypeList.php new file mode 100644 index 0000000..4b6f869 --- /dev/null +++ b/application/Espo/Classes/AppParams/TemplateEntityTypeList.php @@ -0,0 +1,91 @@ +. + * + * 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\AppParams; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\Entities\Template; +use Espo\Tools\App\AppParam; +use RuntimeException; + +/** + * Returns a list of entity types for which a PDF template exists. + * + * @noinspection PhpUnused + */ +class TemplateEntityTypeList implements AppParam +{ + + public function __construct( + private Acl $acl, + private SelectBuilderFactory $selectBuilderFactory, + private EntityManager $entityManager, + ) {} + + /** + * @return string[] + */ + public function get(): array + { + if (!$this->acl->checkScope(Template::ENTITY_TYPE)) { + return []; + } + + $list = []; + + try { + $query = $this->selectBuilderFactory + ->create() + ->from(Template::ENTITY_TYPE) + ->withAccessControlFilter() + ->buildQueryBuilder() + ->select(['entityType']) + ->where(['status' => Template::STATUS_ACTIVE]) + ->group(['entityType']) + ->build(); + } catch (BadRequest|Forbidden $e) { + throw new RuntimeException('', 0, $e); + } + + $templateCollection = $this->entityManager + ->getRDBRepositoryByClass(Template::class) + ->clone($query) + ->find(); + + foreach ($templateCollection as $template) { + $list[] = $template->getTargetEntityType(); + } + + return $list; + } +} diff --git a/application/Espo/Classes/AssignmentNotificators/Email.php b/application/Espo/Classes/AssignmentNotificators/Email.php new file mode 100644 index 0000000..dca2952 --- /dev/null +++ b/application/Espo/Classes/AssignmentNotificators/Email.php @@ -0,0 +1,290 @@ +. + * + * 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\AssignmentNotificators; + +use Espo\Core\Field\DateTime; +use Espo\Core\Field\LinkParent; +use Espo\Core\Name\Field; +use Espo\Core\Notification\DefaultAssignmentNotificator; +use Espo\Entities\EmailAddress; +use Espo\Entities\EmailFolder; +use Espo\Entities\Note; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\Contact; +use Espo\Modules\Crm\Entities\Lead; +use Espo\ORM\Name\Attribute; +use Espo\Tools\Stream\Service as StreamService; +use Espo\Core\Notification\AssignmentNotificator; +use Espo\Core\Notification\AssignmentNotificator\Params; +use Espo\Core\Notification\UserEnabledChecker; +use Espo\Core\AclManager; +use Espo\ORM\EntityManager; +use Espo\ORM\Entity; +use Espo\Entities\User; +use Espo\Entities\Notification; +use Espo\Entities\Email as EmailEntity; +use Espo\Repositories\Email as EmailRepository; +use Espo\Repositories\EmailAddress as EmailAddressRepository; +use Espo\Tools\Email\Util; + +/** + * @implements AssignmentNotificator + */ +class Email implements AssignmentNotificator +{ + private const DAYS_THRESHOLD = 2; + + public function __construct( + private User $user, + private EntityManager $entityManager, + private UserEnabledChecker $userChecker, + private AclManager $aclManager, + private StreamService $streamService, + private DefaultAssignmentNotificator $defaultAssignmentNotificator, + ) {} + + /** + * @param EmailEntity $entity + */ + public function process(Entity $entity, Params $params): void + { + if ( + !in_array( + $entity->getStatus(), + [ + EmailEntity::STATUS_ARCHIVED, + EmailEntity::STATUS_SENT, + EmailEntity::STATUS_BEING_IMPORTED, + ] + ) + ) { + return; + } + + if ( + $entity->getStatus() !== EmailEntity::STATUS_BEING_IMPORTED && + !$this->streamService->checkIsEnabled(EmailEntity::ENTITY_TYPE) + ) { + $this->defaultAssignmentNotificator->process( + $entity, + $params->withOption(DefaultAssignmentNotificator::OPTION_FORCE_ASSIGNED_USER, true) + ); + } + + if ($params->getOption(EmailEntity::SAVE_OPTION_IS_JUST_SENT)) { + $previousUserIdList = []; + } else { + $previousUserIdList = $entity->getFetched('usersIds'); + + if (!is_array($previousUserIdList)) { + $previousUserIdList = []; + } + } + + $dateSent = $entity->getDateSent(); + + if (!$dateSent) { + return; + } + + if ($dateSent->diff(DateTime::createNow())->days > self::DAYS_THRESHOLD) { + return; + } + + $emailUserIdList = $entity->get('usersIds'); + + if (!is_array($emailUserIdList)) { + return; + } + + $userIdList = []; + + foreach ($emailUserIdList as $userId) { + if ( + !in_array($userId, $userIdList) && + !in_array($userId, $previousUserIdList) && + $userId !== $this->user->getId() + ) { + $userIdList[] = $userId; + } + } + + $data = [ + 'emailId' => $entity->getId(), + 'emailName' => $entity->getSubject(), + ]; + + /** @var EmailRepository $emailRepository */ + $emailRepository = $this->entityManager->getRepository(EmailEntity::ENTITY_TYPE); + /** @var EmailAddressRepository $emailAddressRepository */ + $emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + if (!$entity->has('from')) { + $emailRepository->loadFromField($entity); + } + + if (!$entity->has('to')) { + $emailRepository->loadToField($entity); + } + + $person = null; + + $from = $entity->get('from'); + + if ($from) { + $person = $emailAddressRepository->getEntityByAddress($from, null, [ + User::ENTITY_TYPE, + Contact::ENTITY_TYPE, + Lead::ENTITY_TYPE, + ]); + + if ($person) { + $data[Note::DATA_ATTR_PERSON_TYPE] = $person->getEntityType(); + $data[Note::DATA_ATTR_PERSON_NAME] = $person->get(Field::NAME); + $data[Note::DATA_ATTR_PERSON_ID] = $person->getId(); + } + } + + $userIdFrom = null; + + if ($person && $person->getEntityType() === User::ENTITY_TYPE) { + $userIdFrom = $person->getId(); + } + + if (empty($data[Note::DATA_ATTR_PERSON_ID])) { + $data[Note::DATA_ATTR_FROM_STRING] = Util::parseFromName($entity->getFromString() ?? ''); + + if (empty($data[Note::DATA_ATTR_FROM_STRING]) && $from) { + $data[Note::DATA_ATTR_FROM_STRING] = $from; + } + } + + $parent = $entity->getParent(); + $account = $entity->getAccount(); + + foreach ($userIdList as $userId) { + if ($userIdFrom === $userId) { + continue; + } + + if ( + $entity->getUserColumnInTrash($userId) || + $entity->getUserColumnIsRead($userId) || + $entity->getUserSkipNotification($userId) + ) { + continue; + } + + if (!$this->userChecker->checkAssignment(EmailEntity::ENTITY_TYPE, $userId)) { + continue; + } + + if ( + $params->getOption(EmailEntity::SAVE_OPTION_IS_BEING_IMPORTED) || + $params->getOption(EmailEntity::SAVE_OPTION_IS_JUST_SENT) + ) { + $folderId = $entity->getUserColumnFolderId($userId); + + if ( + $folderId && + $this->entityManager + ->getRDBRepositoryByClass(EmailFolder::class) + ->where([ + 'id' => $folderId, + 'skipNotifications' => true, + ]) + ->count() + ) { + continue; + } + } + + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + continue; + } + + if ($user->isPortal()) { + continue; + } + + if (!$this->aclManager->checkScope($user, EmailEntity::ENTITY_TYPE)) { + continue; + } + + $isArchivedOrBeingImported = + $entity->getStatus() === EmailEntity::STATUS_ARCHIVED || + $params->getOption(EmailEntity::SAVE_OPTION_IS_BEING_IMPORTED); + + if ( + $isArchivedOrBeingImported && + $parent && + $this->streamService->checkIsFollowed($parent, $userId) + ) { + continue; + } + + if ( + $isArchivedOrBeingImported && + $account && + $this->streamService->checkIsFollowed($account, $userId) + ) { + continue; + } + + $existing = $this->entityManager + ->getRDBRepository(Notification::ENTITY_TYPE) + ->where([ + 'type' => Notification::TYPE_EMAIL_RECEIVED, + 'userId' => $userId, + 'relatedId' => $entity->getId(), + 'relatedType' => EmailEntity::ENTITY_TYPE, + ]) + ->select([Attribute::ID]) + ->findOne(); + + if ($existing) { + continue; + } + + $notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew(); + + $notification + ->setType(Notification::TYPE_EMAIL_RECEIVED) + ->setUserId($userId) + ->setData($data) + ->setRelated(LinkParent::fromEntity($entity)) + ->setActionId($params->getActionId()); + + $this->entityManager->saveEntity($notification); + } + } +} diff --git a/application/Espo/Classes/Cleanup/AppLog.php b/application/Espo/Classes/Cleanup/AppLog.php new file mode 100644 index 0000000..ce58f18 --- /dev/null +++ b/application/Espo/Classes/Cleanup/AppLog.php @@ -0,0 +1,69 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Field\DateTime; +use Espo\Core\Utils\Config; +use Espo\Entities\AppLogRecord; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\DeleteBuilder; + +class AppLog implements Cleanup +{ + private const PERIOD = '30 days'; + + public function __construct( + private EntityManager $entityManager, + private Config $config + ) {} + + public function process(): void + { + if (!$this->config->get('cleanupAppLog')) { + return; + } + + $query = DeleteBuilder::create() + ->from(AppLogRecord::ENTITY_TYPE) + ->where(['createdAt<' => $this->getBefore()->toString()]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } + + private function getBefore(): DateTime + { + /** @var string $period */ + $period = $this->config->get('cleanupAppLogPeriod') ?? self::PERIOD; + + return DateTime::createNow()->modify('-' . $period); + } +} diff --git a/application/Espo/Classes/Cleanup/Audit.php b/application/Espo/Classes/Cleanup/Audit.php new file mode 100644 index 0000000..8ff2f89 --- /dev/null +++ b/application/Espo/Classes/Cleanup/Audit.php @@ -0,0 +1,105 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Field\DateTime; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Note; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class Audit implements Cleanup +{ + private const PERIOD = '3 months'; + + public function __construct( + private Metadata $metadata, + private EntityManager $entityManager, + private Config $config + ) {} + + public function process(): void + { + if (!$this->config->get('cleanupAudit')) { + return; + } + + $entityTypeList = $this->getEntityTypeList(); + + foreach ($entityTypeList as $scope) { + $this->processEntityType($scope); + } + } + + private function processEntityType(string $entityType): void + { + $query = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(Note::ENTITY_TYPE) + ->where([ + 'parentType' => $entityType, + 'createdAt<' => $this->getBefore()->toString(), + 'type' => [Note::TYPE_UPDATE], + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } + + /** + * @return string[] + */ + private function getEntityTypeList(): array + { + /** @var string[] $scopeList */ + $scopeList = array_keys($this->metadata->get(['scopes']) ?? []); + + $scopeList = array_filter($scopeList, function ($item) { + return $this->metadata->get("scopes.$item.entity") && + !$this->metadata->get("scopes.$item.preserveAuditLog") && + !$this->metadata->get("scopes.$item.stream"); + }); + + return array_values($scopeList); + } + + private function getBefore(): DateTime + { + /** @var string $period */ + $period = $this->config->get('cleanupAuditPeriod') ?? self::PERIOD; + + return DateTime::createNow()->modify('-' . $period); + } +} diff --git a/application/Espo/Classes/Cleanup/Exports.php b/application/Espo/Classes/Cleanup/Exports.php new file mode 100644 index 0000000..98799a4 --- /dev/null +++ b/application/Espo/Classes/Cleanup/Exports.php @@ -0,0 +1,73 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Utils\Config; +use Espo\ORM\EntityManager; + +use Espo\Core\Field\DateTime; + +use Espo\Entities\Export; + +class Exports implements Cleanup +{ + private $config; + + private $entityManager; + + private string $cleanupPeriod = '2 days'; + + public function __construct(Config $config, EntityManager $entityManager) + { + $this->config = $config; + $this->entityManager = $entityManager; + } + + public function process(): void + { + $period = '-' . $this->config->get('cleanupExportsPeriod', $this->cleanupPeriod); + + $before = DateTime::createNow() + ->modify($period) + ->toString(); + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(Export::ENTITY_TYPE) + ->where([ + 'createdAt<' => $before, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } +} diff --git a/application/Espo/Classes/Cleanup/MassActions.php b/application/Espo/Classes/Cleanup/MassActions.php new file mode 100644 index 0000000..a85f49d --- /dev/null +++ b/application/Espo/Classes/Cleanup/MassActions.php @@ -0,0 +1,71 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Utils\Config; +use Espo\ORM\EntityManager; + +use Espo\Core\Field\DateTime; + +class MassActions implements Cleanup +{ + private $config; + + private $entityManager; + + private string $cleanupPeriod = '14 days'; + + public function __construct(Config $config, EntityManager $entityManager) + { + $this->config = $config; + $this->entityManager = $entityManager; + } + + public function process(): void + { + $period = '-' . $this->config->get('cleanupMassActionsPeriod', $this->cleanupPeriod); + + $before = DateTime::createNow() + ->modify($period) + ->toString(); + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from('MassAction') + ->where([ + 'createdAt<' => $before, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } +} diff --git a/application/Espo/Classes/Cleanup/PasswordChangeRequests.php b/application/Espo/Classes/Cleanup/PasswordChangeRequests.php new file mode 100644 index 0000000..6802a7c --- /dev/null +++ b/application/Espo/Classes/Cleanup/PasswordChangeRequests.php @@ -0,0 +1,72 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Utils\Config; +use Espo\Core\Field\DateTime; + +use Espo\ORM\EntityManager; + +use Espo\Entities\PasswordChangeRequest; + +class PasswordChangeRequests implements Cleanup +{ + private Config $config; + private EntityManager $entityManager; + + private string $cleanupPeriod = '30 days'; + + public function __construct(Config $config, EntityManager $entityManager) + { + $this->config = $config; + $this->entityManager = $entityManager; + } + + public function process(): void + { + $period = '-' . $this->config->get('cleanupPasswordChangeRequestsPeriod', $this->cleanupPeriod); + + $before = DateTime::createNow() + ->modify($period) + ->toString(); + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(PasswordChangeRequest::ENTITY_TYPE) + ->where([ + 'createdAt<' => $before, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } +} diff --git a/application/Espo/Classes/Cleanup/Reminders.php b/application/Espo/Classes/Cleanup/Reminders.php new file mode 100644 index 0000000..2e7f976 --- /dev/null +++ b/application/Espo/Classes/Cleanup/Reminders.php @@ -0,0 +1,72 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Modules\Crm\Entities\Reminder; +use Espo\ORM\EntityManager; + +use DateTime; + +class Reminders implements Cleanup +{ + private string $cleanupRemindersPeriod = '15 days'; + + private Config $config; + private EntityManager $entityManager; + + public function __construct(Config $config, EntityManager $entityManager) + { + $this->config = $config; + $this->entityManager = $entityManager; + } + + public function process(): void + { + $period = '-' . $this->config->get('cleanupRemindersPeriod', $this->cleanupRemindersPeriod); + + $dt = new DateTime(); + + $dt->modify($period); + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(Reminder::ENTITY_TYPE) + ->where([ + 'remindAt<' => $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } +} diff --git a/application/Espo/Classes/Cleanup/Stars.php b/application/Espo/Classes/Cleanup/Stars.php new file mode 100644 index 0000000..f860b4c --- /dev/null +++ b/application/Espo/Classes/Cleanup/Stars.php @@ -0,0 +1,137 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Utils\Acl\UserAclManagerProvider; +use Espo\Entities\StarSubscription; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\DeleteBuilder; +use Espo\Tools\Stars\StarService; + +/** + * @noinspection PhpUnused + */ +class Stars implements Cleanup +{ + public function __construct( + private EntityManager $entityManager, + private UserAclManagerProvider $userAclManagerProvider, + private StarService $service + ) {} + + public function process(): void + { + foreach ($this->getEntityTypeList() as $entityType) { + $this->processEntityType($entityType); + } + } + + /** + * @return string[] + */ + private function getEntityTypeList(): array + { + $groups = $this->entityManager->getRDBRepositoryByClass(StarSubscription::class) + ->group('entityType') + ->select('entityType') + ->find(); + + $list = []; + + foreach ($groups as $group) { + $list[] = $group->get('entityType'); + } + + return $list; + } + + private function processEntityType(string $entityType): void + { + if ( + !$this->service->isEnabled($entityType) || + !$this->entityManager->hasRepository($entityType) + ) { + $deleteQuery = DeleteBuilder::create() + ->from(StarSubscription::ENTITY_TYPE) + ->where(['entityType' => $entityType]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($deleteQuery); + + return; + } + + $stars = $this->entityManager + ->getRDBRepositoryByClass(StarSubscription::class) + ->where(['entityType' => $entityType]) + ->sth() + ->find(); + + foreach ($stars as $star) { + $entityId = $star->get('entityId'); + $userId = $star->get('userId'); + + if ($userId === null || $entityId === null) { + continue; + } + + $entity = $this->entityManager->getEntityById($entityType, $entityId); + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$entity || !$user) { + $this->unstar($userId, $entityType, $entityId); + + continue; + } + + $aclManager = $this->userAclManagerProvider->get($user); + + if (!$aclManager->checkEntityRead($user, $entity)) { + $this->unstar($userId, $entityType, $entityId); + } + } + } + + private function unstar(string $userId, string $entityType, string $entityId): void + { + $deleteQuery = DeleteBuilder::create() + ->from(StarSubscription::ENTITY_TYPE) + ->where([ + 'userId' => $userId, + 'entityType' => $entityType, + 'entityId' => $entityId, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($deleteQuery); + } +} diff --git a/application/Espo/Classes/Cleanup/Subscribers.php b/application/Espo/Classes/Cleanup/Subscribers.php new file mode 100644 index 0000000..99486ae --- /dev/null +++ b/application/Espo/Classes/Cleanup/Subscribers.php @@ -0,0 +1,130 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Field\DateTime; +use Espo\Core\Name\Field; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Entities\StreamSubscription; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\Part\Condition as Cond; + +class Subscribers implements Cleanup +{ + private const PERIOD = '2 months'; + + public function __construct( + private Metadata $metadata, + private EntityManager $entityManager, + private Config $config + ) {} + + public function process(): void + { + if (!$this->config->get('cleanupSubscribers')) { + return; + } + + /** @var string[] $scopeList */ + $scopeList = array_keys($this->metadata->get(['scopes']) ?? []); + + /** @var string[] $scopeList */ + $scopeList = array_values(array_filter( + $scopeList, + fn ($item) => (bool) $this->metadata->get(['scopes', $item, 'stream']) + )); + + foreach ($scopeList as $scope) { + $this->processEntityType($scope); + } + } + + private function processEntityType(string $entityType): void + { + /** @var ?array $data */ + $data = $this->metadata->get(['streamDefs', $entityType, 'subscribersCleanup']); + + if (!($data['enabled'] ?? false)) { + return; + } + + /** @var string $dateField */ + $dateField = $data['dateField'] ?? Field::CREATED_AT; + /** @var ?string[] $statusList */ + $statusList = $data['statusList'] ?? null; + /** @var ?string $statusField */ + $statusField = $this->metadata->get(['scopes', $entityType, 'statusField']); + + if ($statusList === null || $statusField === null) { + return; + } + + /** @var string $period */ + $period = $this->metadata->get(['streamDefs', $entityType, 'subscribersCleanup', 'period']) ?? + $this->config->get('cleanupSubscribersPeriod') ?? + self::PERIOD; + + $before = DateTime::createNow()->modify('-' . $period); + + $query = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(StreamSubscription::ENTITY_TYPE, 'subscription') + ->join( + $entityType, + 'entity', + Cond::equal( + Cond::column('entity.id'), + Cond::column('entityId') + ) + ) + ->where( + Cond::and( + Cond::equal( + Cond::column('entityType'), + $entityType + ), + Cond::less( + Cond::column('entity.' . $dateField), + $before->toString() + ), + Cond::in( + Cond::column('entity.' . $statusField), + $statusList + ) + ) + ) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } +} diff --git a/application/Espo/Classes/Cleanup/TwoFactorCodes.php b/application/Espo/Classes/Cleanup/TwoFactorCodes.php new file mode 100644 index 0000000..d2d187d --- /dev/null +++ b/application/Espo/Classes/Cleanup/TwoFactorCodes.php @@ -0,0 +1,77 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime as DateTimeUtil; + +use Espo\ORM\EntityManager; + +use Espo\Entities\TwoFactorCode; + +use DateTime; + +class TwoFactorCodes implements Cleanup +{ + private const PERIOD = '5 days'; + + private $config; + + private $entityManager; + + public function __construct(Config $config, EntityManager $entityManager) + { + $this->config = $config; + $this->entityManager = $entityManager; + } + + public function process(): void + { + $period = '-' . $this->config->get('cleanupTwoFactorCodesPeriod', self::PERIOD); + + $from = (new DateTime()) + ->modify($period) + ->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + + $query = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(TwoFactorCode::ENTITY_TYPE) + ->where([ + 'createdAt<' => $from, + ]) + ->build(); + + $this->entityManager + ->getQueryExecutor() + ->execute($query); + } +} diff --git a/application/Espo/Classes/Cleanup/WebhookQueue.php b/application/Espo/Classes/Cleanup/WebhookQueue.php new file mode 100644 index 0000000..126b970 --- /dev/null +++ b/application/Espo/Classes/Cleanup/WebhookQueue.php @@ -0,0 +1,89 @@ +. + * + * 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\Cleanup; + +use Espo\Core\Cleanup\Cleanup; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Entities\WebhookEventQueueItem; +use Espo\Entities\WebhookQueueItem; +use Espo\ORM\EntityManager; +use DateTime; +use Espo\ORM\Name\Attribute; + +/** + * @noinspection PhpUnused + */ +class WebhookQueue implements Cleanup +{ + private string $cleanupWebhookQueuePeriod = '10 days'; + + public function __construct(private Config $config, private EntityManager $entityManager) + {} + + public function process(): void + { + $period = '-' . $this->config->get('cleanupWebhookQueuePeriod', $this->cleanupWebhookQueuePeriod); + + $datetime = new DateTime(); + $datetime->modify($period); + $from = $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + + $query1 = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(WebhookQueueItem::ENTITY_TYPE) + ->where([ + 'DATE:(createdAt)<' => $from, + 'OR' => [ + 'status!=' => WebhookQueueItem::STATUS_PENDING, + Attribute::DELETED => true, + ], + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query1); + + $query2 = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(WebhookEventQueueItem::ENTITY_TYPE) + ->where([ + 'DATE:(createdAt)<' => $from, + 'OR' => [ + 'isProcessed' => true, + Attribute::DELETED => true, + ], + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query2); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/AppCheck.php b/application/Espo/Classes/ConsoleCommands/AppCheck.php new file mode 100644 index 0000000..b527507 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/AppCheck.php @@ -0,0 +1,128 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Authentication\ConfigDataProvider as AuthenticationConfig; +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Upgrades\Migration\VersionDataProvider; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\Database\Helper; +use Exception; + +/** + * @noinspection PhpUnused + */ +class AppCheck implements Command +{ + public function __construct( + private Helper $databaseHelper, + private SystemConfig $systemConfig, + private VersionDataProvider $versionDataProvider, + private AuthenticationConfig $authenticationConfig, + ) {} + + public function run(Params $params, IO $io): void + { + $this->versionMatch($io); + $this->checkDb($io); + $this->maintenanceIsOff($io); + $this->cronEnabled($io); + } + + private function versionMatch(IO $io): void + { + $io->write('Migration not needed: '); + + if ($this->systemConfig->getVersion() === $this->versionDataProvider->getTargetVersion()) { + $this->writeOK($io); + } else { + $this->writeFail($io); + $io->setExitStatus(1); + } + + $io->writeLine(''); + } + + private function checkDb(IO $io): void + { + $io->write('Database: '); + + try { + $this->databaseHelper->createPDO(); + + $this->writeOK($io); + } catch (Exception) { + $this->writeFail($io); + $io->setExitStatus(1); + } + + $io->writeLine(''); + } + + private function maintenanceIsOff(IO $io): void + { + $io->write('Not in maintenance mode: '); + + if (!$this->authenticationConfig->isMaintenanceMode()) { + $this->writeOK($io); + } else { + $this->writeFail($io); + $io->setExitStatus(1); + } + + $io->writeLine(''); + } + + private function cronEnabled(IO $io): void + { + $io->write('Cron is enabled: '); + + if ($this->systemConfig->isCronEnabled()) { + $this->writeOK($io); + } else { + $this->writeFail($io); + $io->setExitStatus(1); + } + + $io->writeLine(''); + } + + private function writeOK(IO $io): void + { + $io->write("\033[32mOK\033[0m"); + } + + private function writeFail(IO $io): void + { + $io->write("\033[31mFAIL\033[0m"); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/CheckFilePermissions.php b/application/Espo/Classes/ConsoleCommands/CheckFilePermissions.php new file mode 100644 index 0000000..a93714a --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/CheckFilePermissions.php @@ -0,0 +1,70 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\System; +use Espo\Core\Utils\Util; + +/** + * @noinspection PhpUnused + */ +class CheckFilePermissions implements Command +{ + public function __construct( + private FileManager $fileManager, + private System $system + ) {} + + public function run(Params $params, IO $io): void + { + $io->writeLine("\nNote: Run this command under the web server user.\n"); + + $io->writeLine('Writable:'); + $io->writeLine(''); + + foreach ($this->fileManager->getPermissionUtils()->getWritableList() as $path) { + $fullPath = Util::concatPath($this->system->getRootDir(), $path); + + $isWritable = $this->fileManager->isWritable($fullPath); + + $msg = " " . ($isWritable ? "OK" : "FAIL") . " : $path"; + + if (!$isWritable) { + $io->setExitStatus(1); + } + + $io->writeLine($msg); + } + } +} diff --git a/application/Espo/Classes/ConsoleCommands/ConfigPopulate.php b/application/Espo/Classes/ConsoleCommands/ConfigPopulate.php new file mode 100644 index 0000000..439f45b --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/ConfigPopulate.php @@ -0,0 +1,50 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Utils\Config\Populator; + +/** + * @noinspection PhpUnused + */ +class ConfigPopulate implements Command +{ + public function __construct( + private Populator $populator, + ) {} + + public function run(Params $params, IO $io): void + { + $this->populator->populate(); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/CreateAdminUser.php b/application/Espo/Classes/ConsoleCommands/CreateAdminUser.php new file mode 100644 index 0000000..9daa672 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/CreateAdminUser.php @@ -0,0 +1,105 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Utils\Config; +use Espo\Entities\User; +use Espo\ORM\EntityManager; + +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class CreateAdminUser implements Command +{ + public function __construct( + private EntityManager $entityManager, + private Config $config + ) {} + + public function run(Params $params, IO $io): void + { + $userName = $params->getArgument(0); + + if (!$userName) { + $io->writeLine("A username must be specified as the first argument."); + $io->setExitStatus(1); + + return; + } + + /** @var ?string $regExp */ + $regExp = $this->config->get('userNameRegularExpression'); + + if (!$regExp) { + throw new RuntimeException("No `userNameRegularExpression` in config."); + } + + if ( + str_contains($userName, ' ') || + preg_replace("/{$regExp}/", '_', $userName) !== $userName + ) { + $io->writeLine("Not allowed username."); + $io->setExitStatus(1); + + return; + } + + $repository = $this->entityManager->getRDBRepositoryByClass(User::class); + + $existingUser = $repository + ->where([User::FIELD_USER_NAME => $userName]) + ->findOne(); + + if ($existingUser) { + $io->writeLine("A user with the same username already exists."); + $io->setExitStatus(1); + + return; + } + + $user = $repository->getNew(); + + $user->setUserName($userName); + $user->setType(User::TYPE_ADMIN); + $user->setLastName(ucfirst($userName)); + + $repository->save($user); + + $message = "The user '$userName' has been created. " . + "Set password with the command: `bin/command set-password $userName`."; + + $io->writeLine($message); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/CronRun.php b/application/Espo/Classes/ConsoleCommands/CronRun.php new file mode 100644 index 0000000..67f51ca --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/CronRun.php @@ -0,0 +1,70 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\JobManager; +use Espo\Core\Job\PrepareProcessor; +use Espo\Core\Utils\Config\SystemConfig; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class CronRun implements Command +{ + public function __construct( + private PrepareProcessor $prepareProcessor, + private JobManager $jobManager, + private SystemConfig $config, + ) {} + + public function run(Params $params, IO $io): void + { + if (!$this->config->isCronEnabled()) { + throw new RuntimeException("Cron cannot be run as 'cronDisabled' is set to true in the config."); + } + + if (!$params->hasFlag('force') && $this->config->isMaintenanceMode()) { + throw new RuntimeException("Cron cannot be run in maintenance mode. You can use --force flag."); + } + + try { + $this->prepareProcessor->process(); + } catch (TooFrequentRun $e) { + throw new RuntimeException('Too frequent run.', previous: $e); + } + + $this->jobManager->processMainQueue(); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/DbCheck.php b/application/Espo/Classes/ConsoleCommands/DbCheck.php new file mode 100644 index 0000000..a5de60f --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/DbCheck.php @@ -0,0 +1,60 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Utils\Database\Helper; +use Exception; + +/** + * @noinspection PhpUnused + */ +class DbCheck implements Command +{ + public function __construct( + private Helper $helper, + ) {} + + public function run(Params $params, IO $io): void + { + try { + $this->helper->createPDO(); + + $io->writeLine('OK'); + } catch (Exception) { + $io->writeErrorLine("Not connected"); + $io->setExitStatus(1); + + return; + } + } +} diff --git a/application/Espo/Classes/ConsoleCommands/GetConfigParam.php b/application/Espo/Classes/ConsoleCommands/GetConfigParam.php new file mode 100644 index 0000000..66ff74d --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/GetConfigParam.php @@ -0,0 +1,93 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Json; + +/** + * @noinspection PhpUnused + */ +class GetConfigParam implements Command +{ + public function __construct( + private Config $config, + ) {} + + public function run(Params $params, IO $io): void + { + $param = $params->getArgument(0) ?? null; + + if ($param === null) { + $io->writeLine("Parameter name is not specified."); + $io->setExitStatus(1); + + return; + } + + if (!$this->config->has($param)) { + $io->writeLine("Parameter '$param' is not set in config."); + $io->setExitStatus(1); + + return; + } + + $isJson = $params->hasFlag('json'); + + $value = $this->config->get($param); + + $value = $this->formatValue($value, $isJson); + + $io->writeLine($value); + } + + private function formatValue(mixed $value, bool $isJson): string + { + if ($isJson) { + return self::toJson($value); + } + + return match (true) { + $value === true => 'true', + $value === false => 'false', + $value === null => 'null', + is_scalar($value) => (string) $value, + default => self::toJson($value), + }; + } + + private static function toJson(mixed $value): string + { + return Json::encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/Import.php b/application/Espo/Classes/ConsoleCommands/Import.php new file mode 100644 index 0000000..a45dc6c --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/Import.php @@ -0,0 +1,143 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Tools\Import\Service; + +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; + +use Throwable; + +class Import implements Command +{ + public function __construct(private Service $service, private FileManager $fileManager) + {} + + public function run(Params $params, IO $io) : void + { + $id = $params->getOption('id'); + $filePath = $params->getOption('file'); + $paramsId = $params->getOption('paramsId'); + + $forceResume = $params->hasFlag('resume'); + $revert = $params->hasFlag('revert'); + + if (!$id && $filePath) { + if (!$paramsId) { + $io->writeLine("You need to specify --params-id option."); + $io->setExitStatus(1); + + return; + } + + if (!$this->fileManager->isFile($filePath)) { + $io->writeLine("File not found."); + $io->setExitStatus(1); + + return; + } + + $contents = $this->fileManager->getContents($filePath); + + try { + $result = $this->service->importContentsWithParamsId($contents, $paramsId); + + $resultId = $result->getId(); + $countCreated = $result->getCountCreated(); + $countUpdated = $result->getCountUpdated(); + $countError = $result->getCountError(); + $countDuplicate = $result->getCountDuplicate(); + } catch (Throwable $e) { + $io->writeLine("Error occurred: " . $e->getMessage()); + $io->setExitStatus(1); + + return; + } + + $io->writeLine("Finished."); + $io->writeLine(" Import ID: {$resultId}"); + $io->writeLine(" Created: {$countCreated}"); + $io->writeLine(" Updated: {$countUpdated}"); + $io->writeLine(" Duplicates: {$countDuplicate}"); + $io->writeLine(" Errors: {$countError}"); + + return; + } + + if ($id && $revert) { + $io->writeLine("Reverting import..."); + + try { + $this->service->revert($id); + } catch (Throwable $e) { + $io->writeLine("Error occurred: " . $e->getMessage()); + $io->setExitStatus(1); + + return; + } + + $io->writeLine("Finished."); + + return; + } + + if ($id) { + $io->writeLine("Running import, this may take a while..."); + + try { + $result = $this->service->importById($id, true, $forceResume); + } catch (Throwable $e) { + $io->writeLine("Error occurred: " . $e->getMessage()); + $io->setExitStatus(1); + + return; + } + + $countCreated = $result->getCountCreated(); + $countUpdated = $result->getCountUpdated(); + $countError = $result->getCountError(); + $countDuplicate = $result->getCountDuplicate(); + + $io->writeLine("Finished."); + $io->writeLine(" Created: {$countCreated}"); + $io->writeLine(" Updated: {$countUpdated}"); + $io->writeLine(" Duplicates: {$countDuplicate}"); + $io->writeLine(" Errors: {$countError}"); + + return; + } + + $io->writeLine("Not enough params passed."); + $io->setExitStatus(1); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/JobEnqueue.php b/application/Espo/Classes/ConsoleCommands/JobEnqueue.php new file mode 100644 index 0000000..822645c --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/JobEnqueue.php @@ -0,0 +1,122 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\Exceptions\InvalidArgument; +use Espo\Core\Console\IO; +use Espo\Core\Job\Processing\EnqueueDaemon; +use Espo\Core\Job\QueueName; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class JobEnqueue implements Command +{ + public function __construct( + private EnqueueDaemon $enqueueDaemon, + ) {} + + public function run(Params $params, IO $io): void + { + $daemonParams = $this->prepareParams($params); + + $this->enqueueDaemon->run($daemonParams); + } + + private function prepareParams(Params $params): EnqueueDaemon\Params + { + $interval = $this->getInterval($params); + $limit = $this->getLimit($params); + $portion = $this->getPortion($params); + $queue = $params->getOption('queue'); + + if ($queue === QueueName::M0) { + throw new InvalidArgument("Queue 'm0' is not allowed. It is processed in the main queue."); + } + + return new EnqueueDaemon\Params( + interval: $interval, + limit: $limit, + portion: $portion, + queue: $queue, + ); + } + + private function getInterval(Params $params): ?float + { + $valueString = $params->getOption('interval'); + + if ($valueString === null) { + return null; + } + + if ( + filter_var($valueString, FILTER_VALIDATE_INT) !== false || + filter_var($valueString, FILTER_VALIDATE_FLOAT) !== false + ) { + return (float) $valueString; + } + + throw new RuntimeException("Bad interval."); + } + + private function getLimit(Params $params): ?int + { + $valueString = $params->getOption('limit'); + + if ($valueString === null) { + return null; + } + + if (filter_var($valueString, FILTER_VALIDATE_INT) !== false) { + return (int) $valueString; + } + + throw new RuntimeException("Bad limit."); + } + + private function getPortion(Params $params): ?int + { + $valueString = $params->getOption('portion'); + + if ($valueString === null) { + return null; + } + + if (filter_var($valueString, FILTER_VALIDATE_INT) !== false) { + return (int) $valueString; + } + + throw new RuntimeException("Bad portion."); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/JobPrepare.php b/application/Espo/Classes/ConsoleCommands/JobPrepare.php new file mode 100644 index 0000000..1d1d538 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/JobPrepare.php @@ -0,0 +1,94 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Job\Processing\PrepareDaemon; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class JobPrepare implements Command +{ + public function __construct( + private PrepareDaemon $prepareDaemon, + ) {} + + public function run(Params $params, IO $io): void + { + $interval = $this->getInterval($params); + $limit = $this->getLimit($params); + $skipQueues = $params->hasFlag('sq'); + + $daemonParams = new PrepareDaemon\Params( + interval: $interval, + limit: $limit, + skipQueues: $skipQueues, + ); + + $this->prepareDaemon->run($daemonParams); + } + + private function getInterval(Params $params): ?float + { + $intervalString = $params->getOption('interval'); + + if ($intervalString === null) { + return null; + } + + if ( + filter_var($intervalString, FILTER_VALIDATE_INT) !== false || + filter_var($intervalString, FILTER_VALIDATE_FLOAT) !== false + ) { + return (float) $intervalString; + } else { + throw new RuntimeException("Bad interval."); + } + } + + private function getLimit(Params $params): ?int + { + $limitString = $params->getOption('limit'); + + if ($limitString === null) { + return null; + } + + if (filter_var($limitString, FILTER_VALIDATE_INT) !== false) { + return (int) $limitString; + } else { + throw new RuntimeException("Bad limit."); + } + } +} diff --git a/application/Espo/Classes/ConsoleCommands/JobWorker.php b/application/Espo/Classes/ConsoleCommands/JobWorker.php new file mode 100644 index 0000000..2733b18 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/JobWorker.php @@ -0,0 +1,86 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\Exceptions\InvalidArgument; +use Espo\Core\Console\IO; +use Espo\Core\Job\Processing\WorkerDaemon; +use Espo\Core\Job\QueueName; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class JobWorker implements Command +{ + public function __construct( + private WorkerDaemon $workerDaemon, + ) {} + + public function run(Params $params, IO $io): void + { + $daemonParams = $this->prepareParams($params); + + $this->workerDaemon->run($daemonParams); + } + + private function prepareParams(Params $params): WorkerDaemon\Params + { + $limit = $this->getLimit($params); + + $queue = $params->getOption('queue'); + + if ($queue === QueueName::M0) { + throw new InvalidArgument("Queue 'm0' is not allowed. It is processed in the main queue."); + } + + return new WorkerDaemon\Params( + limit: $limit, + queue: $queue, + ); + } + + private function getLimit(Params $params): ?int + { + $limitString = $params->getOption('limit'); + + if ($limitString === null) { + return null; + } + + if (filter_var($limitString, FILTER_VALIDATE_INT) !== false) { + return (int) $limitString; + } + + throw new RuntimeException("Bad limit."); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/PopulateArrayValues.php b/application/Espo/Classes/ConsoleCommands/PopulateArrayValues.php new file mode 100644 index 0000000..9faff51 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/PopulateArrayValues.php @@ -0,0 +1,108 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Exceptions\Error; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Entities\ArrayValue; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Repositories\ArrayValue as ArrayValueRepository; + +class PopulateArrayValues implements Command +{ + private EntityManager $entityManager; + + public function __construct(EntityManager $entityManager) + { + $this->entityManager = $entityManager; + } + + /** + * @throws Error + */ + public function run(Params $params, IO $io): void + { + $entityType = $params->getArgument(0); + $field = $params->getArgument(1); + + if (!$entityType || !$field) { + throw new Error("Entity type and field should be passed as arguments."); + } + + if (!$this->entityManager->hasRepository($entityType)) { + throw new Error("Bad entity type."); + } + + $defs = $this->entityManager->getDefs()->getEntity($entityType); + + if (!$defs->hasAttribute($field)) { + throw new Error("Bad field."); + } + + if ($defs->getAttribute($field)->getType() !== Entity::JSON_ARRAY) { + throw new Error("Non-array field."); + } + + if ($defs->getAttribute($field)->isNotStorable()) { + throw new Error("Not-storable field."); + } + + if (!$defs->getAttribute($field)->getParam('storeArrayValues')) { + throw new Error("Array values disabled for the field.."); + } + + $collection = $this->entityManager + ->getRDBRepository($entityType) + ->sth() + ->find(); + + /** @var ArrayValueRepository $repository */ + $repository = $this->entityManager->getRepository(ArrayValue::ENTITY_TYPE); + + foreach ($collection as $i => $entity) { + if (!$entity instanceof CoreEntity) { + throw new Error(); + } + + $repository->storeEntityAttribute($entity, $field); + + if ($i % 1000 === 0) { + $io->write('.'); + } + } + + $io->writeLine(''); + $io->writeLine('Done.'); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/PopulateNumbers.php b/application/Espo/Classes/ConsoleCommands/PopulateNumbers.php new file mode 100644 index 0000000..2f8913d --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/PopulateNumbers.php @@ -0,0 +1,116 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\Exceptions\ArgumentNotSpecified; +use Espo\Core\Console\Exceptions\InvalidArgument; +use Espo\Core\Console\IO; +use Espo\Core\Exceptions\Error; +use Espo\Core\FieldProcessing\NextNumber\Processor; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\Part\Order; + +class PopulateNumbers implements Command +{ + private Processor $beforeSaveProcessor; + private EntityManager $entityManager; + + public function __construct( + Processor $beforeSaveProcessor, + EntityManager $entityManager + ) { + $this->beforeSaveProcessor = $beforeSaveProcessor; + $this->entityManager = $entityManager; + } + + /** + * @throws Error + */ + public function run(Params $params, IO $io): void + { + $entityType = $params->getArgument(0); + $field = $params->getArgument(1); + + $orderBy = $params->getOption('orderBy') ?? Field::CREATED_AT; + $order = strtoupper($params->getOption('order') ?? Order::ASC); + + if (!$entityType) { + throw new ArgumentNotSpecified("No entity type argument."); + } + + if (!$field) { + throw new ArgumentNotSpecified("No field argument."); + } + + if ($order !== Order::ASC && $order !== Order::DESC) { + throw new InvalidArgument("Bad order option."); + } + + $fieldType = $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->getField($field) + ->getType(); + + if ($fieldType !== 'number') { + throw new InvalidArgument("Field `{$field}` is not of `number` type."); + } + + $collection = $this->entityManager + ->getRDBRepository($entityType) + ->where([ + $field => null, + ]) + ->order($orderBy, $order) + ->sth() + ->find(); + + foreach ($collection as $i => $entity) { + if (!$entity instanceof CoreEntity) { + throw new Error(); + } + + $this->beforeSaveProcessor->processPopulate($entity, $field); + $this->entityManager->saveEntity($entity, [SaveOption::IMPORT => true]); + + if ($i % 1000 === 0) { + $io->write('.'); + } + } + + $io->writeLine(''); + $io->writeLine('Done.'); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/PopulateScheduledJobs.php b/application/Espo/Classes/ConsoleCommands/PopulateScheduledJobs.php new file mode 100644 index 0000000..864fd3b --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/PopulateScheduledJobs.php @@ -0,0 +1,62 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Utils\ScheduledJob\Populator; + +/** + * @noinspection PhpUnused + */ +class PopulateScheduledJobs implements Command +{ + public function __construct( + private Populator $populator, + ) {} + + public function run(Params $params, IO $io): void + { + $names = $this->populator->populate(); + + if ($names === []) { + $io->writeLine("Nothing to create."); + + return; + } + + $io->writeLine("Created scheduled jobs:"); + + foreach ($names as $name) { + $io->writeLine(' ' . $name); + } + } +} diff --git a/application/Espo/Classes/ConsoleCommands/RebuildCategoryPaths.php b/application/Espo/Classes/ConsoleCommands/RebuildCategoryPaths.php new file mode 100644 index 0000000..ecba629 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/RebuildCategoryPaths.php @@ -0,0 +1,69 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Tools\CategoryTree\RebuildPaths; +use Exception; + +class RebuildCategoryPaths implements Command +{ + private RebuildPaths $rebuildPaths; + + public function __construct(RebuildPaths $rebuildPaths) + { + $this->rebuildPaths = $rebuildPaths; + } + + public function run(Params $params, IO $io): void + { + $entityType = $params->getArgument(0); + + if (!$entityType) { + $io->setExitStatus(1); + $io->writeLine("Error: No entity type. Should be specified as the first argument."); + + return; + } + + try { + $this->rebuildPaths->run($entityType); + } catch (Exception $e) { + $io->setExitStatus(1); + $io->writeLine("Error: " . $e->getMessage()); + + return; + } + + $io->writeLine("Done."); + } +} diff --git a/application/Espo/Classes/ConsoleCommands/SetConfigParam.php b/application/Espo/Classes/ConsoleCommands/SetConfigParam.php new file mode 100644 index 0000000..90b62f4 --- /dev/null +++ b/application/Espo/Classes/ConsoleCommands/SetConfigParam.php @@ -0,0 +1,219 @@ +. + * + * 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\ConsoleCommands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\ConfigWriter; +use Espo\Core\Utils\Json; +use Espo\Core\Utils\ObjectUtil; +use Exception; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class SetConfigParam implements Command +{ + private const string TYPE_STRING = 'string'; + private const string TYPE_BOOL = 'bool'; + private const string TYPE_INT = 'int'; + private const string TYPE_FLOAT = 'float'; + private const string TYPE_JSON = 'json'; + private const string TYPE_AUTO = 'auto'; + + /** @var string[] */ + private const array ALLOWED_TYPES = [ + self::TYPE_STRING, + self::TYPE_BOOL, + self::TYPE_INT, + self::TYPE_FLOAT, + self::TYPE_JSON, + self::TYPE_AUTO, + ]; + + public function __construct( + private Config $config, + private ConfigWriter $configWriter, + ) {} + + public function run(Params $params, IO $io): void + { + $type = $params->getOption('type') ?? self::TYPE_STRING; + + if (!in_array($type, self::ALLOWED_TYPES)) { + $io->writeLine("Not allowed type."); + $io->setExitStatus(1); + + return; + } + + $name = $params->getArgument(0) ?? null; + $value = $params->getArgument(1) ?? null; + + if ($name === null) { + $io->writeLine("Parameter name and value are not specified."); + $io->setExitStatus(1); + + return; + } + + if ($value === null) { + $io->writeLine("Value is not specified."); + $io->setExitStatus(1); + + return; + } + + $value = $this->prepareValue($io, $value, $type); + + if ($io->getExitStatus() !== 0) { + return; + } + + if (str_contains($name, '.')) { + if (substr_count($name, '.') > 1) { + $io->writeLine("Cannot use a parameter name with more than one dot."); + $io->setExitStatus(1); + + return; + } + + [$nameOne, $subName] = explode('.', $name); + + if ($nameOne === '' || $subName === '') { + $io->writeLine("Bad parameter name."); + $io->setExitStatus(1); + + return; + } + + $setValue = $this->config->get($nameOne) ?? []; + + if ($setValue instanceof stdClass) { + $setValue = ObjectUtil::clone($setValue); + + $setValue->$subName = $value; + } else if (is_array($setValue) && !array_is_list($setValue)) { + $setValue[$subName] = $value; + } else { + $io->writeLine("No parameter parameter '$nameOne' in config."); + $io->setExitStatus(1); + + return; + } + + $value = $setValue; + $name = $nameOne; + } + + $this->configWriter->set($name, $value); + $this->configWriter->save(); + } + + private function prepareValue(IO $io, string $value, string $type): mixed + { + if ($type === self::TYPE_AUTO) { + $lower = strtolower($value); + + if ($lower === 'true') { + return true; + } + + if ($lower === 'false') { + return false; + } + + if ($lower === 'null') { + return null; + } + + if (filter_var($value, FILTER_VALIDATE_INT) !== false) { + return (int) $value; + } + + if (filter_var($value, FILTER_VALIDATE_FLOAT) !== false) { + return (float) $value; + } + + return $value; + } + + if ($type === self::TYPE_JSON) { + try { + return Json::decode($value); + } catch (Exception) { + $io->writeLine("Invalid JSON."); + $io->setExitStatus(1); + + return null; + } + } + + if ($type === self::TYPE_INT) { + if (!is_numeric($value)) { + $io->writeLine("Value is not numeric."); + $io->setExitStatus(1); + + return null; + } + + return (int) $value; + } + + if ($type === self::TYPE_FLOAT) { + if (!is_numeric($value)) { + $io->writeLine("Value is not numeric."); + $io->setExitStatus(1); + + return null; + } + + return (float) $value; + } + + if ($type === self::TYPE_BOOL) { + $value = strtolower($value); + + if (!in_array($value, ['true', 'false', '1', '0'])) { + $io->writeLine("Value is not boolean."); + $io->setExitStatus(1); + + return null; + } + + return $value === 'true' || $value === '1'; + } + + return $value; + } +} diff --git a/application/Espo/Classes/DefaultLayouts/DefaultSidePanelType.php b/application/Espo/Classes/DefaultLayouts/DefaultSidePanelType.php new file mode 100644 index 0000000..9c98a8a --- /dev/null +++ b/application/Espo/Classes/DefaultLayouts/DefaultSidePanelType.php @@ -0,0 +1,85 @@ +. + * + * 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\DefaultLayouts; + +use Espo\Core\Name\Field; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\ORM\Defs\Params\RelationParam; +use stdClass; + +class DefaultSidePanelType +{ + public function __construct(private Metadata $metadata) + {} + + /** + * @return stdClass[] + */ + public function get(string $scope): array + { + $list = []; + + if ( + $this->metadata->get(['entityDefs', $scope, 'fields', Field::ASSIGNED_USER, FieldParam::TYPE]) === + FieldType::LINK && + $this->metadata->get(['entityDefs', $scope, 'links', Field::ASSIGNED_USER, RelationParam::ENTITY]) === + User::ENTITY_TYPE + || + $this->metadata->get(['entityDefs', $scope, 'fields', Field::ASSIGNED_USERS, FieldParam::TYPE]) === + FieldType::LINK_MULTIPLE && + $this->metadata->get(['entityDefs', $scope, 'links', Field::ASSIGNED_USERS, RelationParam::ENTITY]) === + User::ENTITY_TYPE + ) { + $list[] = (object) ['name' => ':assignedUser']; + } + + if ( + $this->metadata->get(['entityDefs', $scope, 'fields', Field::TEAMS, FieldParam::TYPE]) === + FieldType::LINK_MULTIPLE && + $this->metadata->get(['entityDefs', $scope, 'links', Field::TEAMS, RelationParam::ENTITY]) === + Team::ENTITY_TYPE + ) { + $list[] = (object) ['name' => Field::TEAMS]; + } + + if ( + $this->metadata->get("entityDefs.$scope.fields.collaborators.type") === FieldType::LINK_MULTIPLE && + $this->metadata->get("entityDefs.$scope.links.collaborators.entity") === User::ENTITY_TYPE + ) { + $list[] = (object) ['name' => Field::COLLABORATORS]; + } + + return $list; + } +} diff --git a/application/Espo/Classes/DuplicateWhereBuilders/Company.php b/application/Espo/Classes/DuplicateWhereBuilders/Company.php new file mode 100644 index 0000000..f489090 --- /dev/null +++ b/application/Espo/Classes/DuplicateWhereBuilders/Company.php @@ -0,0 +1,33 @@ +. + * + * 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\DuplicateWhereBuilders; + +class Company extends General +{} diff --git a/application/Espo/Classes/DuplicateWhereBuilders/General.php b/application/Espo/Classes/DuplicateWhereBuilders/General.php new file mode 100644 index 0000000..2619fc0 --- /dev/null +++ b/application/Espo/Classes/DuplicateWhereBuilders/General.php @@ -0,0 +1,275 @@ +. + * + * 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\DuplicateWhereBuilders; + +use Espo\Core\Duplicate\WhereBuilder; +use Espo\Core\Field\EmailAddressGroup; +use Espo\Core\Field\PhoneNumberGroup; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\Part\WhereItem; +use Espo\ORM\Type\AttributeType; + +/** + * @implements WhereBuilder + */ +class General implements WhereBuilder +{ + public function __construct( + private Metadata $metadata, + private Defs $ormDefs, + private Config $config + ) {} + + /** + * @param CoreEntity $entity + */ + public function build(Entity $entity): ?WhereItem + { + /** @var string[] $fieldList */ + $fieldList = $this->metadata->get(['scopes', $entity->getEntityType(), 'duplicateCheckFieldList']) ?? []; + + $orBuilder = OrGroup::createBuilder(); + + $toCheck = false; + + foreach ($fieldList as $field) { + $toCheckItem = $this->applyField($field, $entity, $orBuilder); + + if ($toCheckItem) { + $toCheck = true; + } + } + + if (!$toCheck) { + return null; + } + + return $orBuilder->build(); + } + + private function applyField( + string $field, + CoreEntity $entity, + OrGroupBuilder $orBuilder + ): bool { + + $type = $this->ormDefs + ->getEntity($entity->getEntityType()) + ->tryGetField($field) + ?->getType(); + + if ($type === FieldType::PERSON_NAME) { + return $this->applyFieldPersonName($field, $entity, $orBuilder); + } + + if ($type === FieldType::EMAIL) { + return $this->applyFieldEmail($field, $entity, $orBuilder); + } + + if ($type === FieldType::PHONE) { + return $this->applyFieldPhone($field, $entity, $orBuilder); + } + + if ($entity->getAttributeType($field) === AttributeType::VARCHAR) { + return $this->applyFieldVarchar($field, $entity, $orBuilder); + } + + return false; + } + + private function applyFieldPersonName( + string $field, + CoreEntity $entity, + OrGroupBuilder $orBuilder + ): bool { + + $first = 'first' . ucfirst($field); + $last = 'last' . ucfirst($field); + + if (!$entity->get($first) && !$entity->get($last)) { + return false; + } + + $orBuilder->add( + Cond::and( + Cond::equal( + Cond::column($first), + $entity->get($first) + ), + Cond::equal( + Cond::column($last), + $entity->get($last) + ) + ) + ); + + return true; + } + + private function applyFieldEmail( + string $field, + CoreEntity $entity, + OrGroupBuilder $orBuilder + ): bool { + + $toCheck = false; + + if ( + ($entity->get($field) || $entity->get($field . 'Data')) && + ( + $entity->isNew() || + $entity->isAttributeChanged($field) || + $entity->isAttributeChanged($field . 'Data') + ) + ) { + foreach ($this->getEmailAddressList($entity) as $emailAddress) { + $orBuilder->add( + Cond::equal( + Cond::column($field), + $emailAddress + ) + ); + + $toCheck = true; + } + } + + return $toCheck; + } + + private function applyFieldPhone( + string $field, + CoreEntity $entity, + OrGroupBuilder $orBuilder + ): bool { + + $toCheck = false; + + $isNumeric = $this->config->get('phoneNumberNumericSearch'); + + $column = $isNumeric ? + $field . 'Numeric' : + $field; + + if ( + ($entity->get($field) || $entity->get($field . 'Data')) && + ( + $entity->isNew() || + $entity->isAttributeChanged($field) || + $entity->isAttributeChanged($field . 'Data') + ) + ) { + foreach ($this->getPhoneNumberList($entity) as $number) { + if ($isNumeric) { + $number = preg_replace('/[^0-9]/', '', $number); + } + + $orBuilder->add( + Cond::equal( + Cond::column($column), + $number + ) + ); + + $toCheck = true; + } + } + + return $toCheck; + } + + private function applyFieldVarchar( + string $field, + CoreEntity $entity, + OrGroupBuilder $orBuilder + ): bool { + + if (!$entity->get($field)) { + return false; + } + + $orBuilder->add( + Cond::equal( + Cond::column($field), + $entity->get($field) + ), + ); + + return true; + } + + /** + * @return string[] + */ + private function getEmailAddressList(CoreEntity $entity): array + { + if ($entity->get('emailAddressData')) { + /** @var EmailAddressGroup $eaGroup */ + $eaGroup = $entity->getValueObject('emailAddress'); + + return $eaGroup->getAddressList(); + } + + if ($entity->get('emailAddress')) { + return [ + $entity->get('emailAddress') + ]; + } + + return []; + } + + /** + * @return string[] + */ + private function getPhoneNumberList(CoreEntity $entity): array + { + if ($entity->get('phoneNumberData')) { + /** @var PhoneNumberGroup $eaGroup */ + $eaGroup = $entity->getValueObject('phoneNumber'); + + return $eaGroup->getNumberList(); + } + + if ($entity->get('phoneNumber')) { + return [$entity->get('phoneNumber')]; + } + + return []; + } +} diff --git a/application/Espo/Classes/DuplicateWhereBuilders/Name.php b/application/Espo/Classes/DuplicateWhereBuilders/Name.php new file mode 100644 index 0000000..2651ae5 --- /dev/null +++ b/application/Espo/Classes/DuplicateWhereBuilders/Name.php @@ -0,0 +1,55 @@ +. + * + * 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\DuplicateWhereBuilders; + +use Espo\Core\Duplicate\WhereBuilder; + +use Espo\Core\Name\Field; +use Espo\ORM\Entity; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\WhereItem; + +/** + * @implements WhereBuilder + */ +class Name implements WhereBuilder +{ + public function build(Entity $entity): ?WhereItem + { + if ($entity->get(Field::NAME)) { + return Cond::equal( + Cond::column(Field::NAME), + $entity->get(Field::NAME) + ); + } + + return null; + } +} diff --git a/application/Espo/Classes/DuplicateWhereBuilders/Person.php b/application/Espo/Classes/DuplicateWhereBuilders/Person.php new file mode 100644 index 0000000..864e350 --- /dev/null +++ b/application/Espo/Classes/DuplicateWhereBuilders/Person.php @@ -0,0 +1,33 @@ +. + * + * 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\DuplicateWhereBuilders; + +class Person extends General +{} diff --git a/application/Espo/Classes/FieldConverters/RelationshipRole.php b/application/Espo/Classes/FieldConverters/RelationshipRole.php new file mode 100644 index 0000000..4485dd1 --- /dev/null +++ b/application/Espo/Classes/FieldConverters/RelationshipRole.php @@ -0,0 +1,201 @@ +. + * + * 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\FieldConverters; + +use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs; +use Espo\Core\Utils\Database\Orm\Defs\EntityDefs; +use Espo\Core\Utils\Database\Orm\FieldConverter; +use Espo\ORM\Defs\FieldDefs; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Type\AttributeType; +use RuntimeException; + +class RelationshipRole implements FieldConverter +{ + public function convert(FieldDefs $fieldDefs, string $entityType): EntityDefs + { + $name = $fieldDefs->getName(); + + $attributeDefs = AttributeDefs::create($name) + ->withType(AttributeType::VARCHAR) + ->withNotStorable(); + + $attributeDefs = $this->addWhere($attributeDefs, $fieldDefs, $entityType); + + return EntityDefs::create() + ->withAttribute($attributeDefs); + } + + private function addWhere(AttributeDefs $attributeDefs, FieldDefs $fieldDefs, string $entityType): AttributeDefs + { + $data = $fieldDefs->getParam('converterData'); + + if (!is_array($data)) { + throw new RuntimeException("No `converterData` in field defs."); + } + + /** @var ?string $column */ + $column = $data['column'] ?? null; + /** @var ?string $link */ + $link = $data['link'] ?? null; + /** @var ?string $relationName */ + $relationName = $data[RelationParam::RELATION_NAME] ?? null; + /** @var ?string $nearKey */ + $nearKey = $data['nearKey'] ?? null; + + if (!$column || !$link || !$relationName || !$nearKey) { + throw new RuntimeException("Bad `converterData`."); + } + + $midTable = ucfirst($relationName); + + return $attributeDefs->withParamsMerged([ + 'where' => [ + '=' => [ + 'whereClause' => [ + 'id=s' => [ + 'from' => $midTable, + 'select' => [$nearKey], + 'whereClause' => [ + Attribute::DELETED => false, + $column => '{value}', + ], + ], + ], + ], + '<>' => [ + 'whereClause' => [ + 'id!=s' => [ + 'from' => $midTable, + 'select' => [$nearKey], + 'whereClause' => [ + Attribute::DELETED => false, + $column => '{value}', + ], + ], + ], + ], + 'IN' => [ + 'whereClause' => [ + 'id=s' => [ + 'from' => $midTable, + 'select' => [$nearKey], + 'whereClause' => [ + Attribute::DELETED => false, + $column => '{value}', + ], + ], + ], + ], + 'NOT IN' => [ + 'whereClause' => [ + 'id!=s' => [ + 'from' => $midTable, + 'select' => [$nearKey], + 'whereClause' => [ + Attribute::DELETED => false, + $column => '{value}', + ], + ], + ], + ], + 'LIKE' => [ + 'whereClause' => [ + 'id=s' => [ + 'from' => $midTable, + 'select' => [$nearKey], + 'whereClause' => [ + Attribute::DELETED => false, + "$column*" => '{value}', + ], + ], + ], + ], + 'NOT LIKE' => [ + 'whereClause' => [ + 'id!=s' => [ + 'from' => $midTable, + 'select' => [$nearKey], + 'whereClause' => [ + Attribute::DELETED => false, + "$column*" => '{value}', + ], + ], + ], + ], + 'IS NULL' => [ + 'whereClause' => [ + 'NOT' => [ + 'EXISTS' => [ + 'from' => $entityType, + 'fromAlias' => 'sq', + 'select' => [Attribute::ID], + 'leftJoins' => [ + [ + $link, + 'm', + null, + ['onlyMiddle' => true] + ] + ], + 'whereClause' => [ + "m.$column!=" => null, + 'sq.id:' => lcfirst($entityType) . '.id', + ], + ], + ], + ], + ], + 'IS NOT NULL' => [ + 'whereClause' => [ + 'EXISTS' => [ + 'from' => $entityType, + 'fromAlias' => 'sq', + 'select' => [Attribute::ID], + 'leftJoins' => [ + [ + $link, + 'm', + null, + ['onlyMiddle' => true] + ] + ], + 'whereClause' => [ + "m.$column!=" => null, + 'sq.id:' => lcfirst($entityType) . '.id', + ], + ], + ], + ], + ], + ]); + } +} diff --git a/application/Espo/Classes/FieldDuplicators/AttachmentMultiple.php b/application/Espo/Classes/FieldDuplicators/AttachmentMultiple.php new file mode 100644 index 0000000..578d03f --- /dev/null +++ b/application/Espo/Classes/FieldDuplicators/AttachmentMultiple.php @@ -0,0 +1,90 @@ +. + * + * 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\FieldDuplicators; + +use Espo\Core\Record\Duplicator\FieldDuplicator; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +use Espo\Repositories\Attachment as AttachmentRepository; +use Espo\Entities\Attachment; + +use stdClass; + +class AttachmentMultiple implements FieldDuplicator +{ + private $entityManager; + + public function __construct(EntityManager $entityManager) + { + $this->entityManager = $entityManager; + } + + public function duplicate(Entity $entity, string $field): stdClass + { + $valueMap = (object) []; + + /** @var \Espo\ORM\Collection $attachmentList */ + $attachmentList = $this->entityManager + ->getRDBRepository($entity->getEntityType()) + ->getRelation($entity, $field) + ->find(); + + if (is_countable($attachmentList) && !count($attachmentList)) { + return $valueMap; + } + + $idList = []; + $nameHash = (object) []; + $typeHash = (object) []; + + /** @var AttachmentRepository $attachmentRepository */ + $attachmentRepository = $this->entityManager->getRepository(Attachment::ENTITY_TYPE); + + foreach ($attachmentList as $attachment) { + $copiedAttachment = $attachmentRepository->getCopiedAttachment($attachment); + + $copiedAttachment->set('field', $field); + + $this->entityManager->saveEntity($copiedAttachment); + + $idList[] = $copiedAttachment->getId(); + + $nameHash->{$copiedAttachment->getId()} = $copiedAttachment->getName(); + $typeHash->{$copiedAttachment->getId()} = $copiedAttachment->getType(); + } + + $valueMap->{$field . 'Ids'} = $idList; + $valueMap->{$field . 'Names'} = $nameHash; + $valueMap->{$field . 'Types'} = $typeHash; + + return $valueMap; + } +} diff --git a/application/Espo/Classes/FieldDuplicators/DashboardTemplate/Layout.php b/application/Espo/Classes/FieldDuplicators/DashboardTemplate/Layout.php new file mode 100644 index 0000000..3cbf67e --- /dev/null +++ b/application/Espo/Classes/FieldDuplicators/DashboardTemplate/Layout.php @@ -0,0 +1,113 @@ +. + * + * 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\FieldDuplicators\DashboardTemplate; + +use Espo\Core\Record\Duplicator\FieldDuplicator; +use Espo\Core\Utils\ObjectUtil; +use Espo\Core\Utils\Util; +use Espo\Entities\DashboardTemplate; +use Espo\ORM\Entity; +use LogicException; +use RuntimeException; +use stdClass; + +/** + * @implements FieldDuplicator + */ +class Layout implements FieldDuplicator +{ + public function duplicate(Entity $entity, string $field): stdClass + { + $layout = $entity->getLayoutRaw(); + $options = $entity->getDashletsOptionsRaw(); + + if (!$layout) { + return (object) []; + } + + $copyLayout = []; + $idMap = []; + + foreach ($layout as $tab) { + $copyLayout[] = $this->copyTab($tab, $idMap); + } + + $copyOptions = (object) []; + + foreach (get_object_vars($options) as $id => $item) { + $copyId = $idMap[$id] ?? null; + + if (!is_string($copyId)) { + throw new LogicException(); + } + + if (!$item instanceof stdClass) { + throw new RuntimeException("Bad dashboard options."); + } + + $copyOptions->$copyId = ObjectUtil::clone($item); + } + + return (object) [ + DashboardTemplate::FIELD_LAYOUT => $copyLayout, + DashboardTemplate::FIELD_DASHLETS_OPTIONS => $copyOptions, + ]; + } + + /** + * @param array $idMap + */ + private function copyTab(stdClass $tab, array &$idMap): stdClass + { + $copy = ObjectUtil::clone($tab); + + $copy->id = Util::generateId(); + + $layout = $copy->layout ?? []; + + foreach ($layout as $item) { + if (!$item instanceof stdClass) { + throw new RuntimeException("Bad layout dashlet definition."); + } + + $id = $item->id ?? null; + + if (!is_string($id)) { + throw new RuntimeException("No string ID in layout dashlet definition."); + } + + $item->id = Util::generateId(); + + $idMap[$id] = $item->id; + } + + return $copy; + } +} diff --git a/application/Espo/Classes/FieldDuplicators/File.php b/application/Espo/Classes/FieldDuplicators/File.php new file mode 100644 index 0000000..5c4d552 --- /dev/null +++ b/application/Espo/Classes/FieldDuplicators/File.php @@ -0,0 +1,75 @@ +. + * + * 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\FieldDuplicators; + +use Espo\Core\Record\Duplicator\FieldDuplicator; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +use Espo\Repositories\Attachment as AttachmentRepository; +use Espo\Entities\Attachment; + +use stdClass; + +class File implements FieldDuplicator +{ + private $entityManager; + + public function __construct(EntityManager $entityManager) + { + $this->entityManager = $entityManager; + } + + public function duplicate(Entity $entity, string $field): stdClass + { + $valueMap = (object) []; + + /** @var Attachment|null $attachment */ + $attachment = $this->entityManager + ->getRDBRepository($entity->getEntityType()) + ->getRelation($entity, $field) + ->findOne(); + + if (!$attachment) { + return $valueMap; + } + + /** @var AttachmentRepository $attachmentRepository */ + $attachmentRepository = $this->entityManager->getRepository(Attachment::ENTITY_TYPE); + + $copiedAttachment = $attachmentRepository->getCopiedAttachment($attachment); + + $idAttribute = $field . 'Id'; + + $valueMap->$idAttribute = $copiedAttachment->getId(); + + return $valueMap; + } +} diff --git a/application/Espo/Classes/FieldDuplicators/LinkMultiple.php b/application/Espo/Classes/FieldDuplicators/LinkMultiple.php new file mode 100644 index 0000000..f61f81b --- /dev/null +++ b/application/Espo/Classes/FieldDuplicators/LinkMultiple.php @@ -0,0 +1,82 @@ +. + * + * 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\FieldDuplicators; + +use Espo\Core\Record\Duplicator\FieldDuplicator; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +use stdClass; + +class LinkMultiple implements FieldDuplicator +{ + private EntityManager $entityManager; + + public function __construct(EntityManager $entityManager) + { + $this->entityManager = $entityManager; + } + + public function duplicate(Entity $entity, string $field): stdClass + { + $valueMap = (object) []; + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()); + + if (!$entity->hasRelation($field)) { + return $valueMap; + } + + $relationDefs = $entityDefs->getRelation($field); + + if ( + !$relationDefs->hasForeignEntityType() || + !$relationDefs->hasForeignRelationName() + ) { + return $valueMap; + } + + $foreignRelationType = $this->entityManager + ->getDefs() + ->getEntity($relationDefs->getForeignEntityType()) + ->getRelation($relationDefs->getForeignRelationName()) + ->getType(); + + if ($foreignRelationType !== Entity::MANY_MANY) { + $valueMap->{$field . 'Ids'} = []; + $valueMap->{$field . 'Names'} = (object) []; + $valueMap->{$field . 'Columns'} = (object) []; + } + + return $valueMap; + } +} diff --git a/application/Espo/Classes/FieldDuplicators/Wysiwyg.php b/application/Espo/Classes/FieldDuplicators/Wysiwyg.php new file mode 100644 index 0000000..48fc254 --- /dev/null +++ b/application/Espo/Classes/FieldDuplicators/Wysiwyg.php @@ -0,0 +1,136 @@ +. + * + * 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\FieldDuplicators; + +use Espo\Core\Acl; +use Espo\Core\Record\Duplicator\FieldDuplicator; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Repositories\Attachment as AttachmentRepository; +use Espo\Entities\Attachment; + +use stdClass; + +/** + * @noinspection PhpUnused + */ +class Wysiwyg implements FieldDuplicator +{ + public function __construct( + private EntityManager $entityManager, + private Acl $acl, + private Metadata $metadata, + ) {} + + public function duplicate(Entity $entity, string $field): stdClass + { + $valueMap = (object) []; + + $contents = $entity->get($field); + + if (!$contents) { + return $valueMap; + } + + $matches = []; + + $matchResult = preg_match_all("/\?entryPoint=attachment&id=([^&=\"']+)/", $contents, $matches); + + if ( + !$matchResult || + empty($matches[1]) || + !is_array($matches[1]) + ) { + return $valueMap; + } + + $attachmentIdList = $matches[1]; + + /** @var Attachment[] $attachmentList */ + $attachmentList = []; + + foreach ($attachmentIdList as $id) { + $attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getById($id); + + if (!$attachment) { + continue; + } + + if (!$this->acl->checkEntityRead($attachment)) { + continue; + } + + if (!in_array($attachment->getType(), $this->getAllowedImageFileTypeList())) { + continue; + } + + $attachmentList[] = $attachment; + } + + if (!count($attachmentList)) { + return $valueMap; + } + + /** @var AttachmentRepository $attachmentRepository */ + $attachmentRepository = $this->entityManager->getRepository(Attachment::ENTITY_TYPE); + + foreach ($attachmentList as $attachment) { + $copiedAttachment = $attachmentRepository->getCopiedAttachment($attachment); + + $copiedAttachment->setTargetField($field); + + $copiedAttachment->setMultiple([ + 'relatedId' => null, + 'relatedType' => $entity->getEntityType(), + ]); + + $this->entityManager->saveEntity($copiedAttachment); + + $contents = str_replace( + '?entryPoint=attachment&id=' . $attachment->getId(), + '?entryPoint=attachment&id=' . $copiedAttachment->getId(), + $contents + ); + } + + $valueMap->$field = $contents; + + return $valueMap; + } + + /** + * @return string[] + */ + private function getAllowedImageFileTypeList(): array + { + return $this->metadata->get(['app', 'image', 'allowedFileTypeList']) ?? []; + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/IsBase.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/IsBase.php new file mode 100644 index 0000000..9ca81aa --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/IsBase.php @@ -0,0 +1,63 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\CurrencyRecord; + +use Espo\Core\Currency\ConfigDataProvider; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Entities\CurrencyRecord; +use Espo\ORM\Entity; +use ValueError; + +/** + * @implements Loader + */ +class IsBase implements Loader +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + ) {} + + + public function process(Entity $entity, Params $params): void + { + try { + $code = $entity->getCode(); + } catch (ValueError) { + $entity->setIsBase(false); + + return; + } + + $isBase = $code === $this->configDataProvider->getBaseCurrency(); + + $entity->setIsBase($isBase); + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php new file mode 100644 index 0000000..68e23dc --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php @@ -0,0 +1,62 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\CurrencyRecord; + +use Espo\Core\Binding\Attributes\Qualify; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\Utils\Language; +use Espo\Entities\CurrencyRecord; +use Espo\ORM\Entity; +use ValueError; + +/** + * @implements Loader + */ +class Label implements Loader +{ + public function __construct( + #[Qualify(Language::QUALIFIER_DEFAULT)] + private Language $defaultLanguage, + ) {} + + public function process(Entity $entity, Params $params): void + { + try { + $code = $entity->getCode(); + } catch (ValueError) { + return; + } + + $name = $this->defaultLanguage->translateLabel($code, 'names', 'Currency'); + + $entity->setLabel($name); + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Rate.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Rate.php new file mode 100644 index 0000000..ecb0d32 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Rate.php @@ -0,0 +1,57 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\CurrencyRecord; + +use Espo\Core\Currency\ConfigDataProvider; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Entities\CurrencyRecord; +use Espo\ORM\Entity; +use Espo\Tools\Currency\RateEntryProvider; + +/** + * @implements Loader + */ +class Rate implements Loader +{ + public function __construct( + private RateEntryProvider $rateEntryProvider, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Entity $entity, Params $params): void + { + $rate = $entity->getCode() !== $this->configDataProvider->getBaseCurrency() ? + $this->rateEntryProvider->getCurrentRateEntry($entity)?->getRate() : + '1'; + + $entity->setRate($rate); + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/RateDate.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/RateDate.php new file mode 100644 index 0000000..ac80aa5 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/RateDate.php @@ -0,0 +1,57 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\CurrencyRecord; + +use Espo\Core\Currency\ConfigDataProvider; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Entities\CurrencyRecord; +use Espo\ORM\Entity; +use Espo\Tools\Currency\RateEntryProvider; + +/** + * @implements Loader + */ +class RateDate implements Loader +{ + public function __construct( + private RateEntryProvider $rateEntryProvider, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Entity $entity, Params $params): void + { + $date = $entity->getCode() !== $this->configDataProvider->getBaseCurrency() ? + $this->rateEntryProvider->getCurrentRateEntry($entity)?->getDate() : + null; + + $entity->setRateDate($date); + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Symbol.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Symbol.php new file mode 100644 index 0000000..676b7da --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Symbol.php @@ -0,0 +1,59 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\CurrencyRecord; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\Utils\Metadata; +use Espo\Entities\CurrencyRecord; +use Espo\ORM\Entity; +use ValueError; + +/** + * @implements Loader + */ +class Symbol implements Loader +{ + public function __construct( + private Metadata $metadata, + ) {} + + public function process(Entity $entity, Params $params): void + { + try { + $code = $entity->getCode(); + } catch (ValueError) { + return; + } + $symbol = $this->metadata->get("app.currency.symbolMap.$code"); + + $entity->setSymbol($symbol); + } +} diff --git a/application/Espo/Classes/FieldProcessing/Email/AddressDataLoader.php b/application/Espo/Classes/FieldProcessing/Email/AddressDataLoader.php new file mode 100644 index 0000000..22927eb --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/Email/AddressDataLoader.php @@ -0,0 +1,57 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\Email; + +use Espo\ORM\Entity; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; +use Espo\Repositories\Email as EmailRepository; +use Espo\Entities\Email; + +/** + * @implements Loader + */ +class AddressDataLoader implements Loader +{ + public function __construct(private EntityManager $entityManager) + {} + + /** + * @param Email $entity + */ + public function process(Entity $entity, Params $params): void + { + /** @var EmailRepository $repository */ + $repository = $this->entityManager->getRepository(Email::ENTITY_TYPE); + + $repository->loadNameHash($entity); + } +} diff --git a/application/Espo/Classes/FieldProcessing/Email/AddressLoader.php b/application/Espo/Classes/FieldProcessing/Email/AddressLoader.php new file mode 100644 index 0000000..5db9e1e --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/Email/AddressLoader.php @@ -0,0 +1,60 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\Email; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; +use Espo\Entities\Email; +use Espo\ORM\Entity; +use Espo\Repositories\Email as EmailRepository; + +/** + * @implements Loader + */ +class AddressLoader implements Loader +{ + public function __construct(private EntityManager $entityManager) + {} + /** + * @inheritDoc + */ + public function process(Entity $entity, Params $params): void + { + /** @var EmailRepository $repository */ + $repository = $this->entityManager->getRepository(Email::ENTITY_TYPE); + + $repository->loadFromField($entity); + $repository->loadToField($entity); + $repository->loadCcField($entity); + $repository->loadBccField($entity); + $repository->loadReplyToField($entity); + } +} diff --git a/application/Espo/Classes/FieldProcessing/Email/FolderDataLoader.php b/application/Espo/Classes/FieldProcessing/Email/FolderDataLoader.php new file mode 100644 index 0000000..e7545a1 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/Email/FolderDataLoader.php @@ -0,0 +1,67 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\Email; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Entities\Email; +use Espo\Entities\EmailFolder; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +/** + * @implements Loader + */ +class FolderDataLoader implements Loader +{ + public function __construct(private EntityManager $entityManager) {} + + public function process(Entity $entity, Params $params): void + { + $folderId = $entity->get(Email::USERS_COLUMN_FOLDER_ID); + + if (!$folderId) { + return; + } + + $folder = $this->entityManager + ->getRDBRepositoryByClass(EmailFolder::class) + ->select([Attribute::ID, 'name']) + ->where([Attribute::ID => $folderId]) + ->findOne(); + + if (!$folder) { + return; + } + + $entity->set('folderName', $folder->getName()); + } +} diff --git a/application/Espo/Classes/FieldProcessing/Email/IcsDataLoader.php b/application/Espo/Classes/FieldProcessing/Email/IcsDataLoader.php new file mode 100644 index 0000000..b79cdcb --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/Email/IcsDataLoader.php @@ -0,0 +1,245 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\Email; + +use Espo\Core\Name\Field; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Call; +use Espo\Modules\Crm\Entities\Contact; +use Espo\Modules\Crm\Entities\Lead; +use Espo\Modules\Crm\Entities\Meeting; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\Repositories\EmailAddress as EmailAddressRepository; +use Espo\Entities\EmailAddress; +use Espo\Entities\Email; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\Mail\Event\Event as EspoEvent; +use Espo\Core\Mail\Event\EventFactory; +use Espo\Core\Utils\Log; + +use ICal\Event; +use ICal\ICal; + +use Throwable; +use stdClass; + +/** + * @implements Loader + */ +class IcsDataLoader implements Loader +{ + /** @var array */ + private $entityTypeLinkMap = [ + User::ENTITY_TYPE => Meeting::LINK_USERS, + Contact::ENTITY_TYPE => Meeting::LINK_CONTACTS, + Lead::ENTITY_TYPE => Meeting::LINK_LEADS, + ]; + + public function __construct(private EntityManager $entityManager, private Log $log) + {} + + public function process(Entity $entity, Params $params): void + { + $icsContents = $entity->get('icsContents'); + + if ($icsContents === null) { + return; + } + + $ical = new ICal(); + + $ical->initString($icsContents); + + /* @var ?Event $event */ + $event = $ical->events()[0] ?? null; + + if ($event === null) { + return; + } + + if ($event->status === 'CANCELLED') { + return; + } + + $method = $ical->cal['VCALENDAR']['METHOD'] ?? null; + + if ($method === 'REPLY') { + return; + } + + $espoEvent = EventFactory::createFromU01jmg3Ical($ical); + + $valueMap = (object) [ + 'sourceEmailId' => $entity->getId(), + ]; + + try { + $valueMap->name = $espoEvent->getName(); + $valueMap->description = $espoEvent->getDescription(); + $valueMap->dateStart = $espoEvent->getDateStart(); + $valueMap->dateEnd = $espoEvent->getDateEnd(); + $valueMap->location = $espoEvent->getLocation(); + $valueMap->isAllDay = $espoEvent->isAllDay(); + + if ($espoEvent->isAllDay()) { + $valueMap->dateStartDate = $espoEvent->getDateStart(); + $valueMap->dateEndDate = $espoEvent->getDateEnd(); + } + } catch (Throwable $e) { + $this->log->warning("Error while converting ICS event '" . $entity->getId() . "': " . $e->getMessage()); + + return; + } + + if ($this->eventAlreadyExists($espoEvent)) { + return; + } + + /** @var EmailAddressRepository $emailAddressRepository */ + $emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + $attendeeEmailAddressList = $espoEvent->getAttendeeEmailAddressList(); + $organizerEmailAddress = $espoEvent->getOrganizerEmailAddress(); + + if ($organizerEmailAddress) { + $attendeeEmailAddressList[] = $organizerEmailAddress; + } + + foreach ($attendeeEmailAddressList as $address) { + $personEntity = $emailAddressRepository->getEntityByAddress($address); + + if (!$personEntity) { + continue; + } + + $link = $this->entityTypeLinkMap[$personEntity->getEntityType()] ?? null; + + if (!$link) { + continue; + } + + $idsAttribute = $link . 'Ids'; + $namesAttribute = $link . 'Names'; + + $idList = $valueMap->$idsAttribute ?? []; + $nameMap = $valueMap->$namesAttribute ?? (object) []; + + $idList[] = $personEntity->getId(); + $nameMap->{$personEntity->getId()} = $personEntity->get(Field::NAME); + + $valueMap->$idsAttribute = $idList; + $valueMap->$namesAttribute = $nameMap; + } + + $eventData = (object) [ + 'valueMap' => $valueMap, + 'uid' => $espoEvent->getUid(), + 'createdEvent' => null, + ]; + + $this->loadCreatedEvent($entity, $espoEvent, $eventData); + + $entity->set('icsEventData', $eventData); + $entity->set('icsEventDateStart', $espoEvent->getDateStart()); + + if ($espoEvent->isAllDay()) { + $entity->set('icsEventDateStartDate', $espoEvent->getDateStart()); + } + } + + private function loadCreatedEvent(Entity $entity, EspoEvent $espoEvent, stdClass $eventData): void + { + $emailSameEvent = $this->entityManager + ->getRDBRepository(Email::ENTITY_TYPE) + ->where([ + 'icsEventUid' => $espoEvent->getUid(), + 'id!=' => $entity->getId() + ]) + ->findOne(); + + if (!$emailSameEvent) { + return; + } + + if ( + !$emailSameEvent->get('createdEventId') || + !$emailSameEvent->get('createdEventType') + ) { + return; + } + + $createdEvent = $this->entityManager + ->getEntityById($emailSameEvent->get('createdEventType'), $emailSameEvent->get('createdEventId')); + + if (!$createdEvent) { + return; + } + + $eventData->createdEvent = (object) [ + 'id' => $createdEvent->getId(), + 'entityType' => $emailSameEvent->getEntityType(), + 'name' => $createdEvent->get(Field::NAME), + ]; + } + + private function eventAlreadyExists(EspoEvent $espoEvent): bool + { + $id = $espoEvent->getUid(); + + if (!$id) { + return false; + } + + $found1 = $this->entityManager + ->getRDBRepository(Meeting::ENTITY_TYPE) + ->select([Attribute::ID]) + ->where([Attribute::ID => $id]) + ->findOne(); + + if ($found1) { + return true; + } + + $found2 = $this->entityManager + ->getRDBRepository(Call::ENTITY_TYPE) + ->select([Attribute::ID]) + ->where([Attribute::ID => $id]) + ->findOne(); + + if ($found2) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/FieldProcessing/Email/StringDataLoader.php b/application/Espo/Classes/FieldProcessing/Email/StringDataLoader.php new file mode 100644 index 0000000..b15e148 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/Email/StringDataLoader.php @@ -0,0 +1,128 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\Email; + +use Espo\Core\Name\Field; +use Espo\Core\Name\Link; +use Espo\ORM\Entity; +use Espo\ORM\Name\Attribute; +use Espo\Repositories\EmailAddress as EmailAddressRepository; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; +use Espo\Entities\Email; +use Espo\Entities\User; + +/** + * @implements Loader + */ +class StringDataLoader implements Loader +{ + private const LINK_EMAIL_ADDRESSES = Link::EMAIL_ADDRESSES; + + /** @var array */ + private $fromEmailAddressNameCache = []; + + public function __construct( + private EntityManager $entityManager, + private User $user + ) {} + + public function process(Entity $entity, Params $params): void + { + /** @var Email $entity */ + + $userEmailAddressIdList = []; + + $emailAddressCollection = $this->entityManager + ->getRelation($this->user, self::LINK_EMAIL_ADDRESSES) + ->select([Attribute::ID]) + ->find(); + + foreach ($emailAddressCollection as $emailAddress) { + $userEmailAddressIdList[] = $emailAddress->getId(); + } + + if ( + in_array($entity->get('fromEmailAddressId'), $userEmailAddressIdList) || + $entity->get('createdById') === $this->user->getId() && + $entity->getStatus() === Email::STATUS_SENT + ) { + $entity->loadLinkMultipleField('toEmailAddresses'); + + $idList = $entity->get('toEmailAddressesIds'); + $names = $entity->get('toEmailAddressesNames'); + + if (empty($idList)) { + return; + } + + $list = []; + + foreach ($idList as $emailAddressId) { + $person = $this->getEmailAddressRepository()->getEntityByAddressId($emailAddressId, null, true); + + $list[] = $person ? $person->get(Field::NAME) : $names->$emailAddressId; + } + + $entity->set('personStringData', 'To: ' . implode(', ', $list)); + + return; + } + + /** @var ?string $fromEmailAddressId */ + $fromEmailAddressId = $entity->get('fromEmailAddressId'); + + if (!$fromEmailAddressId) { + return; + } + + if (!array_key_exists($fromEmailAddressId, $this->fromEmailAddressNameCache)) { + $person = $this->getEmailAddressRepository()->getEntityByAddressId($fromEmailAddressId, null, true); + + $fromName = $person?->get(Field::NAME); + + $this->fromEmailAddressNameCache[$fromEmailAddressId] = $fromName; + } + + $fromName = + $this->fromEmailAddressNameCache[$fromEmailAddressId] ?? + $entity->get('fromName') ?? + $entity->get('fromEmailAddressName'); + + $entity->set('personStringData', $fromName); + } + + private function getEmailAddressRepository(): EmailAddressRepository + { + /** @var EmailAddressRepository */ + return $this->entityManager->getRepository('EmailAddress'); + } +} diff --git a/application/Espo/Classes/FieldProcessing/Email/UserColumnsLoader.php b/application/Espo/Classes/FieldProcessing/Email/UserColumnsLoader.php new file mode 100644 index 0000000..4ac933e --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/Email/UserColumnsLoader.php @@ -0,0 +1,90 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\Email; + +use Espo\Entities\Email; +use Espo\ORM\Entity; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; + +/** + * @implements Loader + */ +class UserColumnsLoader implements Loader +{ + public function __construct( + private EntityManager $entityManager, + private User $user + ) {} + + public function process(Entity $entity, Params $params): void + { + $emailUser = $this->entityManager + ->getRDBRepository(Email::RELATIONSHIP_EMAIL_USER) + ->select([ + Email::USERS_COLUMN_IS_READ, + Email::USERS_COLUMN_IS_IMPORTANT, + Email::USERS_COLUMN_IN_TRASH, + Email::USERS_COLUMN_IN_ARCHIVE, + ]) + ->where([ + Attribute::DELETED => false, + 'userId' => $this->user->getId(), + 'emailId' => $entity->getId(), + ]) + ->findOne(); + + if (!$emailUser) { + $entity->set(Email::USERS_COLUMN_IS_READ, null); + $entity->clear(Email::USERS_COLUMN_IS_IMPORTANT); + $entity->clear(Email::USERS_COLUMN_IN_TRASH); + $entity->clear(Email::USERS_COLUMN_IN_ARCHIVE); + + return; + } + + $values = [ + Email::USERS_COLUMN_IS_READ => $emailUser->get(Email::USERS_COLUMN_IS_READ), + Email::USERS_COLUMN_IS_IMPORTANT => $emailUser->get(Email::USERS_COLUMN_IS_IMPORTANT), + Email::USERS_COLUMN_IN_TRASH => $emailUser->get(Email::USERS_COLUMN_IN_TRASH), + Email::USERS_COLUMN_IN_ARCHIVE => $emailUser->get(Email::USERS_COLUMN_IN_ARCHIVE), + 'isUsersSent' => $entity->getSentBy()?->getId() === $this->user->getId(), + ]; + + $entity->setMultiple($values); + + foreach ($values as $key => $value) { + $entity->setFetched($key, $value); + } + } +} diff --git a/application/Espo/Classes/FieldProcessing/Import/CountsLoader.php b/application/Espo/Classes/FieldProcessing/Import/CountsLoader.php new file mode 100644 index 0000000..dec673a --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/Import/CountsLoader.php @@ -0,0 +1,63 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\Import; + +use Espo\Entities\Import; +use Espo\ORM\Entity; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; + +use Espo\Repositories\Import as ImportRepository; + +/** + * @implements Loader + */ +class CountsLoader implements Loader +{ + public function __construct(private EntityManager $entityManager) + {} + + public function process(Entity $entity, Params $params): void + { + /** @var ImportRepository $repository */ + $repository = $this->entityManager->getRepository('Import'); + + $importedCount = $repository->countResultRecords($entity, 'imported'); + $duplicateCount = $repository->countResultRecords($entity, 'duplicates'); + $updatedCount = $repository->countResultRecords($entity, 'updated'); + + $entity->set([ + 'importedCount' => $importedCount, + 'duplicateCount' => $duplicateCount, + 'updatedCount' => $updatedCount, + ]); + } +} diff --git a/application/Espo/Classes/FieldProcessing/InboundEmail/IsSystemLoader.php b/application/Espo/Classes/FieldProcessing/InboundEmail/IsSystemLoader.php new file mode 100644 index 0000000..791807d --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/InboundEmail/IsSystemLoader.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\InboundEmail; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\Mail\ConfigDataProvider; +use Espo\Entities\InboundEmail; +use Espo\ORM\Entity; + +/** + * @implements Loader + */ +class IsSystemLoader implements Loader +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Entity $entity, Params $params): void + { + $isSystem = $entity->getEmailAddress() === $this->configDataProvider->getSystemOutboundAddress(); + + $entity->set('isSystem', $isSystem); + } +} diff --git a/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php b/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php new file mode 100644 index 0000000..4141a93 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php @@ -0,0 +1,158 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\LeadCapture; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\ApplicationConfig; +use Espo\Core\Utils\FieldUtil; +use Espo\Core\Utils\Util; +use Espo\Entities\LeadCapture; +use Espo\Modules\Crm\Entities\Lead; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Type\AttributeType; + +/** + * @implements Loader + */ +class ExampleLoader implements Loader +{ + public function __construct( + private FieldUtil $fieldUtil, + private ApplicationConfig $applicationConfig, + private EntityManager $entityManager, + private Config $config, + ) {} + + public function process(Entity $entity, Params $params): void + { + $entity->set('exampleRequestMethod', 'POST'); + + $entity->set('exampleRequestHeaders', [ + 'Content-Type: application/json', + ]); + + $this->processRequestUrl($entity); + $this->processRequestPayload($entity); + $this->processFormUrl($entity); + } + + private function processRequestUrl(LeadCapture $entity): void + { + $apiKey = $entity->getApiKey(); + $siteUrl = $this->applicationConfig->getSiteUrl(); + + if (!$apiKey) { + return; + } + + $requestUrl = "$siteUrl/api/v1/LeadCapture/$apiKey"; + + $entity->set('exampleRequestUrl', $requestUrl); + } + + private function processRequestPayload(LeadCapture $entity): void + { + $requestPayload = "```\n{\n"; + + $attributeList = []; + + $attributeIgnoreList = [ + 'emailAddressIsOptedOut', + 'phoneNumberIsOptedOut', + 'emailAddressIsInvalid', + 'phoneNumberIsInvalid', + 'emailAddressData', + 'phoneNumberData', + ]; + + foreach ($entity->getFieldList() as $field) { + foreach ($this->fieldUtil->getActualAttributeList(Lead::ENTITY_TYPE, $field) as $attribute) { + if (!in_array($attribute, $attributeIgnoreList)) { + $attributeList[] = $attribute; + } + } + } + + $seed = $this->entityManager->getNewEntity(Lead::ENTITY_TYPE); + + foreach ($attributeList as $i => $attribute) { + $value = strtoupper(Util::camelCaseToUnderscore($attribute)); + + if ( + in_array( + $seed->getAttributeType($attribute), [ + Entity::VARCHAR, + Entity::TEXT, + AttributeType::DATETIME, + AttributeType::DATE, + ] + ) + ) { + $value = '"' . $value . '"'; + } + + $requestPayload .= " \"" . $attribute . "\": " . $value; + + if ($i < count($attributeList) - 1) { + $requestPayload .= ","; + } + + $requestPayload .= "\n"; + } + + $requestPayload .= "}\n```"; + + $entity->set('exampleRequestPayload', $requestPayload); + } + + private function processFormUrl(LeadCapture $entity): void + { + $formId = $entity->getFormId(); + $siteUrl = $this->getSiteUrl(); + + if (!$entity->hasFormEnabled() || !$formId) { + $entity->set('formUrl', null); + + return; + } + + $formUrl = "$siteUrl?entryPoint=leadCaptureForm&id=$formId"; + + $entity->set('formUrl', $formUrl); + } + + private function getSiteUrl(): string + { + return $this->config->get('leadCaptureSiteUrl') ?? $this->applicationConfig->getSiteUrl(); + } +} diff --git a/application/Espo/Classes/FieldProcessing/Note/AdditionalFieldsLoader.php b/application/Espo/Classes/FieldProcessing/Note/AdditionalFieldsLoader.php new file mode 100644 index 0000000..56f49c2 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/Note/AdditionalFieldsLoader.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\Note; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Entities\Note; +use Espo\ORM\Entity; +use Espo\Tools\Stream\MassNotePreparator; + +/** + * @implements Loader + */ +class AdditionalFieldsLoader implements Loader +{ + public function __construct( + private MassNotePreparator $massNotePreparator, + ) {} + + public function process(Entity $entity, Params $params): void + { + $entity->loadAdditionalFields(); + + $this->massNotePreparator->prepare([$entity]); + } +} diff --git a/application/Espo/Classes/FieldProcessing/OAuthProvider/AuthorizationRedirectUriLoader.php b/application/Espo/Classes/FieldProcessing/OAuthProvider/AuthorizationRedirectUriLoader.php new file mode 100644 index 0000000..d9ac7c3 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/OAuthProvider/AuthorizationRedirectUriLoader.php @@ -0,0 +1,51 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\OAuthProvider; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Entities\OAuthProvider; +use Espo\ORM\Entity; +use Espo\Tools\OAuth\ConfigDataProvider; + +/** + * @implements Loader + */ +class AuthorizationRedirectUriLoader implements Loader +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Entity $entity, Params $params): void + { + $entity->set('authorizationRedirectUri', $this->configDataProvider->getRedirectUri()); + } +} diff --git a/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php b/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php new file mode 100644 index 0000000..004d29a --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php @@ -0,0 +1,64 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\Portal; + +use Espo\ORM\Entity; + +use Espo\Repositories\Portal as PortalRepository; +use Espo\Entities\Portal; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; + +/** + * @implements Loader + */ +class UrlLoader implements Loader +{ + private EntityManager $entityManager; + + public function __construct(EntityManager $entityManager) + { + $this->entityManager = $entityManager; + } + + public function process(Entity $entity, Params $params): void + { + /** @var Portal $entity */ + + $this->getPortalRepository()->loadUrlField($entity); + } + + private function getPortalRepository(): PortalRepository + { + /** @var PortalRepository */ + return $this->entityManager->getRepository('Portal'); + } +} diff --git a/application/Espo/Classes/FieldProcessing/User/LastAccessLoader.php b/application/Espo/Classes/FieldProcessing/User/LastAccessLoader.php new file mode 100644 index 0000000..a081721 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/User/LastAccessLoader.php @@ -0,0 +1,112 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\User; + +use Espo\Core\Name\Field; +use Espo\Entities\AuthLogRecord; +use Espo\Entities\AuthToken; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl; +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; + +use DateTime; +use Espo\ORM\Name\Attribute; +use Exception; + +/** + * @implements Loader + * @noinspection PhpUnused + */ +class LastAccessLoader implements Loader +{ + private EntityManager $entityManager; + private Acl $acl; + + public function __construct(EntityManager $entityManager, Acl $acl) + { + $this->entityManager = $entityManager; + $this->acl = $acl; + } + + public function process(Entity $entity, Params $params): void + { + if (!$this->acl->checkField($entity->getEntityType(), 'lastAccess')) { + return; + } + + $authToken = $this->entityManager + ->getRDBRepository(AuthToken::ENTITY_TYPE) + ->select([Attribute::ID, 'lastAccess']) + ->where([ + 'userId' => $entity->getId(), + ]) + ->order('lastAccess', 'DESC') + ->findOne(); + + $lastAccess = null; + + if ($authToken) { + $lastAccess = $authToken->get('lastAccess'); + } + + $dt = null; + + if ($lastAccess) { + try { + $dt = new DateTime($lastAccess); + } catch (Exception) {} + } + + $where = [ + 'userId' => $entity->getId(), + 'isDenied' => false, + ]; + + if ($dt) { + $where['requestTime>'] = $dt->format('U'); + } + + $authLogRecord = $this->entityManager + ->getRDBRepository(AuthLogRecord::ENTITY_TYPE) + ->select([Attribute::ID, Field::CREATED_AT]) + ->where($where) + ->order('requestTime', true) + ->findOne(); + + if ($authLogRecord) { + $lastAccess = $authLogRecord->get(Field::CREATED_AT); + } + + $entity->set('lastAccess', $lastAccess); + } +} diff --git a/application/Espo/Classes/FieldProcessing/WorkingTimeCalendar/IsDefaultLoader.php b/application/Espo/Classes/FieldProcessing/WorkingTimeCalendar/IsDefaultLoader.php new file mode 100644 index 0000000..0d1b11e --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/WorkingTimeCalendar/IsDefaultLoader.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldProcessing\WorkingTimeCalendar; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\Utils\Config; +use Espo\Entities\WorkingTimeCalendar; +use Espo\ORM\Entity; + +/** + * @implements Loader + */ +class IsDefaultLoader implements Loader +{ + public function __construct( + private Config $config, + ) {} + + public function process(Entity $entity, Params $params): void + { + $isDefault = $entity->getId() === $this->config->get('workingTimeCalendarId'); + + $entity->set('isDefault', $isDefault); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/ArrayFromNull.php b/application/Espo/Classes/FieldSanitizers/ArrayFromNull.php new file mode 100644 index 0000000..065ac3a --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/ArrayFromNull.php @@ -0,0 +1,54 @@ +. + * + * 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\FieldSanitizers; + +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; + +/** + * @noinspection PhpUnused + */ +class ArrayFromNull implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + if (!$data->has($field)) { + return; + } + + $value = $data->get($field); + + if ($value !== null) { + return; + } + + $data->set($field, []); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/ArrayStringTrim.php b/application/Espo/Classes/FieldSanitizers/ArrayStringTrim.php new file mode 100644 index 0000000..b933f68 --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/ArrayStringTrim.php @@ -0,0 +1,72 @@ +. + * + * 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\FieldSanitizers; + +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; + +/** + * @noinspection PhpUnused + */ +class ArrayStringTrim implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + if (!$data->has($field)) { + return; + } + + $value = $data->get($field); + + if (!is_array($value)) { + return; + } + + foreach ($value as $i => $item) { + if (!is_string($item)) { + continue; + } + + $value[$i] = self::trim($item); + } + + $data->set($field, $value); + } + + private static function trim(string $value): string + { + // @todo Remove check when PHP 8.4 is min supported. + if (function_exists('mb_trim')) { + return mb_trim($value); + } + + return trim($value); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/Date.php b/application/Espo/Classes/FieldSanitizers/Date.php new file mode 100644 index 0000000..53e7591 --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/Date.php @@ -0,0 +1,69 @@ +. + * + * 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\FieldSanitizers; + +use DateTimeImmutable; +use DateTimeInterface; +use Espo\Core\Field\Date as DateValue; +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Exception; + +/** + * @noinspection PhpUnused + */ +class Date implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + $value = $data->get($field); + + if ($value === null) { + return; + } + + try { + DateValue::fromString($value); + + return; + } catch (Exception) {} + + $dateTime = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $value); + + if ($dateTime === false) { + return; + } + + $value = $dateTime->format(DateTimeUtil::SYSTEM_DATE_FORMAT); + + $data->set($field, $value); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/Datetime.php b/application/Espo/Classes/FieldSanitizers/Datetime.php new file mode 100644 index 0000000..cfe8136 --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/Datetime.php @@ -0,0 +1,72 @@ +. + * + * 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\FieldSanitizers; + +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Espo\Core\Field\DateTime as DateTimeValue; +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Exception; + +/** + * @noinspection PhpUnused + */ +class Datetime implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + $value = $data->get($field); + + if ($value === null) { + return; + } + + try { + DateTimeValue::fromString($value); + + return; + } catch (Exception) {} + + $dateTime = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $value); + + if ($dateTime === false) { + return; + } + + $value = $dateTime + ->setTimezone(new DateTimeZone('UTC')) + ->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + + $data->set($field, $value); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/DatetimeOptionalDate.php b/application/Espo/Classes/FieldSanitizers/DatetimeOptionalDate.php new file mode 100644 index 0000000..ca2bded --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/DatetimeOptionalDate.php @@ -0,0 +1,71 @@ +. + * + * 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\FieldSanitizers; + +use DateTimeImmutable; +use DateTimeInterface; +use Espo\Core\Field\Date; +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Exception; + +/** + * @noinspection PhpUnused + */ +class DatetimeOptionalDate implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + $attribute = $field . 'Date'; + + $value = $data->get($attribute); + + if ($value === null) { + return; + } + + try { + Date::fromString($value); + + return; + } catch (Exception) {} + + $dateTime = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $value); + + if ($dateTime === false) { + return; + } + + $value = $dateTime->format(DateTimeUtil::SYSTEM_DATE_FORMAT); + + $data->set($attribute, $value); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/Decimal.php b/application/Espo/Classes/FieldSanitizers/Decimal.php new file mode 100644 index 0000000..fb355d4 --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/Decimal.php @@ -0,0 +1,55 @@ +. + * + * 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\FieldSanitizers; + +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; + +class Decimal implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + if (!$data->has($field)) { + return; + } + + $value = $data->get($field); + + if (is_string($value) || $value === null) { + return; + } + + if (is_float($value) || is_int($value)) { + $value = (string) $value; + } + + $data->set($field, $value); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/EmptyStringToNull.php b/application/Espo/Classes/FieldSanitizers/EmptyStringToNull.php new file mode 100644 index 0000000..0cf5172 --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/EmptyStringToNull.php @@ -0,0 +1,58 @@ +. + * + * 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\FieldSanitizers; + +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; + +/** + * @noinspection PhpUnused + */ +class EmptyStringToNull implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + if (!$data->has($field)) { + return; + } + + $value = $data->get($field); + + if (!is_string($value)) { + return; + } + + if ($value === '') { + $value = null; + } + + $data->set($field, $value); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/Phone.php b/application/Espo/Classes/FieldSanitizers/Phone.php new file mode 100644 index 0000000..c7d04c9 --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/Phone.php @@ -0,0 +1,77 @@ +. + * + * 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\FieldSanitizers; + +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; +use Espo\Core\PhoneNumber\Sanitizer as PhoneNumberSanitizer; +use stdClass; + +class Phone implements Sanitizer +{ + public function __construct( + private PhoneNumberSanitizer $phoneNumberSanitizer + ) {} + + public function sanitize(Data $data, string $field): void + { + $number = $data->get($field); + + if ($number !== null) { + $number = $this->phoneNumberSanitizer->sanitize($number); + + $data->set($field, $number); + } + + $items = $data->get($field . 'Data'); + + if (!is_array($items)) { + return; + } + + foreach ($items as $item) { + if (!$item instanceof stdClass) { + continue; + } + + $number = $item->phoneNumber ?? null; + + if (!is_scalar($number)) { + continue; + } + + $number = (string) $number; + + $item->phoneNumber = $this->phoneNumberSanitizer->sanitize($number); + } + + $data->set($field . 'Data', $items); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/StringLowerCase.php b/application/Espo/Classes/FieldSanitizers/StringLowerCase.php new file mode 100644 index 0000000..6d1191d --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/StringLowerCase.php @@ -0,0 +1,56 @@ +. + * + * 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\FieldSanitizers; + +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; + +/** + * @noinspection PhpUnused + */ +class StringLowerCase implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + if (!$data->has($field)) { + return; + } + + $value = $data->get($field); + + if (!is_string($value)) { + return; + } + + $value = mb_strtolower($value); + + $data->set($field, $value); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/StringTrim.php b/application/Espo/Classes/FieldSanitizers/StringTrim.php new file mode 100644 index 0000000..c9b126e --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/StringTrim.php @@ -0,0 +1,70 @@ +. + * + * 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\FieldSanitizers; + +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; + +/** + * @noinspection PhpUnused + */ +class StringTrim implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + if (!$data->has($field)) { + return; + } + + $value = $data->get($field); + + if (!is_string($value)) { + return; + } + + $value = self::trim($value); + + if ($value === '') { + $value = null; + } + + $data->set($field, $value); + } + + private static function trim(string $value): string + { + // @todo Remove check when PHP 8.4 is min supported. + if (function_exists('mb_trim')) { + return mb_trim($value); + } + + return trim($value); + } +} diff --git a/application/Espo/Classes/FieldSanitizers/StringUpperCase.php b/application/Espo/Classes/FieldSanitizers/StringUpperCase.php new file mode 100644 index 0000000..b8b9f28 --- /dev/null +++ b/application/Espo/Classes/FieldSanitizers/StringUpperCase.php @@ -0,0 +1,56 @@ +. + * + * 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\FieldSanitizers; + +use Espo\Core\FieldSanitize\Sanitizer; +use Espo\Core\FieldSanitize\Sanitizer\Data; + +/** + * @noinspection PhpUnused + */ +class StringUpperCase implements Sanitizer +{ + public function sanitize(Data $data, string $field): void + { + if (!$data->has($field)) { + return; + } + + $value = $data->get($field); + + if (!is_string($value)) { + return; + } + + $value = mb_strtoupper($value); + + $data->set($field, $value); + } +} diff --git a/application/Espo/Classes/FieldValidators/ArrayIntType.php b/application/Espo/Classes/FieldValidators/ArrayIntType.php new file mode 100644 index 0000000..6a671bb --- /dev/null +++ b/application/Espo/Classes/FieldValidators/ArrayIntType.php @@ -0,0 +1,35 @@ +. + * + * 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; + +class ArrayIntType extends ArrayType +{ + +} diff --git a/application/Espo/Classes/FieldValidators/ArrayType.php b/application/Espo/Classes/FieldValidators/ArrayType.php new file mode 100644 index 0000000..20734aa --- /dev/null +++ b/application/Espo/Classes/FieldValidators/ArrayType.php @@ -0,0 +1,253 @@ +. + * + * 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; + +use Espo\Core\Utils\Metadata; + +use Espo\ORM\Defs; +use Espo\ORM\Entity; + +use stdClass; + +class ArrayType +{ + private const DEFAULT_MAX_ITEM_LENGTH = 100; + + public function __construct(protected Metadata $metadata, private Defs $defs) + {} + + public function checkRequired(Entity $entity, string $field): bool + { + return $this->isNotEmpty($entity, $field); + } + + public function checkMaxCount(Entity $entity, string $field, int $validationValue): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + $list = $entity->get($field); + + if (count($list) > $validationValue) { + return false; + } + + return true; + } + + public function checkArrayOfString(Entity $entity, string $field): bool + { + /** @var ?mixed[] $list */ + $list = $entity->get($field); + + if ($list === null) { + return true; + } + + foreach ($list as $item) { + if (!is_string($item)) { + return false; + } + } + + return true; + } + + public function checkValid(Entity $entity, string $field): bool + { + if (!$entity->has($field)) { + return true; + } + + /** @var ?string[] $value */ + $value = $entity->get($field); + + if ($value === null || $value === []) { + return true; + } + + $fieldDefs = $this->defs + ->getEntity($entity->getEntityType()) + ->getField($field); + + if ($fieldDefs->getParam('allowCustomOptions')) { + return true; + } + + $optionList = $this->getOptionList($entity->getEntityType(), $field); + + if ($optionList === null) { + return true; + } + + foreach ($value as $item) { + if (!in_array($item, $optionList)) { + return false; + } + } + + return true; + } + + /** + * @return ?string[] + */ + private function getOptionList(string $entityType, string $field): ?array + { + $fieldDefs = $this->defs + ->getEntity($entityType) + ->getField($field); + + /** @var ?string $path */ + $path = $fieldDefs->getParam('optionsPath'); + /** @var ?string $path */ + $ref = $fieldDefs->getParam('optionsReference'); + + if (!$path && $ref && str_contains($ref, '.')) { + [$refEntityType, $refField] = explode('.', $ref); + + $path = "entityDefs.{$refEntityType}.fields.{$refField}.options"; + } + + /** @var string[]|null|false $optionList */ + $optionList = $path ? + $this->metadata->get($path) : + $fieldDefs->getParam('options'); + + if ($optionList === null) { + return null; + } + + // For bc. + if ($optionList === false) { + return null; + } + + return $optionList; + } + + public function rawCheckArray(stdClass $data, string $field): bool + { + if (isset($data->$field) && !is_array($data->$field)) { + return false; + } + + return true; + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + if (!$entity->has($field) || $entity->get($field) === null) { + return false; + } + + $list = $entity->get($field); + + if (!is_array($list)) { + return false; + } + + if (count($list)) { + return true; + } + + return false; + } + + public function checkMaxItemLength(Entity $entity, string $field, ?int $validationValue): bool + { + $maxLength = $validationValue ?? self::DEFAULT_MAX_ITEM_LENGTH; + + /** @var mixed[] $value */ + $value = $entity->get($field) ?? []; + + foreach ($value as $item) { + if (is_string($item) && mb_strlen($item) > $maxLength) { + return false; + } + } + + return true; + } + + public function checkPattern(Entity $entity, string $field, ?string $validationValue): bool + { + if (!$validationValue) { + return true; + } + + $pattern = $validationValue; + + if ($validationValue[0] === '$') { + $patternName = substr($validationValue, 1); + + $pattern = $this->metadata->get(['app', 'regExpPatterns', $patternName, 'pattern']) ?? + $pattern; + } + + $preparedPattern = '/^' . $pattern . '$/'; + + /** @var string[] $value */ + $value = $entity->get($field) ?? []; + + foreach ($value as $item) { + if ($item === '') { + continue; + } + + if (!preg_match($preparedPattern, $item)) { + return false; + } + } + + return true; + } + + public function checkNoEmptyString(Entity $entity, string $field, ?bool $validationValue): bool + { + if (!$validationValue) { + return true; + } + + /** @var string[] $value */ + $value = $entity->get($field) ?? []; + + $optionList = $this->getOptionList($entity->getEntityType(), $field) ?? []; + + foreach ($value as $item) { + if ($item === '' && !in_array($item, $optionList)) { + return false; + } + } + + return true; + } +} diff --git a/application/Espo/Classes/FieldValidators/Attachment/Related.php b/application/Espo/Classes/FieldValidators/Attachment/Related.php new file mode 100644 index 0000000..c884b85 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Attachment/Related.php @@ -0,0 +1,47 @@ +. + * + * 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\Attachment; + +use Espo\Classes\FieldValidators\LinkParentType; +use Espo\ORM\Entity; + +class Related extends LinkParentType +{ + public function checkValid(Entity $entity, string $field): bool + { + $typeValue = $entity->get($field . 'Type'); + + if ($typeValue === 'TemplateManager') { + return true; + } + + return parent::checkValid($entity, $field); + } +} diff --git a/application/Espo/Classes/FieldValidators/AuthenticationProvider/MethodValid.php b/application/Espo/Classes/FieldValidators/AuthenticationProvider/MethodValid.php new file mode 100644 index 0000000..dc4c189 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/AuthenticationProvider/MethodValid.php @@ -0,0 +1,62 @@ +. + * + * 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\AuthenticationProvider; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Metadata; +use Espo\Entities\AuthenticationProvider; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class MethodValid implements Validator +{ + public function __construct(private Metadata $metadata) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->get($field); + + if (!$value) { + return Failure::create(); + } + + $isAvailable = $this->metadata->get(['authenticationMethods', $value, 'provider', 'isAvailable']); + + if (!$isAvailable) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/ChecklistType.php b/application/Espo/Classes/FieldValidators/ChecklistType.php new file mode 100644 index 0000000..c361cc5 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/ChecklistType.php @@ -0,0 +1,32 @@ +. + * + * 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; + +class ChecklistType extends ArrayType {} diff --git a/application/Espo/Classes/FieldValidators/Common/Host/NotInternal.php b/application/Espo/Classes/FieldValidators/Common/Host/NotInternal.php new file mode 100644 index 0000000..93785fe --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Common/Host/NotInternal.php @@ -0,0 +1,62 @@ +. + * + * 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\Host; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Security\HostCheck; +use Espo\ORM\Entity; + +/** + * @implements Validator + * @since 9.3.2 + */ +class NotInternal implements Validator +{ + public function __construct( + private HostCheck $hostCheck, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->get($field); + + if (!$value) { + return null; + } + + if (!$this->hostCheck->isHostAndNotInternal($value)) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Common/Link/CascadingSelect.php b/application/Espo/Classes/FieldValidators/Common/Link/CascadingSelect.php new file mode 100644 index 0000000..76597ef --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Common/Link/CascadingSelect.php @@ -0,0 +1,98 @@ +. + * + * 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 + */ +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; + } +} diff --git a/application/Espo/Classes/FieldValidators/Common/LinkMultiple/CascadingSelect.php b/application/Espo/Classes/FieldValidators/Common/LinkMultiple/CascadingSelect.php new file mode 100644 index 0000000..58843d9 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Common/LinkMultiple/CascadingSelect.php @@ -0,0 +1,110 @@ +. + * + * 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 + */ +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; + } +} diff --git a/application/Espo/Classes/FieldValidators/Common/PipelineStage/Valid.php b/application/Espo/Classes/FieldValidators/Common/PipelineStage/Valid.php new file mode 100644 index 0000000..db246b5 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Common/PipelineStage/Valid.php @@ -0,0 +1,70 @@ +. + * + * 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\PipelineStage; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Name\Field; +use Espo\Entities\PipelineStage; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $pipelineId = $entity->get(Field::PIPELINE . 'Id'); + $stageId = $entity->get(Field::PIPELINE_STAGE . 'Id'); + + if (!$pipelineId && $stageId) { + return Failure::create(); + } + + $stage = $this->entityManager->getRDBRepositoryByClass(PipelineStage::class)->getById($stageId); + + if (!$stage) { + return Failure::create(); + } + + if ($stage->getPipeline()->getId() !== $pipelineId) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/CurrencyRecordRate/Record/NonBase.php b/application/Espo/Classes/FieldValidators/CurrencyRecordRate/Record/NonBase.php new file mode 100644 index 0000000..6530295 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/CurrencyRecordRate/Record/NonBase.php @@ -0,0 +1,56 @@ +. + * + * 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\CurrencyRecordRate\Record; + +use Espo\Core\Currency\ConfigDataProvider; +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\CurrencyRecordRate; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class NonBase implements Validator +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if ($entity->getRecord()->getCode() === $this->configDataProvider->getBaseCurrency()) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/CurrencyType.php b/application/Espo/Classes/FieldValidators/CurrencyType.php new file mode 100644 index 0000000..fd8396e --- /dev/null +++ b/application/Espo/Classes/FieldValidators/CurrencyType.php @@ -0,0 +1,139 @@ +. + * + * 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; + +use Espo\Core\Field\Currency; +use Espo\Core\Utils\Config; +use Espo\ORM\BaseEntity; +use Espo\ORM\Defs\Params\AttributeParam; +use Espo\ORM\Entity; + +class CurrencyType extends FloatType +{ + private const DEFAULT_PRECISION = 13; + + public function __construct(private Config $config) {} + + protected function isNotEmpty(Entity $entity, string $field): bool + { + return + $entity->has($field) && $entity->get($field) !== null && + $entity->has($field . 'Currency') && $entity->get($field . 'Currency') !== null && + $entity->get($field . 'Currency') !== ''; + } + + public function checkValid(Entity $entity, string $field): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + if ($entity->getAttributeType($field) !== Entity::VARCHAR) { + return true; + } + + /** @var string $value */ + $value = $entity->get($field); + + if (preg_match('/^-?[0-9]+\.?[0-9]*$/', $value)) { + return true; + } + + return false; + } + + public function checkInPermittedRange(Entity $entity, string $field): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + if ($entity->getAttributeType($field) !== Entity::VARCHAR) { + return true; + } + + if (!$entity instanceof BaseEntity) { + return true; + } + + /** @var int $precision */ + $precision = $entity->getAttributeParam($field, AttributeParam::PRECISION) ?? self::DEFAULT_PRECISION; + + $value = $entity->get($field); + + $currency = Currency::create($value, 'USD'); + + if ($currency->isNegative()) { + $currency = $currency->multiply(-1); + } + + $pad = str_pad('', $precision, '9'); + + assert(is_numeric($pad)); + + $limit = Currency::create($pad, 'USD'); + + if ($currency->compare($limit) === 1) { + return false; + } + + return true; + } + + public function checkValidCurrency(Entity $entity, string $field): bool + { + $attribute = $field . 'Currency'; + + if (!$entity->has($attribute)) { + return true; + } + + $currency = $entity->get($attribute); + $currencyList = $this->config->get('currencyList') ?? [$this->config->get('defaultCurrency')]; + + if ( + $currency === null && + !$entity->has($field) && + $entity->isNew() + ) { + return true; + } + + if ( + $currency === null && + $entity->has($field) && + $entity->get($field) === null + ) { + return true; + } + + return in_array($currency, $currencyList); + } +} diff --git a/application/Espo/Classes/FieldValidators/DateType.php b/application/Espo/Classes/FieldValidators/DateType.php new file mode 100644 index 0000000..cfdf173 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/DateType.php @@ -0,0 +1,66 @@ +. + * + * 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; + +use Espo\Core\Field\Date; +use Espo\ORM\Entity; + +use Exception; + +class DateType +{ + public function checkRequired(Entity $entity, string $field): bool + { + return $this->isNotEmpty($entity, $field); + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + return $entity->has($field) && $entity->get($field) !== null; + } + + public function checkValid(Entity $entity, string $field): bool + { + /** @var ?string $value */ + $value = $entity->get($field); + + if ($value === null) { + return true; + } + + try { + Date::fromString($value); + } catch (Exception $e) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Classes/FieldValidators/DatetimeOptionalType.php b/application/Espo/Classes/FieldValidators/DatetimeOptionalType.php new file mode 100644 index 0000000..019f42e --- /dev/null +++ b/application/Espo/Classes/FieldValidators/DatetimeOptionalType.php @@ -0,0 +1,83 @@ +. + * + * 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; + +use Espo\Core\Field\DateTime; +use Espo\Core\Field\Date; +use Espo\ORM\Entity; +use Exception; + +class DatetimeOptionalType extends DatetimeType +{ + public function checkRequired(Entity $entity, string $field): bool + { + return $this->isNotEmpty($entity, $field); + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + if ($entity->has($field) && $entity->get($field) !== null) { + return true; + } + + if ($entity->has($field . 'Date') && $entity->get($field . 'Date') !== null) { + return true; + } + + return false; + } + + public function checkValid(Entity $entity, string $field): bool + { + /** @var ?string $dateValue */ + $dateValue = $entity->get($field . 'Date'); + + if ($dateValue !== null) { + try { + Date::fromString($dateValue); + } catch (Exception $e) { + return false; + } + } + + /** @var ?string $value */ + $value = $entity->get($field); + + if ($value !== null) { + try { + DateTime::fromString($value); + } catch (Exception $e) { + return false; + } + } + + return true; + } +} diff --git a/application/Espo/Classes/FieldValidators/DatetimeType.php b/application/Espo/Classes/FieldValidators/DatetimeType.php new file mode 100644 index 0000000..046344d --- /dev/null +++ b/application/Espo/Classes/FieldValidators/DatetimeType.php @@ -0,0 +1,56 @@ +. + * + * 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; + +use Espo\Core\Field\DateTime; +use Espo\ORM\Entity; + +use Exception; + +class DatetimeType extends DateType +{ + public function checkValid(Entity $entity, string $field): bool + { + /** @var ?string $value */ + $value = $entity->get($field); + + if ($value === null) { + return true; + } + + try { + DateTime::fromString($value); + } catch (Exception $e) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Classes/FieldValidators/DecimalType.php b/application/Espo/Classes/FieldValidators/DecimalType.php new file mode 100644 index 0000000..5bc3803 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/DecimalType.php @@ -0,0 +1,103 @@ +. + * + * 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; + +use Espo\Core\Currency\CalculatorUtil; +use Espo\ORM\Entity; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class DecimalType +{ + public function __construct() + {} + + public function checkRequired(Entity $entity, string $field): bool + { + return $this->isNotEmpty($entity, $field); + } + + /** + * @param mixed $validationValue + * @noinspection PhpUnused + */ + public function checkMax(Entity $entity, string $field, $validationValue): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + return CalculatorUtil::compare($entity->get($field), $validationValue) <= 0; + } + + /** + * @param mixed $validationValue + * @noinspection PhpUnused + */ + public function checkMin(Entity $entity, string $field, $validationValue): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + return CalculatorUtil::compare($entity->get($field), $validationValue) >= 0; + } + + /** @noinspection PhpUnused */ + public function rawCheckValid(stdClass $data, string $field): bool + { + $value = $data->$field ?? null; + + if ($value === null) { + return true; + } + + if (!is_string($value)) { + return false; + } + + if ($value === '') { + return false; + } + + if (preg_match('/^-?[0-9]+\.?[0-9]*$/', $value)) { + return true; + } + + return false; + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + return $entity->has($field) && $entity->get($field) !== null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Email/Addresses/MaxCount.php b/application/Espo/Classes/FieldValidators/Email/Addresses/MaxCount.php new file mode 100644 index 0000000..332d504 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Email/Addresses/MaxCount.php @@ -0,0 +1,73 @@ +. + * + * 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\Email\Addresses; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Config; +use Espo\Entities\Email; +use Espo\ORM\Entity; + +use LogicException; + +/** + * @implements Validator + */ +class MaxCount implements Validator +{ + private const MAX_COUNT = 100; + + public function __construct(private Config $config) {} + + /** + * @param Email $entity + */ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if ($field === 'to') { + $addresses = $entity->getToAddressList(); + } else if ($field === 'cc') { + $addresses = $entity->getCcAddressList(); + } else if ($field === 'bcc') { + $addresses = $entity->getBccAddressList(); + } else { + throw new LogicException(); + } + + $maxCount = $this->config->get('emailRecipientAddressMaxCount') ?? self::MAX_COUNT; + + if (count($addresses) > $maxCount) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Email/Addresses/Valid.php b/application/Espo/Classes/FieldValidators/Email/Addresses/Valid.php new file mode 100644 index 0000000..11ef630 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Email/Addresses/Valid.php @@ -0,0 +1,68 @@ +. + * + * 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\Email\Addresses; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\ORM\Entity; +use Espo\Entities\Email; + +use LogicException; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + /** + * @param Email $entity + */ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if ($field === 'to') { + $addresses = $entity->getToAddressList(); + } else if ($field === 'cc') { + $addresses = $entity->getCcAddressList(); + } else if ($field === 'bcc') { + $addresses = $entity->getBccAddressList(); + } else { + throw new LogicException(); + } + + foreach ($addresses as $address) { + if (!filter_var($address, FILTER_VALIDATE_EMAIL)) { + return Failure::create(); + } + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Email/EmailAddresses.php b/application/Espo/Classes/FieldValidators/Email/EmailAddresses.php new file mode 100644 index 0000000..ecf077e --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Email/EmailAddresses.php @@ -0,0 +1,53 @@ +. + * + * 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\Email; + +use Espo\Entities\Email; +use Espo\ORM\Entity; + +class EmailAddresses +{ + /** + * @param Email $entity + */ + public function checkRequired(Entity $entity, string $field): bool + { + if ($entity->getStatus() === Email::STATUS_DRAFT) { + return true; + } + + return $this->isNotEmpty($entity, $field); + } + + private function isNotEmpty(Entity $entity, string $field): bool + { + return $entity->has($field) && $entity->get($field) !== '' && $entity->get($field) !== null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Email/SendAt/Future.php b/application/Espo/Classes/FieldValidators/Email/SendAt/Future.php new file mode 100644 index 0000000..0a3537f --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Email/SendAt/Future.php @@ -0,0 +1,58 @@ +. + * + * 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\Email\SendAt; + +use Espo\Core\Field\DateTime; +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\Email; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Future implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->getSendAt(); + + if (!$value) { + return null; + } + + if ($value->isLessThan(DateTime::createNow())) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/EmailAccount/FolderMap/Valid.php b/application/Espo/Classes/FieldValidators/EmailAccount/FolderMap/Valid.php new file mode 100644 index 0000000..6c6e0f9 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/EmailAccount/FolderMap/Valid.php @@ -0,0 +1,80 @@ +. + * + * 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\EmailAccount\FolderMap; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\EmailAccount; +use Espo\Entities\EmailFolder; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use stdClass; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $map = $entity->get('folderMap'); + + if ($map === null) { + return null; + } + + if (!$map instanceof stdClass) { + return Failure::create(); + } + + foreach (get_object_vars($map) as $folderId) { + if ($folderId === null) { + continue; + } + + if (!is_string($folderId)) { + return Failure::create(); + } + + $folderEntry = $this->entityManager->getRepositoryByClass(EmailFolder::class)->getById($folderId); + + if (!$folderEntry) { + return Failure::create(); + } + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/EmailType.php b/application/Espo/Classes/FieldValidators/EmailType.php new file mode 100644 index 0000000..3801c68 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/EmailType.php @@ -0,0 +1,154 @@ +. + * + * 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; + +use Espo\Core\Name\Field; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\ORM\Entity; + +use stdClass; + +class EmailType +{ + private const DEFAULT_MAX_LENGTH = 255; + private const MAX_COUNT = 10; + + public function __construct( + private Metadata $metadata, + private Config $config, + ) {} + + public function checkRequired(Entity $entity, string $field): bool + { + if ($this->isNotEmpty($entity, $field)) { + return true; + } + + $dataList = $entity->get($field . 'Data'); + + if (!is_array($dataList)) { + return false; + } + + foreach ($dataList as $item) { + if (!empty($item->emailAddress)) { + return true; + } + } + + return false; + } + + public function checkEmailAddress(Entity $entity, string $field): bool + { + if ($this->isNotEmpty($entity, $field)) { + $address = $entity->get($field); + + if (!filter_var($address, FILTER_VALIDATE_EMAIL)) { + return false; + } + } + + $dataList = $entity->get($field . 'Data'); + + if (!is_array($dataList)) { + return true; + } + + foreach ($dataList as $item) { + if (!$item instanceof stdClass) { + return false; + } + + if (empty($item->emailAddress)) { + continue; + } + + $address = $item->emailAddress; + + if (!filter_var($address, FILTER_VALIDATE_EMAIL)) { + return false; + } + } + + return true; + } + + public function checkMaxLength(Entity $entity, string $field): bool + { + /** @var ?string $value */ + $value = $entity->get($field); + + /** @var int $maxLength */ + $maxLength = $this->metadata + ->get(['entityDefs', 'EmailAddress', 'fields', Field::NAME, FieldParam::MAX_LENGTH]) ?? + self::DEFAULT_MAX_LENGTH; + + if ($value && mb_strlen($value) > $maxLength) { + return false; + } + + $dataList = $entity->get($field . 'Data'); + + if (!is_array($dataList)) { + return true; + } + + foreach ($dataList as $item) { + $value = $item->emailAddress; + + if ($value && mb_strlen($value) > $maxLength) { + return false; + } + } + + return true; + } + + public function checkMaxCount(Entity $entity, string $field): bool + { + $maxCount = $this->config->get('emailAddressMaxCount') ?? self::MAX_COUNT; + + $dataList = $entity->get($field . 'Data'); + + if (!is_array($dataList)) { + return true; + } + + return count($dataList) <= $maxCount; + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + return $entity->has($field) && $entity->get($field) !== '' && $entity->get($field) !== null; + } +} diff --git a/application/Espo/Classes/FieldValidators/EnumType.php b/application/Espo/Classes/FieldValidators/EnumType.php new file mode 100644 index 0000000..045c5a1 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/EnumType.php @@ -0,0 +1,121 @@ +. + * + * 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; + +use Espo\Core\Utils\Metadata; + +use Espo\ORM\Defs; +use Espo\ORM\Entity; + +class EnumType +{ + private Metadata $metadata; + private Defs $defs; + + private const DEFAULT_MAX_LENGTH = 255; + + public function __construct(Metadata $metadata, Defs $defs) + { + $this->metadata = $metadata; + $this->defs = $defs; + } + + public function checkRequired(Entity $entity, string $field): bool + { + return $this->isNotEmpty($entity, $field); + } + + public function checkValid(Entity $entity, string $field): bool + { + if (!$entity->has($field)) { + return true; + } + + $fieldDefs = $this->defs + ->getEntity($entity->getEntityType()) + ->getField($field); + + /** @var ?string $path */ + $path = $fieldDefs->getParam('optionsPath'); + /** @var ?string $path */ + $ref = $fieldDefs->getParam('optionsReference'); + + if (!$path && $ref && str_contains($ref, '.')) { + [$refEntityType, $refField] = explode('.', $ref); + + $path = "entityDefs.{$refEntityType}.fields.{$refField}.options"; + } + + /** @var string[]|null|false $optionList */ + $optionList = $path ? + $this->metadata->get($path) : + $fieldDefs->getParam('options'); + + if ($optionList === null) { + return true; + } + + // For bc. + if ($optionList === false) { + return true; + } + + $optionList = array_map( + fn ($item) => $item === '' ? null : $item, + $optionList + ); + + $value = $entity->get($field); + + return in_array($value, $optionList); + } + + public function checkMaxLength(Entity $entity, string $field, ?int $validationValue): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + $value = $entity->get($field); + + $maxLength = $validationValue ?? self::DEFAULT_MAX_LENGTH; + + if (mb_strlen($value) > $maxLength) { + return false; + } + + return true; + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + return $entity->has($field) && $entity->get($field) !== null; + } +} diff --git a/application/Espo/Classes/FieldValidators/FileType.php b/application/Espo/Classes/FieldValidators/FileType.php new file mode 100644 index 0000000..a3bb805 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/FileType.php @@ -0,0 +1,34 @@ +. + * + * 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; + +class FileType extends LinkType +{ +} diff --git a/application/Espo/Classes/FieldValidators/FloatType.php b/application/Espo/Classes/FieldValidators/FloatType.php new file mode 100644 index 0000000..e16bd94 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/FloatType.php @@ -0,0 +1,34 @@ +. + * + * 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; + +class FloatType extends IntType +{ +} diff --git a/application/Espo/Classes/FieldValidators/ImageType.php b/application/Espo/Classes/FieldValidators/ImageType.php new file mode 100644 index 0000000..9597b1f --- /dev/null +++ b/application/Espo/Classes/FieldValidators/ImageType.php @@ -0,0 +1,34 @@ +. + * + * 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; + +class ImageType extends FileType +{ +} diff --git a/application/Espo/Classes/FieldValidators/InboundEmail/FetchSince/Required.php b/application/Espo/Classes/FieldValidators/InboundEmail/FetchSince/Required.php new file mode 100644 index 0000000..06ed8e0 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/InboundEmail/FetchSince/Required.php @@ -0,0 +1,56 @@ +. + * + * 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\InboundEmail\FetchSince; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\EmailAccount; +use Espo\Entities\InboundEmail; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Required implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if (!$entity->isAvailableForFetching()) { + return null; + } + + if (!$entity->get('fetchSince')) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/IntType.php b/application/Espo/Classes/FieldValidators/IntType.php new file mode 100644 index 0000000..869cfea --- /dev/null +++ b/application/Espo/Classes/FieldValidators/IntType.php @@ -0,0 +1,139 @@ +. + * + * 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; + +use Doctrine\DBAL\Types\Types; +use Espo\ORM\Defs; +use Espo\ORM\Defs\Params\AttributeParam; +use Espo\ORM\Entity; +use stdClass; + +class IntType +{ + public function __construct( + private Defs $defs, + ) {} + + public function checkRequired(Entity $entity, string $field): bool + { + return $this->isNotEmpty($entity, $field); + } + + /** @noinspection PhpUnused */ + public function checkRangeInternal(Entity $entity, string $field): bool + { + $value = $entity->get($field); + + if ($value === null) { + return true; + } + + $dbType = $this->defs + ->getEntity($entity->getEntityType()) + ->tryGetAttribute($field) + ?->getParam(AttributeParam::DB_TYPE) ?? Types::INTEGER; + + $ranges = [ + Types::INTEGER => [-2147483648, 2147483647], + Types::SMALLINT => [-32768, 32767], + ]; + + $range = $ranges[$dbType] ?? null; + + if (!$range) { + return true; + } + + if ($value < $range[0] || $value > $range[1]) { + return false; + } + + return true; + } + + /** + * @param mixed $validationValue + * @noinspection PhpUnused + */ + public function checkMax(Entity $entity, string $field, $validationValue): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + if ($entity->get($field) > $validationValue) { + return false; + } + + return true; + } + + /** + * @param mixed $validationValue + * @noinspection PhpUnused + */ + public function checkMin(Entity $entity, string $field, $validationValue): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + if ($entity->get($field) < $validationValue) { + return false; + } + + return true; + } + + /** @noinspection PhpUnused */ + public function rawCheckValid(stdClass $data, string $field): bool + { + if (!isset($data->$field)) { + return true; + } + + $value = $data->$field; + + if ($value === '') { + return true; + } + + if (is_numeric($value)) { + return true; + } + + return false; + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + return $entity->has($field) && $entity->get($field) !== null; + } +} diff --git a/application/Espo/Classes/FieldValidators/JsonArrayType.php b/application/Espo/Classes/FieldValidators/JsonArrayType.php new file mode 100644 index 0000000..a77695c --- /dev/null +++ b/application/Espo/Classes/FieldValidators/JsonArrayType.php @@ -0,0 +1,70 @@ +. + * + * 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; + +use Espo\ORM\Entity; + +use stdClass; + +class JsonArrayType +{ + public function checkRequired(Entity $entity, string $field): bool + { + return $this->isNotEmpty($entity, $field); + } + + public function rawCheckArray(stdClass $data, string $field): bool + { + if (isset($data->$field) && !is_array($data->$field)) { + return false; + } + + return true; + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + if (!$entity->has($field) || $entity->get($field) === null) { + return false; + } + + $list = $entity->get($field); + + if (!is_array($list)) { + return false; + } + + if (count($list)) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Classes/FieldValidators/LeadCapture/Pipeline/Valid.php b/application/Espo/Classes/FieldValidators/LeadCapture/Pipeline/Valid.php new file mode 100644 index 0000000..1e79f6e --- /dev/null +++ b/application/Espo/Classes/FieldValidators/LeadCapture/Pipeline/Valid.php @@ -0,0 +1,58 @@ +. + * + * 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\LeadCapture\Pipeline; + +use Espo\Entities\LeadCapture; +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Modules\Crm\Entities\Lead; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $pipeline = $entity->getPipeline(); + + if (!$pipeline) { + return null; + } + + if ($pipeline->getTargetEntityType() !== Lead::ENTITY_TYPE) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/LinkMultipleType.php b/application/Espo/Classes/FieldValidators/LinkMultipleType.php new file mode 100644 index 0000000..4fbde7d --- /dev/null +++ b/application/Espo/Classes/FieldValidators/LinkMultipleType.php @@ -0,0 +1,282 @@ +. + * + * 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; + +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; + +use stdClass; + +/** + * @noinspection PhpUnused + */ +class LinkMultipleType +{ + private const COLUMN_TYPE_ENUM = 'enum'; + private const COLUMN_TYPE_VARCHAR = 'varchar'; + private const COLUMN_TYPE_BOOL = 'bool'; + + public function __construct(private Metadata $metadata, private Defs $defs) + {} + + public function checkRequired(Entity $entity, string $field): bool + { + if (!$entity instanceof CoreEntity) { + return false; + } + + /** @var string[] $idList */ + $idList = $entity->getLinkMultipleIdList($field); + + return count($idList) > 0; + } + + /** @noinspection PhpUnused */ + public function checkPattern(Entity $entity, string $field): bool + { + /** @var ?mixed[] $idList */ + $idList = $entity->get($field . 'Ids'); + + if ($idList === null || $idList === []) { + return true; + } + + $pattern = $this->metadata->get(['app', 'regExpPatterns', 'id', 'pattern']); + + if (!$pattern) { + return true; + } + + $preparedPattern = '/^' . $pattern . '$/'; + + foreach ($idList as $id) { + if (!is_string($id)) { + return false; + } + + if (!preg_match($preparedPattern, $id)) { + return false; + } + } + + return true; + } + + /** @noinspection PhpUnused */ + public function checkMaxCount(Entity $entity, string $field, ?int $maxCount): bool + { + if ($maxCount === null) { + return true; + } + + $list = $entity->get($field . 'Ids'); + + if (!is_array($list)) { + return true; + } + + if (count($list) > $maxCount) { + return false; + } + + return true; + } + + /** @noinspection PhpUnused */ + public function checkColumnsValid(Entity $entity, string $field): bool + { + if (!$entity instanceof CoreEntity) { + return true; + } + + if (!$entity->has($field . 'Columns')) { + return true; + } + + /** @var ?stdClass $columnsData */ + $columnsData = $entity->get($field . 'Columns'); + + if ($columnsData === null) { + return true; + } + + $entityDefs = $this->defs->getEntity($entity->getEntityType()); + $fieldDefs = $entityDefs->getField($field); + + if ($fieldDefs->isNotStorable()) { + return true; + } + + /** @var ?array $columnsMap */ + $columnsMap = $fieldDefs->getParam('columns'); + + if ($columnsMap === null || $columnsMap === []) { + return true; + } + + if (!$entityDefs->hasRelation($field)) { + return true; + } + + $relationDefs = $entityDefs->getRelation($field); + + if (!$relationDefs->hasForeignEntityType()) { + return true; + } + + $foreignEntityType = $relationDefs->getForeignEntityType(); + + foreach (array_keys(get_object_vars($columnsData)) as $id) { + $itemData = $columnsData->$id; + + if (!$itemData instanceof stdClass) { + return false; + } + + foreach ($columnsMap as $column => $foreignField) { + if (!property_exists($itemData, $column)) { + continue; + } + + $value = $itemData->$column; + + $result = $this->checkColumnValue($foreignEntityType, $foreignField, $value); + + if (!$result) { + return false; + } + } + } + + return true; + } + + /** + * @param mixed $value + */ + private function checkColumnValue(string $entityType, string $field, $value): bool + { + $fieldDefs = $this->defs + ->getEntity($entityType) + ->getField($field); + + $type = $fieldDefs->getType(); + + if ($type === self::COLUMN_TYPE_VARCHAR) { + return $this->checkColumnValueVarchar($fieldDefs, $value); + } + + if ($type === self::COLUMN_TYPE_ENUM) { + return $this->checkColumnValueEnum($fieldDefs, $value); + } + + if ($type === self::COLUMN_TYPE_BOOL) { + return is_bool($value); + } + + return true; + } + + /** + * @param mixed $value + */ + private function checkColumnValueVarchar(Defs\FieldDefs $fieldDefs, $value): bool + { + if ($value === null) { + return true; + } + + if (!is_string($value)) { + return false; + } + + $maxLength = $fieldDefs->getParam(FieldParam::MAX_LENGTH); + $pattern = $fieldDefs->getParam('pattern'); + + if ($maxLength && mb_strlen($value) > $maxLength) { + return false; + } + + if ($pattern) { + if ($pattern[0] === '$') { + $patternName = substr($pattern, 1); + + $pattern = $this->metadata + ->get(['app', 'regExpPatterns', $patternName, 'pattern']) ?? + $pattern; + } + + $preparedPattern = '/^' . $pattern . '$/'; + + if (!preg_match($preparedPattern, $value)) { + return false; + } + } + + return true; + } + + /** + * @param mixed $value + */ + private function checkColumnValueEnum(Defs\FieldDefs $fieldDefs, $value): bool + { + if (!is_string($value) && $value !== null) { + return false; + } + + /** @var ?string $path */ + $path = $fieldDefs->getParam('optionsPath'); + + /** @var string[]|null|false $optionList */ + $optionList = $path ? + $this->metadata->get($path) : + $fieldDefs->getParam('options'); + + if ($optionList === null) { + return true; + } + + // For bc. + if ($optionList === false) { + return true; + } + + $optionList = array_map( + fn ($item) => $item === '' ? null : $item, + $optionList + ); + + return in_array($value, $optionList); + } +} diff --git a/application/Espo/Classes/FieldValidators/LinkParentType.php b/application/Espo/Classes/FieldValidators/LinkParentType.php new file mode 100644 index 0000000..3c02985 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/LinkParentType.php @@ -0,0 +1,108 @@ +. + * + * 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; + +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs; +use Espo\ORM\Entity; + +class LinkParentType +{ + private Metadata $metadata; + private Defs $defs; + + public function __construct(Metadata $metadata, Defs $defs) + { + $this->metadata = $metadata; + $this->defs = $defs; + } + + public function checkRequired(Entity $entity, string $field): bool + { + $idAttribute = $field . 'Id'; + $typeAttribute = $field . 'Type'; + + if ( + !$entity->has($idAttribute) || + $entity->get($idAttribute) === '' || + $entity->get($idAttribute) === null + ) { + return false; + } + + if (!$entity->get($typeAttribute)) { + return false; + } + + return true; + } + + public function checkPattern(Entity $entity, string $field): bool + { + /** @var ?string $idValue */ + $idValue = $entity->get($field . 'Id'); + + if ($idValue === null) { + return true; + } + + $pattern = $this->metadata->get(['app', 'regExpPatterns', 'id', 'pattern']); + + if (!$pattern) { + return true; + } + + $preparedPattern = '/^' . $pattern . '$/'; + + return (bool) preg_match($preparedPattern, $idValue); + } + + public function checkValid(Entity $entity, string $field): bool + { + /** @var ?string $typeValue */ + $typeValue = $entity->get($field . 'Type'); + + if ($typeValue === null) { + return true; + } + + /** @var ?string[] $entityTypeList */ + $entityTypeList = $this->defs + ->getEntity($entity->getEntityType()) + ->getField($field) + ->getParam('entityList'); + + if ($entityTypeList !== null) { + return in_array($typeValue, $entityTypeList); + } + + return (bool) $this->metadata->get(['entityDefs', $typeValue]); + } +} diff --git a/application/Espo/Classes/FieldValidators/LinkType.php b/application/Espo/Classes/FieldValidators/LinkType.php new file mode 100644 index 0000000..86fa93e --- /dev/null +++ b/application/Espo/Classes/FieldValidators/LinkType.php @@ -0,0 +1,73 @@ +. + * + * 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; + +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; + +class LinkType +{ + private Metadata $metadata; + + public function __construct(Metadata $metadata) + { + $this->metadata = $metadata; + } + + public function checkRequired(Entity $entity, string $field): bool + { + $idAttribute = $field . 'Id'; + + if (!$entity->has($idAttribute)) { + return false; + } + + return $entity->get($idAttribute) !== null && $entity->get($idAttribute) !== ''; + } + + public function checkPattern(Entity $entity, string $field): bool + { + $idValue = $entity->get($field . 'Id'); + + if ($idValue === null) { + return true; + } + + $pattern = $this->metadata->get(['app', 'regExpPatterns', 'id', 'pattern']); + + if (!$pattern) { + return true; + } + + $preparedPattern = '/^' . $pattern . '$/'; + + return (bool) preg_match($preparedPattern, $idValue); + } +} diff --git a/application/Espo/Classes/FieldValidators/MultiEnumType.php b/application/Espo/Classes/FieldValidators/MultiEnumType.php new file mode 100644 index 0000000..2882874 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/MultiEnumType.php @@ -0,0 +1,40 @@ +. + * + * 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; + +use Espo\ORM\Entity; + +class MultiEnumType extends ArrayType +{ + public function checkNoEmptyString(Entity $entity, string $field, ?bool $validationValue): bool + { + return parent::checkNoEmptyString($entity, $field, true); + } +} diff --git a/application/Espo/Classes/FieldValidators/OAuthClient/Scopes/Valid.php b/application/Espo/Classes/FieldValidators/OAuthClient/Scopes/Valid.php new file mode 100644 index 0000000..32899fd --- /dev/null +++ b/application/Espo/Classes/FieldValidators/OAuthClient/Scopes/Valid.php @@ -0,0 +1,60 @@ +. + * + * 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\OAuthClient\Scopes; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Tools\OAuthServer\Entities\Client; +use Espo\ORM\Entity; +use Espo\Tools\OAuthServer\Scope\ScopesProvider; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct( + private ScopesProvider $scopesProvider, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $scopes = $this->scopesProvider->get(); + + foreach ($entity->getScopes() as $scope) { + if (!in_array($scope, $scopes)) { + return Failure::create(); + } + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/PasswordType.php b/application/Espo/Classes/FieldValidators/PasswordType.php new file mode 100644 index 0000000..efdfac1 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/PasswordType.php @@ -0,0 +1,65 @@ +. + * + * 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; + +use stdClass; + +class PasswordType +{ + private const DEFAULT_MAX_LENGTH = 255; + + public function rawCheckValid(stdClass $data, string $field): bool + { + $value = $data->$field ?? null; + + if ($value === null) { + return true; + } + + return is_string($value); + } + + public function rawCheckMaxLength(stdClass $data, string $field, ?int $validationValue): bool + { + $value = $data->$field ?? null; + + if (!is_string($value)) { + return true; + } + + $maxLength = $validationValue ?? self::DEFAULT_MAX_LENGTH; + + if (mb_strlen($value) > $maxLength) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Classes/FieldValidators/PersonNameType.php b/application/Espo/Classes/FieldValidators/PersonNameType.php new file mode 100644 index 0000000..8ad5ff2 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/PersonNameType.php @@ -0,0 +1,64 @@ +. + * + * 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; + +use Espo\ORM\Entity; +use Espo\Core\Utils\FieldUtil; + +class PersonNameType +{ + public function __construct(private FieldUtil $fieldUtil) + {} + + public function checkRequired(Entity $entity, string $field): bool + { + $isEmpty = true; + + $attributeList = $this->fieldUtil->getActualAttributeList($entity->getEntityType(), $field); + + foreach ($attributeList as $attribute) { + if ($attribute === 'salutation' . ucfirst($field)) { + continue; + } + + if ($entity->has($attribute) && $entity->get($attribute) !== '') { + $isEmpty = false; + + break; + } + } + + if ($isEmpty) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Classes/FieldValidators/PhoneType.php b/application/Espo/Classes/FieldValidators/PhoneType.php new file mode 100644 index 0000000..ec5b288 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/PhoneType.php @@ -0,0 +1,255 @@ +. + * + * 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; + +use Brick\PhoneNumber\PhoneNumber; +use Brick\PhoneNumber\PhoneNumberParseException; +use Espo\Core\PhoneNumber\Util; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\ORM\Entity; + +use stdClass; + +/** + * @noinspection PhpUnused + */ +class PhoneType +{ + private const DEFAULT_MAX_LENGTH = 36; + private const MAX_COUNT = 10; + + public function __construct( + private Metadata $metadata, + private Defs $defs, + private Config $config + ) {} + + public function checkRequired(Entity $entity, string $field): bool + { + if ($this->isNotEmpty($entity, $field)) { + return true; + } + + $dataList = $entity->get($field . 'Data'); + + if (!is_array($dataList)) { + return false; + } + + foreach ($dataList as $item) { + if (!empty($item->phoneNumber)) { + return true; + } + } + + return false; + } + + public function checkValid(Entity $entity, string $field): bool + { + if ($this->isNotEmpty($entity, $field)) { + $number = $entity->get($field); + + if (!$this->isValidNumber($number)) { + return false; + } + } + + $dataList = $entity->get($field . 'Data'); + + if (!is_array($dataList)) { + return true; + } + + foreach ($dataList as $item) { + if (!$item instanceof stdClass) { + return false; + } + + $number = $item->phoneNumber ?? null; + $type = $item->type ?? null; + + if (!$number) { + return false; + } + + if (!$this->isValidNumber($number)) { + return false; + } + + if (!$this->isValidType($entity->getEntityType(), $field, $type)) { + return false; + } + } + + return true; + } + + public function checkMaxLength(Entity $entity, string $field): bool + { + /** @var ?string $value */ + $value = $entity->get($field); + + /** @var int $maxLength */ + $maxLength = $this->metadata->get(['entityDefs', 'PhoneNumber', 'fields', 'name', FieldParam::MAX_LENGTH]) ?? + self::DEFAULT_MAX_LENGTH; + + if ($value && mb_strlen($value) > $maxLength) { + return false; + } + + $dataList = $entity->get($field . 'Data'); + + if (!is_array($dataList)) { + return true; + } + + foreach ($dataList as $item) { + $value = $item->phoneNumber; + + if ($value && mb_strlen($value) > $maxLength) { + return false; + } + } + + return true; + } + + /** + * @param mixed $type + */ + private function isValidType(string $entityType, string $field, $type): bool + { + if ($type === null) { + // Will be stored with a default type. + return true; + } + + if (!is_string($type)) { + return false; + } + + /** @var string[]|null|false $typeList */ + $typeList = $this->defs + ->getEntity($entityType) + ->getField($field) + ->getParam('typeList'); + + if ($typeList === null) { + return true; + } + + // For bc. + if ($typeList === false) { + return true; + } + + return in_array($type, $typeList); + } + + /** + * @param mixed $number + */ + private function isValidNumber($number): bool + { + if (!is_string($number)) { + return false; + } + + if ($number === '') { + return false; + } + + $pattern = $this->metadata->get(['app', 'regExpPatterns', 'phoneNumberLoose', 'pattern']); + + if (!$pattern) { + return true; + } + + $preparedPattern = '/^' . $pattern . '$/'; + + if (!preg_match($preparedPattern, $number)) { + return false; + } + + if (!$this->config->get('phoneNumberInternational')) { + return true; + } + + $ext = null; + + if ($this->config->get('phoneNumberExtensions')) { + [$number, $ext] = Util::splitExtension($number); + } + + if ($ext) { + if (!preg_match('/[0-9]+/', $ext)) { + return false; + } + + if (strlen($ext) > 6) { + return false; + } + } + + try { + $numberObj = PhoneNumber::parse($number); + } catch (PhoneNumberParseException) { + return false; + } + + if ((string) $numberObj !== $number) { + return false; + } + + return $numberObj->isPossibleNumber(); + } + + public function checkMaxCount(Entity $entity, string $field): bool + { + $maxCount = $this->config->get('phoneNumberMaxCount') ?? self::MAX_COUNT; + + $dataList = $entity->get($field . 'Data'); + + if (!is_array($dataList)) { + return true; + } + + return count($dataList) <= $maxCount; + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + return $entity->has($field) && $entity->get($field) !== '' && $entity->get($field) !== null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Pipeline/EntityType/Valid.php b/application/Espo/Classes/FieldValidators/Pipeline/EntityType/Valid.php new file mode 100644 index 0000000..8c5f983 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Pipeline/EntityType/Valid.php @@ -0,0 +1,58 @@ +. + * + * 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\Pipeline\EntityType; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\Pipeline; +use Espo\ORM\Entity; +use Espo\Tools\Pipeline\MetadataProvider; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct( + private MetadataProvider $metadataProvider, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $entityType = $entity->getTargetEntityType(); + + if (!$this->metadataProvider->isEnabled($entityType)) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Pipeline/Field/Valid.php b/application/Espo/Classes/FieldValidators/Pipeline/Field/Valid.php new file mode 100644 index 0000000..726d3c0 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Pipeline/Field/Valid.php @@ -0,0 +1,62 @@ +. + * + * 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\Pipeline\Field; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Pipeline; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct( + private Metadata $metadata, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if (!$entity->has(Pipeline::FIELD_FIELD)) { + return null; + } + + $field = $entity->getTargetField(); + + if ($this->metadata->get("scopes.$field.statusField") !== $field) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/PipelineStatus/MappedStatus/Valid.php b/application/Espo/Classes/FieldValidators/PipelineStatus/MappedStatus/Valid.php new file mode 100644 index 0000000..9219f79 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/PipelineStatus/MappedStatus/Valid.php @@ -0,0 +1,80 @@ +. + * + * 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\PipelineStatus\MappedStatus; + +use Espo\Core\Utils\Metadata; +use Espo\Entities\PipelineStage; +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use Espo\Tools\OpenApi\Util\EnumOptionsProvider; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct( + private Metadata $metadata, + private EnumOptionsProvider $enumOptionsProvider, + private Defs $defs, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $status = $entity->getMappedStatus(); + + $entityType = $entity->getPipeline()->getTargetEntityType(); + + $field = $this->metadata->get("scopes.$entityType.statusField"); + + if (!$field) { + return null; + } + + $fieldDefs = $this->defs + ->getEntity($entityType) + ->getField($field); + + $options = $this->enumOptionsProvider->get($fieldDefs); + + if (!$options) { + return Failure::create(); + } + + if (!in_array($status, $options)) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/ScheduledJob/Scheduling/Valid.php b/application/Espo/Classes/FieldValidators/ScheduledJob/Scheduling/Valid.php new file mode 100644 index 0000000..05c857a --- /dev/null +++ b/application/Espo/Classes/FieldValidators/ScheduledJob/Scheduling/Valid.php @@ -0,0 +1,61 @@ +. + * + * 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\ScheduledJob\Scheduling; + +use Cron\CronExpression; +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\ScheduledJob; +use Espo\ORM\Entity; +use Exception; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $scheduling = $entity->getScheduling(); + + if ($scheduling === null) { + return null; + } + + try { + new CronExpression($scheduling); + } catch (Exception) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Settings/AuthIpAddressWhitelist/Valid.php b/application/Espo/Classes/FieldValidators/Settings/AuthIpAddressWhitelist/Valid.php new file mode 100644 index 0000000..258de10 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Settings/AuthIpAddressWhitelist/Valid.php @@ -0,0 +1,90 @@ +. + * + * 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\Settings\AuthIpAddressWhitelist; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $list = $entity->get($field); + + if (!is_array($list)) { + return null; + } + + foreach ($list as $item) { + if (!is_string($item)) { + continue; + } + + if (!$this->isValid($item)) { + return Failure::create(); + } + } + + return null; + } + + private function isValid(string $item): bool + { + $address = $item; + + if (count(explode('/', $item)) > 1) { + [$address, $mask] = explode('/', $item, 2); + + if (!is_numeric($mask)) { + return false; + } + + $mask = (int) $mask; + + if ($mask < 0 || $mask > 128) { + return false; + } + } + + if ( + filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false && + filter_var($address, FILTER_VALIDATE_IP) === false + ) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Classes/FieldValidators/Settings/AvailableReactions/Valid.php b/application/Espo/Classes/FieldValidators/Settings/AvailableReactions/Valid.php new file mode 100644 index 0000000..163e1a4 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Settings/AvailableReactions/Valid.php @@ -0,0 +1,66 @@ +. + * + * 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\Settings\AvailableReactions; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Settings; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct(private Metadata $metadata) + {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->get($field); + + if (!is_array($value)) { + return null; + } + + /** @var string[] $allowedList */ + $allowedList = array_map(fn ($it) => $it['type'], $this->metadata->get("app.reactions.list", [])); + + foreach ($value as $it) { + if (!in_array($it, $allowedList)) { + return Failure::create(); + } + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Settings/ThousandSeparator/Valid.php b/application/Espo/Classes/FieldValidators/Settings/ThousandSeparator/Valid.php new file mode 100644 index 0000000..92a3d5c --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Settings/ThousandSeparator/Valid.php @@ -0,0 +1,60 @@ +. + * + * 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\Settings\ThousandSeparator; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->get($field); + + if (!$value) { + return null; + } + + if (!is_string($value)) { + return Failure::create(); + } + + if (preg_match('/^[0-9]$/', $value)) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/TextType.php b/application/Espo/Classes/FieldValidators/TextType.php new file mode 100644 index 0000000..08e68fe --- /dev/null +++ b/application/Espo/Classes/FieldValidators/TextType.php @@ -0,0 +1,63 @@ +. + * + * 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; + +use Espo\ORM\Entity; + +class TextType +{ + public function checkRequired(Entity $entity, string $field): bool + { + return $this->isNotEmpty($entity, $field); + } + + public function checkMaxLength(Entity $entity, string $field, int $validationValue): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + $value = $entity->get($field); + + if (mb_strlen($value) > $validationValue) { + return false; + } + + return true; + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + return + $entity->has($field) && + $entity->get($field) !== '' && + $entity->get($field) !== null; + } +} diff --git a/application/Espo/Classes/FieldValidators/UrlMultipleType.php b/application/Espo/Classes/FieldValidators/UrlMultipleType.php new file mode 100644 index 0000000..2aaa908 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/UrlMultipleType.php @@ -0,0 +1,71 @@ +. + * + * 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; + +use Espo\ORM\Entity; + +class UrlMultipleType extends ArrayType +{ + private const MAX_ITEM_LENGTH = 255; + + public function checkNoEmptyString(Entity $entity, string $field, ?bool $validationValue): bool + { + return parent::checkNoEmptyString($entity, $field, true); + } + + public function checkMaxItemLength(Entity $entity, string $field, ?int $validationValue): bool + { + return parent::checkMaxItemLength($entity, $field, $validationValue ?? self::MAX_ITEM_LENGTH); + } + + public function checkPattern(Entity $entity, string $field, ?string $validationValue): bool + { + $value = $entity->get($field); + + if (is_array($value)) { + $allValid = true; + + foreach ($value as $it) { + if (is_string($it) && filter_var($it, FILTER_VALIDATE_URL) === false) { + $allValid = false; + } + } + + if ($allValid) { + return true; + } + } + + /** @var string $pattern */ + $pattern = $this->metadata->get(['app', 'regExpPatterns', 'uriOptionalProtocol', 'pattern']); + + return parent::checkPattern($entity, $field, $pattern); + } +} diff --git a/application/Espo/Classes/FieldValidators/UrlType.php b/application/Espo/Classes/FieldValidators/UrlType.php new file mode 100644 index 0000000..6ffc798 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/UrlType.php @@ -0,0 +1,83 @@ +. + * + * 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; + +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs; +use Espo\ORM\Entity; + +class UrlType +{ + + public function __construct( + private Metadata $metadata, + private VarcharType $varcharType, + private Defs $defs, + ) {} + + public function checkRequired(Entity $entity, string $field): bool + { + return $this->varcharType->checkRequired($entity, $field); + } + + public function checkMaxLength(Entity $entity, string $field, ?int $validationValue): bool + { + return $this->varcharType->checkMaxLength($entity, $field, $validationValue); + } + + public function checkValid(Entity $entity, string $field): bool + { + $value = $entity->get($field); + + if ($value === null) { + return true; + } + + if ( + $this->defs + ->getEntity($entity->getEntityType()) + ->tryGetField($field) + ?->getParam('protocolRequired') + ) { + return filter_var($value, FILTER_VALIDATE_URL) !== false; + } + + if (filter_var($value, FILTER_VALIDATE_URL) !== false) { + return true; + } + + /** @var string $pattern */ + $pattern = $this->metadata->get(['app', 'regExpPatterns', 'uriOptionalProtocol', 'pattern']); + + $preparedPattern = '/^' . $pattern . '$/'; + + return (bool) preg_match($preparedPattern, $value); + } +} diff --git a/application/Espo/Classes/FieldValidators/User/DefaultTeam/IsUserTeam.php b/application/Espo/Classes/FieldValidators/User/DefaultTeam/IsUserTeam.php new file mode 100644 index 0000000..6b7ef9c --- /dev/null +++ b/application/Espo/Classes/FieldValidators/User/DefaultTeam/IsUserTeam.php @@ -0,0 +1,55 @@ +. + * + * 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\User\DefaultTeam; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class IsUserTeam implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if (!$entity->getDefaultTeam()) { + return null; + } + + if (in_array($entity->getDefaultTeam()->getId(), $entity->getTeamIdList())) { + return null; + } + + return Failure::create(); + } +} diff --git a/application/Espo/Classes/FieldValidators/User/UserName/Valid.php b/application/Espo/Classes/FieldValidators/User/UserName/Valid.php new file mode 100644 index 0000000..2314b6d --- /dev/null +++ b/application/Espo/Classes/FieldValidators/User/UserName/Valid.php @@ -0,0 +1,79 @@ +. + * + * 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\User\UserName; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Config; +use Espo\Entities\User; +use Espo\ORM\Entity; +use RuntimeException; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + private Config $config; + + public function __construct(Config $config) { + $this->config = $config; + } + + /** + * @param User $entity + */ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->getUserName(); + + if ($value === null) { + return null; + } + + /** @var ?string $regExp */ + $regExp = $this->config->get('userNameRegularExpression'); + + if (!$regExp) { + throw new RuntimeException("No `userNameRegularExpression` in config."); + } + + if (strpos($value, ' ') !== false) { + return Failure::create(); + } + + if (preg_replace("/{$regExp}/", '_', $value) !== $value) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/VarcharType.php b/application/Espo/Classes/FieldValidators/VarcharType.php new file mode 100644 index 0000000..fad62f0 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/VarcharType.php @@ -0,0 +1,107 @@ +. + * + * 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; + +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs; +use Espo\ORM\Entity; + +class VarcharType +{ + private Metadata $metadata; + + private const DEFAULT_MAX_LENGTH = 255; + private Defs $defs; + + public function __construct(Metadata $metadata, Defs $defs) + { + $this->metadata = $metadata; + $this->defs = $defs; + } + + public function checkRequired(Entity $entity, string $field): bool + { + return $this->isNotEmpty($entity, $field); + } + + public function checkMaxLength(Entity $entity, string $field, ?int $validationValue): bool + { + if (!$this->isNotEmpty($entity, $field)) { + return true; + } + + $fieldDefs = $this->defs + ->getEntity($entity->getEntityType()) + ->getField($field); + + if ($fieldDefs->isNotStorable() && !$validationValue) { + return true; + } + + $value = $entity->get($field); + + $maxLength = $validationValue ?? self::DEFAULT_MAX_LENGTH; + + if (mb_strlen($value) > $maxLength) { + return false; + } + + return true; + } + + public function checkPattern(Entity $entity, string $field, ?string $validationValue): bool + { + if (!$this->isNotEmpty($entity, $field) || !$validationValue) { + return true; + } + + $value = $entity->get($field); + $pattern = $validationValue; + + if ($validationValue[0] === '$') { + $patternName = substr($validationValue, 1); + + $pattern = $this->metadata->get(['app', 'regExpPatterns', $patternName, 'pattern']) ?? + $pattern; + } + + $preparedPattern = '/^' . $pattern . '$/'; + + return (bool) preg_match($preparedPattern, $value); + } + + protected function isNotEmpty(Entity $entity, string $field): bool + { + return + $entity->has($field) && + $entity->get($field) !== '' && + $entity->get($field) !== null; + } +} diff --git a/application/Espo/Classes/FieldValidators/Webhook/Url/NotInternal.php b/application/Espo/Classes/FieldValidators/Webhook/Url/NotInternal.php new file mode 100644 index 0000000..f468c25 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/Webhook/Url/NotInternal.php @@ -0,0 +1,71 @@ +. + * + * 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\Webhook\Url; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Core\Utils\Security\UrlCheck; +use Espo\Core\Webhook\AddressUtil; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class NotInternal implements Validator +{ + public function __construct( + private UrlCheck $urlCheck, + private AddressUtil $addressUtil, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $value = $entity->get($field); + + if (!$value) { + return null; + } + + if (!$this->urlCheck->isUrl($value)) { + return null; + } + + if ($this->addressUtil->isAllowedUrl($value)) { + return null; + } + + if (!$this->urlCheck->isUrlAndNotInternal($value)) { + return Failure::create(); + } + + return null; + } +} diff --git a/application/Espo/Classes/FieldValidators/WorkingTimeRange/Calendars/OnlyIfNoUsers.php b/application/Espo/Classes/FieldValidators/WorkingTimeRange/Calendars/OnlyIfNoUsers.php new file mode 100644 index 0000000..746fa83 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/WorkingTimeRange/Calendars/OnlyIfNoUsers.php @@ -0,0 +1,55 @@ +. + * + * 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\WorkingTimeRange\Calendars; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\WorkingTimeRange; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class OnlyIfNoUsers implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if ($entity->getCalendars()->getCount() === 0) { + return null; + } + + if ($entity->getUsers()->getCount() === 0) { + return null; + } + + return Failure::create(); + } +} diff --git a/application/Espo/Classes/FieldValidators/WorkingTimeRange/Calendars/RequiredIfNoUsers.php b/application/Espo/Classes/FieldValidators/WorkingTimeRange/Calendars/RequiredIfNoUsers.php new file mode 100644 index 0000000..7730b09 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/WorkingTimeRange/Calendars/RequiredIfNoUsers.php @@ -0,0 +1,52 @@ +. + * + * 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\WorkingTimeRange\Calendars; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\WorkingTimeRange; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class RequiredIfNoUsers implements Validator +{ + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if ($entity->getCalendars()->getCount() !== 0 || $entity->getUsers()->getCount() !== 0) { + return null; + } + + return Failure::create(); + } +} diff --git a/application/Espo/Classes/FieldValidators/WorkingTimeRange/Users/OnlyIfNoCalendars.php b/application/Espo/Classes/FieldValidators/WorkingTimeRange/Users/OnlyIfNoCalendars.php new file mode 100644 index 0000000..24c82a5 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/WorkingTimeRange/Users/OnlyIfNoCalendars.php @@ -0,0 +1,55 @@ +. + * + * 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\WorkingTimeRange\Users; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\WorkingTimeRange; +use Espo\ORM\Entity; + +/** + * @implements Validator + */ +class OnlyIfNoCalendars implements Validator +{ + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + if ($entity->getUsers()->getCount() === 0) { + return null; + } + + if ($entity->getCalendars()->getCount() === 0) { + return null; + } + + return Failure::create(); + } +} diff --git a/application/Espo/Classes/JobPreparators/CheckEmailAccounts.php b/application/Espo/Classes/JobPreparators/CheckEmailAccounts.php new file mode 100644 index 0000000..9db05dc --- /dev/null +++ b/application/Espo/Classes/JobPreparators/CheckEmailAccounts.php @@ -0,0 +1,65 @@ +. + * + * 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\JobPreparators; + +use Espo\Core\Job\Preparator; +use Espo\Core\Job\Preparator\Data; +use Espo\Core\Name\Field; +use Espo\ORM\EntityManager; +use Espo\Entities\EmailAccount; +use Espo\Core\Job\Preparator\CollectionHelper; + +use DateTimeImmutable; + +class CheckEmailAccounts implements Preparator +{ + /** + * @param CollectionHelper $helper + */ + public function __construct( + private EntityManager $entityManager, + private CollectionHelper $helper + ) {} + + public function prepare(Data $data, DateTimeImmutable $executeTime): void + { + $collection = $this->entityManager + ->getRDBRepositoryByClass(EmailAccount::class) + ->join(Field::ASSIGNED_USER, 'assignedUserAdditional') + ->where([ + 'status' => EmailAccount::STATUS_ACTIVE, + 'useImap' => true, + 'assignedUserAdditional.isActive' => true, + ]) + ->find(); + + $this->helper->prepare($collection, $data, $executeTime); + } +} diff --git a/application/Espo/Classes/JobPreparators/CheckInboundEmails.php b/application/Espo/Classes/JobPreparators/CheckInboundEmails.php new file mode 100644 index 0000000..6b5497c --- /dev/null +++ b/application/Espo/Classes/JobPreparators/CheckInboundEmails.php @@ -0,0 +1,62 @@ +. + * + * 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\JobPreparators; + +use Espo\Core\Job\Preparator; +use Espo\Core\Job\Preparator\Data; +use Espo\ORM\EntityManager; +use Espo\Entities\InboundEmail; +use Espo\Core\Job\Preparator\CollectionHelper; + +use DateTimeImmutable; + +class CheckInboundEmails implements Preparator +{ + /** + * @param CollectionHelper $helper + */ + public function __construct( + private EntityManager $entityManager, + private CollectionHelper $helper + ) {} + + public function prepare(Data $data, DateTimeImmutable $executeTime): void + { + $collection = $this->entityManager + ->getRDBRepositoryByClass(InboundEmail::class) + ->where([ + 'status' => InboundEmail::STATUS_ACTIVE, + 'useImap' => true, + ]) + ->find(); + + $this->helper->prepare($collection, $data, $executeTime); + } +} diff --git a/application/Espo/Classes/Jobs/AuthTokenControl.php b/application/Espo/Classes/Jobs/AuthTokenControl.php new file mode 100644 index 0000000..ad33dd5 --- /dev/null +++ b/application/Espo/Classes/Jobs/AuthTokenControl.php @@ -0,0 +1,134 @@ +. + * + * 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\Jobs; + +use Espo\Entities\AuthToken; +use Espo\Entities\Portal; +use Espo\Core\Job\JobDataLess; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use DateTime; + +/** + * @noinspection PhpUnused + */ +class AuthTokenControl implements JobDataLess +{ + private const LIMIT = 500; + + public function __construct( + private Config $config, + private EntityManager $entityManager + ) {} + + public function run(): void + { + $lifetime = (int) ($this->config->get('authTokenLifetime', 0) * 60); + $maxIdleTime = (int) ($this->config->get('authTokenMaxIdleTime', 0) * 60); + + $portalIds = []; + + /** @var iterable $portals */ + $portals = $this->entityManager + ->getRDBRepositoryByClass(Portal::class) + ->find(); + + foreach ($portals as $portal) { + $portalIds[] = $portal->getId(); + } + + $this->process(null, $lifetime, $maxIdleTime, $portalIds); + + foreach ($portals as $portal) { + $itemLifetime = $portal->get('authTokenLifetime') !== null ? + (int) ($portal->get('authTokenLifetime') * 60) : + $lifetime; + + $itemMaxIdleTime = $portal->get('authTokenMaxIdleTime') !== null ? + (int) ($portal->get('authTokenMaxIdleTime') * 60) : + $maxIdleTime; + + $this->process($portal->getId(), $itemLifetime, $itemMaxIdleTime); + } + } + + /** + * @param string[] $ignorePortalIds + */ + private function process(?string $portalId, int $lifetime, int $maxIdleTime, array $ignorePortalIds = []): void + { + if (!$lifetime && !$maxIdleTime) { + return; + } + + $whereClause = ['isActive' => true]; + + if ($portalId) { + $whereClause['portalId'] = $portalId; + } + + if (!$portalId && $ignorePortalIds !== []) { + $whereClause[] = [ + 'OR' => [ + ['portalId' => null], + ['portalId!=' => $ignorePortalIds], + ] + ]; + } + + if ($lifetime) { + $dt = new DateTime(); + $dt->modify("-$lifetime minutes"); + + $whereClause['createdAt<'] = $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } + + if ($maxIdleTime) { + $dt = new DateTime(); + $dt->modify("-$maxIdleTime minutes"); + + $whereClause['lastAccess<'] = $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } + + $tokenList = $this->entityManager + ->getRDBRepository(AuthToken::ENTITY_TYPE) + ->sth() + ->where($whereClause) + ->limit(0, self::LIMIT) + ->find(); + + foreach ($tokenList as $token) { + $token->set('isActive', false); + + $this->entityManager->saveEntity($token); + } + } +} diff --git a/application/Espo/Classes/Jobs/CheckEmailAccounts.php b/application/Espo/Classes/Jobs/CheckEmailAccounts.php new file mode 100644 index 0000000..c80f16e --- /dev/null +++ b/application/Espo/Classes/Jobs/CheckEmailAccounts.php @@ -0,0 +1,58 @@ +. + * + * 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\Jobs; + +use Espo\Core\Mail\Account\PersonalAccount\Service; +use Espo\Core\Job\Job; +use Espo\Core\Job\Job\Data; + +use RuntimeException; +use Throwable; + +class CheckEmailAccounts implements Job +{ + public function __construct(private Service $service) + {} + + public function run(Data $data): void + { + $targetId = $data->getTargetId(); + + if (!$targetId) { + throw new RuntimeException("No target."); + } + + try { + $this->service->fetch($targetId); + } catch (Throwable $e) { + throw new RuntimeException("CheckEmailAccounts job failed, $targetId.", 0, $e); + } + } +} diff --git a/application/Espo/Classes/Jobs/CheckInboundEmails.php b/application/Espo/Classes/Jobs/CheckInboundEmails.php new file mode 100644 index 0000000..711ef01 --- /dev/null +++ b/application/Espo/Classes/Jobs/CheckInboundEmails.php @@ -0,0 +1,58 @@ +. + * + * 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\Jobs; + +use Espo\Core\Mail\Account\GroupAccount\Service; +use Espo\Core\Job\Job; +use Espo\Core\Job\Job\Data; + +use RuntimeException; +use Throwable; + +class CheckInboundEmails implements Job +{ + public function __construct(private Service $service) + {} + + public function run(Data $data): void + { + $targetId = $data->getTargetId(); + + if (!$targetId) { + throw new RuntimeException("No target."); + } + + try { + $this->service->fetch($targetId); + } catch (Throwable $e) { + throw new RuntimeException("CheckInboundEmails job failed, $targetId.", 0, $e); + } + } +} diff --git a/application/Espo/Classes/Jobs/CheckNewExtensionVersion.php b/application/Espo/Classes/Jobs/CheckNewExtensionVersion.php new file mode 100644 index 0000000..0f77306 --- /dev/null +++ b/application/Espo/Classes/Jobs/CheckNewExtensionVersion.php @@ -0,0 +1,53 @@ +. + * + * 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\Jobs; + +use Espo\Entities\Job; + +class CheckNewExtensionVersion extends CheckNewVersion +{ + public function run(): void + { + if ( + !$this->config->get('adminNotifications') || + !$this->config->get('adminNotificationsNewExtensionVersion') + ) { + return; + } + + $className = \Espo\Tools\AdminNotifications\Jobs\CheckNewExtensionVersion::class; + + $this->entityManager->createEntity(Job::ENTITY_TYPE, [ + 'name' => $className, + 'className' => $className, + 'executeTime' => $this->getRunTime(), + ]); + } +} diff --git a/application/Espo/Classes/Jobs/CheckNewVersion.php b/application/Espo/Classes/Jobs/CheckNewVersion.php new file mode 100644 index 0000000..67076cd --- /dev/null +++ b/application/Espo/Classes/Jobs/CheckNewVersion.php @@ -0,0 +1,93 @@ +. + * + * 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\Jobs; + +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Entities\Job; +use Espo\Core\Job\JobDataLess; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Config; + +use DateTime; +use DateTimeZone; + +class CheckNewVersion implements JobDataLess +{ + public function __construct( + protected Config $config, + protected EntityManager $entityManager, + private Config\ApplicationConfig $applicationConfig, + ) {} + + public function run(): void + { + if ( + !$this->config->get('adminNotifications') || + !$this->config->get('adminNotificationsNewVersion') + ) { + return; + } + + $className = \Espo\Tools\AdminNotifications\Jobs\CheckNewVersion::class; + + /** @todo Job scheduler is not used for bc reasons. */ + $this->entityManager->createEntity(Job::ENTITY_TYPE, [ + 'name' => $className, + 'className' => $className, + 'executeTime' => $this->getRunTime(), + ]); + } + + protected function getRunTime(): string + { + $hour = rand(0, 4); + $minute = rand(0, 59); + + $nextDay = new DateTime('+ 1 day'); + $time = $nextDay->format(DateTimeUtil::SYSTEM_DATE_FORMAT) . ' ' . $hour . ':' . $minute . ':00'; + + $timeZone = $this->applicationConfig->getTimeZone(); + + $datetime = new DateTime($time, new DateTimeZone($timeZone)); + + return $datetime + ->setTimezone(new DateTimeZone('UTC')) + ->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } + + /** + * For backward compatibility. + * @deprecated + */ + protected function getEntityManager() /** @phpstan-ignore-line */ + { + return $this->entityManager; + } +} diff --git a/application/Espo/Classes/Jobs/Cleanup.php b/application/Espo/Classes/Jobs/Cleanup.php new file mode 100644 index 0000000..e6de7d1 --- /dev/null +++ b/application/Espo/Classes/Jobs/Cleanup.php @@ -0,0 +1,796 @@ +. + * + * 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\Jobs; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Job\Job\Status as JobStatus; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Record\ServiceContainer; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Entities\ActionHistoryRecord; +use Espo\Entities\ArrayValue; +use Espo\Entities\Attachment; +use Espo\Entities\AuthLogRecord; +use Espo\Entities\AuthToken; +use Espo\Entities\Job; +use Espo\Entities\Note; +use Espo\Entities\Notification; +use Espo\Entities\ScheduledJob; +use Espo\Entities\ScheduledJobLogRecord; +use Espo\Entities\UniqueId; +use Espo\Entities\UserReaction; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\DeleteBuilder; +use Espo\ORM\Repository\RDBRepository; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\InjectableFactory; +use Espo\Core\Job\JobDataLess; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; + +use DateTime; +use RuntimeException; +use SplFileInfo; +use Exception; +use Throwable; + +class Cleanup implements JobDataLess +{ + private string $cleanupJobPeriod = '10 days'; + private string $cleanupActionHistoryPeriod = '15 days'; + private string $cleanupAuthTokenPeriod = '1 month'; + private string $cleanupAuthLogPeriod = '2 months'; + private string $cleanupNotificationsPeriod = '2 months'; + private string $cleanupAttachmentsPeriod = '15 days'; + private string $cleanupAttachmentsFromPeriod = '6 months'; + private string $cleanupBackupPeriod = '2 month'; + private string $cleanupDeletedRecordsPeriod = '2 months'; + + private const LIMIT = 5000; + private const SCHEDULED_JOB_LOG_LIMIT = 10; + + public function __construct( + private Config $config, + private EntityManager $entityManager, + private Metadata $metadata, + private FileManager $fileManager, + private InjectableFactory $injectableFactory, + private SelectBuilderFactory $selectBuilderFactory, + private ServiceContainer $recordServiceContainer, + private Log $log + ) {} + + public function run(): void + { + $this->cleanupJobs(); + $this->cleanupScheduledJobLog(); + $this->cleanupAttachments(); + $this->cleanupNotifications(); + $this->cleanupActionHistory(); + $this->cleanupAuthToken(); + $this->cleanupAuthLog(); + $this->cleanupUpgradeBackups(); + $this->cleanupUniqueIds(); + $this->cleanupDeletedRecords(); + + $items = $this->metadata->get(['app', 'cleanup']) ?? []; + + usort($items, function ($a, $b) { + $o1 = $a['order'] ?? 0; + $o2 = $b['order'] ?? 0; + + return $o1 <=> $o2; + }); + + $injectableFactory = $this->injectableFactory; + + foreach ($items as $name => $item) { + try { + /** @var class-string<\Espo\Core\Cleanup\Cleanup> $className */ + $className = $item['className']; + + $obj = $injectableFactory->create($className); + + $obj->process(); + } catch (Throwable $e) { + $this->log->error("Cleanup: $name: " . $e->getMessage()); + } + } + } + + private function cleanupJobs(): void + { + $delete = $this->entityManager->getQueryBuilder()->delete() + ->from(Job::ENTITY_TYPE) + ->where([ + 'modifiedAt<' => $this->getCleanupJobFromDate(), + 'status!=' => JobStatus::PENDING, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + + $delete = $this->entityManager->getQueryBuilder()->delete() + ->from(Job::ENTITY_TYPE) + ->where([ + 'modifiedAt<' => $this->getCleanupJobFromDate(), + 'status=' => JobStatus::PENDING, + Attribute::DELETED => true, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + + private function cleanupUniqueIds(): void + { + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(UniqueId::ENTITY_TYPE) + ->where([ + 'terminateAt!=' => null, + 'terminateAt<' => date(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + + private function cleanupScheduledJobLog(): void + { + $scheduledJobList = $this->entityManager + ->getRDBRepositoryByClass(ScheduledJob::class) + ->select([Attribute::ID]) + ->find(); + + foreach ($scheduledJobList as $scheduledJob) { + $scheduledJobId = $scheduledJob->getId(); + + $ignoreLogRecordList = $this->entityManager + ->getRDBRepositoryByClass(ScheduledJobLogRecord::class) + ->select([Attribute::ID]) + ->where([ + 'scheduledJobId' => $scheduledJobId, + ]) + ->order(Field::CREATED_AT, 'DESC') + ->limit(0, self::SCHEDULED_JOB_LOG_LIMIT) + ->find(); + + if (!count($ignoreLogRecordList)) { + continue; + } + + $ignoreIdList = []; + + foreach ($ignoreLogRecordList as $logRecord) { + $ignoreIdList[] = $logRecord->getId(); + } + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(ScheduledJobLogRecord::ENTITY_TYPE) + ->where([ + 'scheduledJobId' => $scheduledJobId, + 'createdAt<' => $this->getCleanupJobFromDate(), + 'id!=' => $ignoreIdList, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + } + + private function cleanupActionHistory(): void + { + $period = '-' . $this->config->get('cleanupActionHistoryPeriod', $this->cleanupActionHistoryPeriod); + + $datetime = $this->createDateTimeFromPeriod($period); + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(ActionHistoryRecord::ENTITY_TYPE) + ->where([ + 'createdAt<' => $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + + private function cleanupAuthToken(): void + { + $period = '-' . $this->config->get('cleanupAuthTokenPeriod', $this->cleanupAuthTokenPeriod); + + $datetime = $this->createDateTimeFromPeriod($period); + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(AuthToken::ENTITY_TYPE) + ->where([ + 'modifiedAt<' => $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + 'isActive' => false, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + + private function cleanupAuthLog(): void + { + $period = '-' . $this->config->get('cleanupAuthLogPeriod', $this->cleanupAuthLogPeriod); + + $datetime = $this->createDateTimeFromPeriod($period); + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(AuthLogRecord::ENTITY_TYPE) + ->where([ + 'createdAt<' => $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + + private function getCleanupJobFromDate(): string + { + $period = '-' . $this->config->get('cleanupJobPeriod', $this->cleanupJobPeriod); + + $datetime = $this->createDateTimeFromPeriod($period); + + return $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } + + private function cleanupAttachments(): void + { + $period = '-' . $this->config->get('cleanupAttachmentsPeriod', $this->cleanupAttachmentsPeriod); + $datetime = $this->createDateTimeFromPeriod($period); + + $this->deleteTemporaryAttachments($datetime); + $this->deleteOrphanAttachments($datetime); + $this->deleteRelatedToDeletedAttachments($datetime); + $this->deleteBeingUploadedAttachments($datetime); + $this->fullDeleteDeletedAttachments($datetime); + } + + private function cleanupNotifications(): void + { + $period = '-' . $this->config->get('cleanupNotificationsPeriod', $this->cleanupNotificationsPeriod); + + $datetime = $this->createDateTimeFromPeriod($period); + + /** @var iterable $notifications */ + $notifications = $this->entityManager + ->getRDBRepository(Notification::ENTITY_TYPE) + ->sth() + ->where(['createdAt<' => $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT)]) + ->find(); + + foreach ($notifications as $notification) { + $this->entityManager + ->getRDBRepository(Notification::ENTITY_TYPE) + ->deleteFromDb($notification->getId()); + } + } + + private function cleanupUpgradeBackups(): void + { + $path = 'data/.backup/upgrades'; + + $datetime = $this->createDateTimeFromPeriod('-' . $this->cleanupBackupPeriod); + + $fileManager = $this->fileManager; + + if ($fileManager->exists($path)) { + /** @var string[] $fileList */ + $fileList = $fileManager->getFileList($path, false, '', false); + + foreach ($fileList as $dirName) { + $dirPath = $path . '/' . $dirName; + + $info = new SplFileInfo($dirPath); + + if ($datetime->getTimestamp() > $info->getMTime()) { + $fileManager->removeInDir($dirPath, true); + } + } + } + } + + private function cleanupDeletedEntity(Entity $entity): void + { + $scope = $entity->getEntityType(); + + if (!$entity->get(Attribute::DELETED)) { + return; + } + + $repository = $this->entityManager->getRepository($scope); + + if ( + !$repository instanceof RDBRepository || + !$entity instanceof CoreEntity + ) { + return; + } + + $repository->deleteFromDb($entity->getId()); + + foreach ($entity->getRelationList() as $relation) { + if ($entity->getRelationType($relation) !== Entity::MANY_MANY) { + continue; + } + + try { + $relationName = $entity->getRelationParam($relation, RelationParam::RELATION_NAME); + + if (!$relationName) { + continue; + } + + $midKey = $entity->getRelationParam($relation, RelationParam::MID_KEYS)[0]; + + if (!$midKey) { + continue; + } + + $where = [ + $midKey => $entity->getId(), + ]; + + $conditions = $entity->getRelationParam($relation, RelationParam::CONDITIONS) ?? []; + + foreach ($conditions as $key => $value) { + $where[$key] = $value; + } + + $relationEntityType = ucfirst($relationName); + + if (!$this->entityManager->hasRepository($relationEntityType)) { + continue; + } + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from($relationEntityType) + ->where($where) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } catch (Exception $e) { + $this->log->error("Cleanup: " . $e->getMessage()); + } + } + + $this->cleanupEntityNotes($entity); + $this->cleanupEntityAttachments($entity); + + if ($scope === Note::ENTITY_TYPE) { + $this->cleanupNoteReactions($entity); + } + + $this->cleanupEntityArrayValues($entity); + } + + private function cleanupDeletedRecords(): void + { + if (!$this->config->get('cleanupDeletedRecords')) { + return; + } + + $period = '-' . $this->config->get('cleanupDeletedRecordsPeriod', $this->cleanupDeletedRecordsPeriod); + + $datetime = $this->createDateTimeFromPeriod($period); + + /** @var string[] $scopeList */ + $scopeList = array_keys($this->metadata->get(['scopes'])); + + foreach ($scopeList as $scope) { + if (!$this->metadata->get(['scopes', $scope, 'entity'])) { + continue; + } + + if ($scope === Attachment::ENTITY_TYPE) { + continue; + } + + if (!$this->entityManager->hasRepository($scope)) { + continue; + } + + $repository = $this->entityManager->getRepository($scope); + + if (!$repository instanceof RDBRepository) { + continue; + } + + $service = $this->recordServiceContainer->get($scope); + + $whereClause = [Attribute::DELETED => true]; + + if ( + !$this->entityManager + ->getDefs() + ->getEntity($scope) + ->hasAttribute(Attribute::DELETED) + ) { + continue; + } + + if ($this->metadata->get(['entityDefs', $scope, 'fields', Field::MODIFIED_AT])) { + $whereClause['modifiedAt<'] = $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } else if ($this->metadata->get(['entityDefs', $scope, 'fields', Field::CREATED_AT])) { + $whereClause['createdAt<'] = $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } + + $query = $this->entityManager + ->getQueryBuilder() + ->select() + ->from($scope) + ->withDeleted() + ->build(); + + $deletedEntityList = $repository + ->clone($query) + ->select([Attribute::ID, Attribute::DELETED]) + ->where($whereClause) + ->find(); + + foreach ($deletedEntityList as $entity) { + if (method_exists($service, 'cleanup')) { + try { + $service->cleanup($entity->getId()); + } catch (Throwable $e) { + $this->log + ->error("Cleanup job: Cleanup scope $scope: " . $e->getMessage(), ['exception' => $e]); + } + } + + $this->cleanupDeletedEntity($entity); + } + } + } + + private function createDateTimeFromPeriod(string $period): DateTime + { + $datetime = new DateTime(); + + try { + $datetime->modify($period); + } catch (Exception $e) { /** @phpstan-ignore-line */ + throw new RuntimeException($e->getMessage(), 0, $e); + } + + return $datetime; + } + + private function cleanupEntityAttachments(CoreEntity $entity): void + { + // @todo Add file, image types support. + + $attachments = $this->entityManager + ->getRDBRepository(Attachment::ENTITY_TYPE) + ->where([ + 'parentId' => $entity->getId(), + 'parentType' => $entity->getEntityType(), + ]) + ->find(); + + foreach ($attachments as $attachment) { + $this->entityManager->removeEntity($attachment); + + $this->entityManager + ->getRDBRepository(Attachment::ENTITY_TYPE) + ->deleteFromDb($attachment->getId()); + } + } + + private function cleanupEntityNotes(CoreEntity $entity): void + { + $scope = $entity->getEntityType(); + + $query = $this->entityManager + ->getQueryBuilder() + ->select() + ->from(Note::ENTITY_TYPE) + ->withDeleted() + ->build(); + + $noteList = $this->entityManager + ->getRDBRepository(Note::ENTITY_TYPE) + ->clone($query) + ->sth() + ->where([ + 'OR' => [ + [ + 'relatedType' => $scope, + 'relatedId' => $entity->getId(), + ], + [ + 'parentType' => $scope, + 'parentId' => $entity->getId(), + ] + ] + ]) + ->find(); + + foreach ($noteList as $note) { + $this->entityManager->removeEntity($note); + + $note->set(Attribute::DELETED, true); + + $this->cleanupDeletedEntity($note); + } + } + + private function cleanupNoteReactions(CoreEntity $entity): void + { + // @todo If ever reactions are supported not only for notes, then move out of the if-block. + + $deleteReactionsQuery = DeleteBuilder::create() + ->from(UserReaction::ENTITY_TYPE) + ->where([ + 'parentId' => $entity->getId(), + 'parentType' => Note::ENTITY_TYPE, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($deleteReactionsQuery); + } + + private function cleanupEntityArrayValues(CoreEntity $entity): void + { + $arrayValues = $this->entityManager + ->getRDBRepository(ArrayValue::ENTITY_TYPE) + ->sth() + ->where([ + 'entityType' => $entity->getEntityType(), + 'entityId' => $entity->getId(), + ]) + ->find(); + + foreach ($arrayValues as $arrayValue) { + $this->entityManager + ->getRDBRepository(ArrayValue::ENTITY_TYPE) + ->deleteFromDb($arrayValue->getId()); + } + } + + private function deleteOrphanAttachments(DateTime $datetime): void + { + if (!$this->config->get('cleanupOrphanAttachments')) { + return; + } + + try { + $orphanQueryBuilder = $this->selectBuilderFactory + ->create() + ->from(Attachment::ENTITY_TYPE) + ->withPrimaryFilter('orphan') + ->buildQueryBuilder(); + } catch (BadRequest|Forbidden $e) { + throw new RuntimeException('', 0, $e); + } + + $orphanQueryBuilder->where([ + 'createdAt<' => $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + 'createdAt>' => '2018-01-01 00:00:00', + ]); + + $collection = $this->entityManager + ->getRDBRepository(Attachment::ENTITY_TYPE) + ->clone($orphanQueryBuilder->build()) + ->sth() + ->limit(0, self::LIMIT) + ->find(); + + foreach ($collection as $entity) { + $this->entityManager->removeEntity($entity); + } + } + + private function deleteTemporaryAttachments(DateTime $datetime): void + { + $collection = $this->entityManager + ->getRDBRepository(Attachment::ENTITY_TYPE) + ->sth() + ->where([ + 'OR' => [ + [ + 'role' => [ + Attachment::ROLE_EXPORT_FILE, + 'Mail Merge', + 'Mass Pdf', + ] + ] + ], + 'createdAt<' => $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + ]) + ->limit(0, self::LIMIT) + ->find(); + + foreach ($collection as $entity) { + $this->entityManager->removeEntity($entity); + } + } + + private function deleteBeingUploadedAttachments(DateTime $datetime): void + { + $isBeingUploadedCollection = $this->entityManager + ->getRDBRepository(Attachment::ENTITY_TYPE) + ->sth() + ->where([ + 'isBeingUploaded' => true, + 'createdAt<' => $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + ]) + ->find(); + + foreach ($isBeingUploadedCollection as $e) { + $this->entityManager->removeEntity($e); + } + } + + private function deleteRelatedToDeletedAttachments(DateTime $datetime): void + { + $fromPeriod = '-' . $this->config->get('cleanupAttachmentsFromPeriod', $this->cleanupAttachmentsFromPeriod); + + $datetimeFrom = $this->createDateTimeFromPeriod($fromPeriod); + + /** @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, 'object']) && $scope !== Note::ENTITY_TYPE) { + continue; + } + + if (!$this->metadata->get(['entityDefs', $scope, 'fields', Field::MODIFIED_AT])) { + continue; + } + + $hasAttachmentField = false; + + if ($scope === Note::ENTITY_TYPE) { + $hasAttachmentField = true; + } + + if (!$hasAttachmentField) { + foreach ($this->metadata->get(['entityDefs', $scope, 'fields']) as $defs) { + if (empty($defs['type'])) { + continue; + } + + if ( + in_array($defs['type'], [ + FieldType::FILE, + FieldType::IMAGE, + FieldType::ATTACHMENT_MULTIPLE, + ]) + ) { + $hasAttachmentField = true; + + break; + } + } + } + + if (!$hasAttachmentField) { + continue; + } + + if (!$this->entityManager->hasRepository($scope)) { + continue; + } + + $repository = $this->entityManager->getRepository($scope); + + if (!method_exists($repository, 'find')) { + continue; + } + + if (!method_exists($repository, 'clone')) { + continue; + } + + $query = $this->entityManager + ->getQueryBuilder() + ->select(['id']) + ->from($scope) + ->withDeleted() + ->where([ + Attribute::DELETED => true, + 'modifiedAt<' => $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + 'modifiedAt>' => $datetimeFrom->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + ]) + ->build(); + + $deletedEntities = $repository + ->clone($query) + ->sth() + ->find(); + + foreach ($deletedEntities as $deletedEntity) { + $attachmentToRemoveList = $this->entityManager + ->getRDBRepository(Attachment::ENTITY_TYPE) + ->sth() + ->where([ + 'OR' => [ + [ + 'relatedType' => $scope, + 'relatedId' => $deletedEntity->getId(), + ], + [ + 'parentType' => $scope, + 'parentId' => $deletedEntity->getId(), + ] + ] + ]) + ->find(); + + foreach ($attachmentToRemoveList as $attachmentToRemove) { + $this->entityManager->removeEntity($attachmentToRemove); + } + } + } + } + + private function fullDeleteDeletedAttachments(DateTime $datetime): void + { + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(Attachment::ENTITY_TYPE) + ->where([ + Attribute::DELETED => true, + Field::CREATED_AT . '<' => $datetime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } +} diff --git a/application/Espo/Classes/Jobs/Dummy.php b/application/Espo/Classes/Jobs/Dummy.php new file mode 100644 index 0000000..f303916 --- /dev/null +++ b/application/Espo/Classes/Jobs/Dummy.php @@ -0,0 +1,37 @@ +. + * + * 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\Jobs; + +use Espo\Core\Job\JobDataLess; + +class Dummy implements JobDataLess +{ + public function run(): void {} +} diff --git a/application/Espo/Classes/Jobs/ProcessWebhookQueue.php b/application/Espo/Classes/Jobs/ProcessWebhookQueue.php new file mode 100644 index 0000000..74edee2 --- /dev/null +++ b/application/Espo/Classes/Jobs/ProcessWebhookQueue.php @@ -0,0 +1,44 @@ +. + * + * 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\Jobs; + +use Espo\Core\Job\JobDataLess; +use Espo\Core\Webhook\Queue; + +class ProcessWebhookQueue implements JobDataLess +{ + public function __construct(private Queue $queue) + {} + + public function run(): void + { + $this->queue->process(); + } +} diff --git a/application/Espo/Classes/Jobs/SendEmailNotifications.php b/application/Espo/Classes/Jobs/SendEmailNotifications.php new file mode 100644 index 0000000..34c4bf0 --- /dev/null +++ b/application/Espo/Classes/Jobs/SendEmailNotifications.php @@ -0,0 +1,49 @@ +. + * + * 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\Jobs; + +use Espo\Core\Job\JobDataLess; + +use Espo\Tools\EmailNotification\Processor; + +class SendEmailNotifications implements JobDataLess +{ + private $processor; + + public function __construct(Processor $processor) + { + $this->processor = $processor; + } + + public function run(): void + { + $this->processor->process(); + } +} diff --git a/application/Espo/Classes/Jobs/SendScheduledEmails.php b/application/Espo/Classes/Jobs/SendScheduledEmails.php new file mode 100644 index 0000000..72ba3e9 --- /dev/null +++ b/application/Espo/Classes/Jobs/SendScheduledEmails.php @@ -0,0 +1,165 @@ +. + * + * 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\Jobs; + +use Espo\Core\Acl\Table; +use Espo\Core\AclManager; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Field\DateTime; +use Espo\Core\Job\JobDataLess; +use Espo\Core\Mail\Exceptions\NoSmtp; +use Espo\Core\Mail\Exceptions\SendingError; +use Espo\Core\Name\Field; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Language; +use Espo\Core\Utils\Log; +use Espo\Entities\Email; +use Espo\Entities\Notification; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\Tools\Email\SendService; +use Exception; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class SendScheduledEmails implements JobDataLess +{ + private const BATCH_COUNT = 10; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + private Log $log, + private SendService $sendService, + private Language $language, + private AclManager $aclManager, + private Config\ApplicationConfig $applicationConfig, + ) {} + + public function run(): void + { + $emails = $this->entityManager + ->getRDBRepositoryByClass(Email::class) + ->where([ + 'status' => Email::STATUS_DRAFT, + 'sendAt!=' => null, + 'sendAt<' => DateTime::createNow()->toString(), + ]) + ->order('sendAt') + ->order(Field::CREATED_AT) + ->limit(0, $this->getPortion()) + ->sth() + ->find(); + + foreach ($emails as $email) { + try { + $this->processEmail($email); + } catch (SendingError|Exception $e) { + $this->log->error("Scheduled email send, {$email->getId()}." . $e->getMessage(), ['exception' => $e]); + + $this->processFail($email); + } + } + } + + private function getPortion(): int + { + return $this->config->get('emailScheduledBatchCount') ?? self::BATCH_COUNT; + } + + + /** + * @throws BadRequest + * @throws Error + * @throws NoSmtp + * @throws SendingError + */ + private function processEmail(Email $email): void + { + $user = $this->getUser($email); + + $this->sendService->send($email, $user); + + $this->entityManager->saveEntity($email); + } + + private function processFail(Email $email): void + { + $email->setSendAt(null); + $this->entityManager->saveEntity($email); + + if (!$email->getCreatedBy()) { + return; + } + + $message = $this->language->translateLabel('couldNotSentScheduledEmail', 'messages', 'Email'); + $message = str_replace('{link}', $this->getEmailLink($email), $message); + + $notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew(); + + $notification + ->setType(Notification::TYPE_MESSAGE) + ->setUserId($email->getCreatedBy()->getId()) + ->setMessage($message); + + $this->entityManager->saveEntity($notification); + } + + private function getEmailLink(Email $email): string + { + return rtrim($this->applicationConfig->getSiteUrl()) . '#Email/view/' . $email->getId(); + } + + private function getUser(Email $email): User + { + if (!$email->getCreatedBy()) { + throw new RuntimeException("No createdBy in email."); + } + + $userId = $email->getCreatedBy()->getId(); + + $user = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->getById($userId); + + if (!$user) { + throw new RuntimeException("User $userId not found."); + } + + if (!$this->aclManager->checkScope($user, Email::ENTITY_TYPE, Table::ACTION_CREATE)) { + throw new RuntimeException("User $userId don't have access to create emails."); + } + + return $user; + } +} diff --git a/application/Espo/Classes/Jobs/SyncCurrencyRates.php b/application/Espo/Classes/Jobs/SyncCurrencyRates.php new file mode 100644 index 0000000..3c46496 --- /dev/null +++ b/application/Espo/Classes/Jobs/SyncCurrencyRates.php @@ -0,0 +1,48 @@ +. + * + * 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\Jobs; + +use Espo\Core\Job\JobDataLess; +use Espo\Tools\Currency\SyncManager; + +/** + * @noinspection PhpUnused + */ +class SyncCurrencyRates implements JobDataLess +{ + public function __construct( + private SyncManager $syncManager, + ) {} + + public function run(): void + { + $this->syncManager->refreshCache(); + } +} diff --git a/application/Espo/Classes/MassAction/Email/MoveToFolder.php b/application/Espo/Classes/MassAction/Email/MoveToFolder.php new file mode 100644 index 0000000..d7effc8 --- /dev/null +++ b/application/Espo/Classes/MassAction/Email/MoveToFolder.php @@ -0,0 +1,128 @@ +. + * + * 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\MassAction\Email; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\MassAction\Data; +use Espo\Core\MassAction\MassAction; +use Espo\Core\MassAction\Params; +use Espo\Core\MassAction\QueryBuilder; +use Espo\Core\MassAction\Result; +use Espo\Entities\Email; +use Espo\Entities\EmailFolder; +use Espo\Entities\GroupEmailFolder; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\Tools\Email\Folder; +use Espo\Tools\Email\InboxService as EmailService; +use Exception; +use RuntimeException; + +class MoveToFolder implements MassAction +{ + public function __construct( + private QueryBuilder $queryBuilder, + private EntityManager $entityManager, + private EmailService $service, + private User $user + ) {} + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function process(Params $params, Data $data): Result + { + $folderId = $data->get('folderId'); + + if (!is_string($folderId)) { + throw new BadRequest("No folder ID."); + } + + if ( + $folderId !== Folder::INBOX && + $folderId !== Folder::ARCHIVE && + !str_starts_with($folderId, 'group:') + ) { + $folder = $this->entityManager + ->getRDBRepositoryByClass(EmailFolder::class) + ->where([ + 'assignedUserId' => $this->user->getId(), + 'id' => $folderId, + ]) + ->findOne(); + + if (!$folder) { + throw new Forbidden("Folder not found."); + } + } + + if ($folderId && str_starts_with($folderId, 'group:')) { + $folder = $this->entityManager + ->getRDBRepositoryByClass(GroupEmailFolder::class) + ->where([Attribute::ID => substr($folderId, 6)]) + ->findOne(); + + if (!$folder) { + throw new Forbidden("Group folder not found."); + } + } + + try { + $query = $this->queryBuilder->build($params); + } catch (BadRequest|Forbidden $e) { + throw new RuntimeException($e->getMessage()); + } + + $collection = $this->entityManager + ->getRDBRepositoryByClass(Email::class) + ->clone($query) + ->sth() + ->select([Attribute::ID]) + ->find(); + + $count = 0; + + foreach ($collection as $email) { + try { + $this->service->moveToFolder($email->getId(), $folderId, $this->user->getId()); + } catch (Exception) { + continue; + } + + $count++; + } + + return new Result($count); + } +} diff --git a/application/Espo/Classes/MassAction/User/MassDelete.php b/application/Espo/Classes/MassAction/User/MassDelete.php new file mode 100644 index 0000000..780b61d --- /dev/null +++ b/application/Espo/Classes/MassAction/User/MassDelete.php @@ -0,0 +1,110 @@ +. + * + * 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\MassAction\User; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\MassAction\Actions\MassDelete as MassDeleteOriginal; +use Espo\Core\MassAction\Data; +use Espo\Core\MassAction\MassAction; +use Espo\Core\MassAction\Params; +use Espo\Core\MassAction\QueryBuilder; +use Espo\Core\MassAction\Result; +use Espo\Core\ORM\EntityManager; + +use Espo\Core\Utils\SystemUser; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; + +/** + * Extended to forbid removal of own and system users. + */ +class MassDelete implements MassAction +{ + public function __construct( + private MassDeleteOriginal $massDeleteOriginal, + private QueryBuilder $queryBuilder, + private EntityManager $entityManager, + private Acl $acl, + private User $user + ) {} + + /** + * @throws Forbidden + * @throws BadRequest + * @throws Error + */ + public function process(Params $params, Data $data): Result + { + $entityType = $params->getEntityType(); + + if (!$this->acl->check($entityType, Acl\Table::ACTION_DELETE)) { + throw new Forbidden("No delete access for '$entityType'."); + } + + if ( + !$params->hasIds() && + $this->acl->getPermissionLevel(Acl\Permission::MASS_UPDATE) !== Acl\Table::LEVEL_YES + ) { + throw new Forbidden("No mass-update permission."); + } + + $query = $this->queryBuilder->build($params); + + $collection = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->clone($query) + ->sth() + ->select([Attribute::ID, 'userName']) + ->find(); + + foreach ($collection as $entity) { + $this->checkEntity($entity); + } + + return $this->massDeleteOriginal->process($params, $data); + } + + /** + * @throws Forbidden + */ + private function checkEntity(User $entity): void + { + if ($entity->getUserName() === SystemUser::NAME) { + throw new Forbidden("Can't delete 'system' user."); + } + + if ($entity->getId() === $this->user->getId()) { + throw new Forbidden("Can't delete own user."); + } + } +} diff --git a/application/Espo/Classes/MassAction/User/MassUpdate.php b/application/Espo/Classes/MassAction/User/MassUpdate.php new file mode 100644 index 0000000..0c72322 --- /dev/null +++ b/application/Espo/Classes/MassAction/User/MassUpdate.php @@ -0,0 +1,190 @@ +. + * + * 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\MassAction\User; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\MassAction\Actions\MassUpdate as MassUpdateOriginal; +use Espo\Core\MassAction\QueryBuilder; +use Espo\Core\MassAction\Params; +use Espo\Core\MassAction\Result; +use Espo\Core\MassAction\Data; +use Espo\Core\MassAction\MassAction; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\DataManager; +use Espo\Core\Acl; +use Espo\Core\Acl\Table; + +use Espo\Core\Exceptions\Forbidden; + +use Espo\Core\Utils\SystemUser; +use Espo\Entities\User; +use Espo\ORM\EntityManager; + +use Espo\ORM\Name\Attribute; +use Espo\Tools\MassUpdate\Data as MassUpdateData; + +class MassUpdate implements MassAction +{ + private const PERMISSION = Acl\Permission::MASS_UPDATE; + + /** @var string[] */ + private array $notAllowedAttributeList = [ + 'type', + 'password', + 'emailAddress', + 'isAdmin', + 'isSuperAdmin', + 'isPortalUser', + ]; + + public function __construct( + private MassUpdateOriginal $massUpdateOriginal, + private QueryBuilder $queryBuilder, + private EntityManager $entityManager, + private Acl $acl, + private User $user, + private FileManager $fileManager, + private DataManager $dataManager + ) {} + + /** + * @throws Forbidden + * @throws BadRequest + */ + public function process(Params $params, Data $data): Result + { + $entityType = $params->getEntityType(); + + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden("Only admin can mass-update users."); + } + + if (!$this->acl->check($entityType, Table::ACTION_EDIT)) { + throw new Forbidden("No edit access for '{$entityType}'."); + } + + if ($this->acl->getPermissionLevel(self::PERMISSION) !== Table::LEVEL_YES) { + throw new Forbidden("No mass-update permission."); + } + + $massUpdateData = MassUpdateData::fromMassActionData($data); + + $this->checkAccess($massUpdateData); + + $query = $this->queryBuilder->build($params); + + $collection = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->clone($query) + ->sth() + ->select([Attribute::ID, 'userName']) + ->find(); + + foreach ($collection as $entity) { + $this->checkEntity($entity, $massUpdateData); + } + + $result = $this->massUpdateOriginal->process($params, $data); + + $this->afterProcess($result, $massUpdateData); + + return $result; + } + + /** + * @throws Forbidden + */ + private function checkAccess(MassUpdateData $data): void + { + foreach ($this->notAllowedAttributeList as $attribute) { + if ($data->has($attribute)) { + throw new Forbidden("Attribute '{$attribute}' not allowed for mass-update."); + } + } + } + + /** + * @throws Forbidden + */ + private function checkEntity(User $entity, MassUpdateData $data): void + { + if ($entity->getUserName() === SystemUser::NAME) { + throw new Forbidden("Can't update 'system' user."); + } + + if ($entity->getId() === $this->user->getId()) { + if ($data->has('isActive')) { + throw new Forbidden("Can't change 'isActive' field for own user."); + } + } + } + + private function afterProcess(Result $result, MassUpdateData $dataWrapped): void + { + $data = $dataWrapped->getValues(); + + if ( + property_exists($data, 'rolesIds') || + property_exists($data, 'teamsIds') || + property_exists($data, 'type') || + property_exists($data, 'portalRolesIds') || + property_exists($data, 'portalsIds') + ) { + foreach ($result->getIds() as $id) { + $this->clearRoleCache($id); + } + + $this->dataManager->updateCacheTimestamp(); + } + + if ( + property_exists($data, 'portalRolesIds') || + property_exists($data, 'portalsIds') || + property_exists($data, 'contactId') || + property_exists($data, 'accountsIds') + ) { + $this->clearPortalRolesCache(); + + $this->dataManager->updateCacheTimestamp(); + } + } + + private function clearRoleCache(string $id): void + { + $part = basename($id); + + $this->fileManager->removeFile("data/cache/application/acl/$part.php"); + } + + private function clearPortalRolesCache(): void + { + $this->fileManager->removeInDir('data/cache/application/aclPortal'); + } +} diff --git a/application/Espo/Classes/Record/AppSecret/ValueInputFilter.php b/application/Espo/Classes/Record/AppSecret/ValueInputFilter.php new file mode 100644 index 0000000..c8eba70 --- /dev/null +++ b/application/Espo/Classes/Record/AppSecret/ValueInputFilter.php @@ -0,0 +1,61 @@ +. + * + * 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\AppSecret; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; +use Espo\Core\Utils\Crypt; + +/** + * @noinspection PhpUnused + */ +class ValueInputFilter implements Filter +{ + private const ATTR_VALUE = 'value'; + + public function __construct(private Crypt $crypt) {} + + /** + * @throws BadRequest + */ + public function filter(Data $data): void + { + $value = $data->get(self::ATTR_VALUE); + + if ($value !== null) { + if (!is_string($value)) { + throw new BadRequest(); + } + + $data->set(self::ATTR_VALUE, $this->crypt->encrypt($value)); + } + } +} diff --git a/application/Espo/Classes/Record/Attachment/CreateInputFilter.php b/application/Espo/Classes/Record/Attachment/CreateInputFilter.php new file mode 100644 index 0000000..28f9350 --- /dev/null +++ b/application/Espo/Classes/Record/Attachment/CreateInputFilter.php @@ -0,0 +1,156 @@ +. + * + * 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\Attachment; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; +use Espo\Entities\Attachment; +use Espo\ORM\EntityManager; +use Espo\Tools\Attachment\AccessChecker; +use Espo\Tools\Attachment\DetailsObtainer; +use Espo\Tools\Attachment\FieldData; + +/** + * @noinspection PhpUnused + */ +class CreateInputFilter implements Filter +{ + public function __construct( + private EntityManager $entityManager, + private AccessChecker $accessChecker, + private DetailsObtainer $detailsObtainer + ) {} + + /** + * @throws BadRequest + * @throws Error + * @throws Forbidden + */ + public function filter(Data $data): void + { + $data->clear('parentId'); + $data->clear('relatedId'); + + $contents = $this->handleContents($data); + + $relatedEntityType = $this->getRelatedEntityType($data); + + $field = $data->get('field'); + $role = $data->get('role') ?? Attachment::ROLE_ATTACHMENT; + + if (!$relatedEntityType || !$field) { + throw new BadRequest("No `field` and `parentType`."); + } + + $fieldData = new FieldData($field, $data->get('parentType'), $data->get('relatedType')); + + $this->accessChecker->check($fieldData, $role); + $this->checkMaxSize($contents, $data, $field, $role); + } + + private function getRelatedEntityType(Data $data): ?string + { + if ($data->get('parentType') !== null) { + $data->clear('relatedType'); + + return $data->get('parentType'); + } + + if ($data->get('relatedType') !== null) { + return $data->get('relatedType'); + } + + return null; + } + + /** + * @throws BadRequest + */ + private function handleContents(Data $data): string + { + $isBeingUploaded = $data->get('isBeingUploaded') ?? false; + + $contents = ''; + + if (!$isBeingUploaded) { + if (!$data->has('file')) { + throw new BadRequest("No file contents."); + } + + $file = $data->get('file'); + + if (!is_string($file)) { + throw new BadRequest("Non-string file contents."); + } + + $arr = explode(',', $file); + + if (count($arr) < 2) { + throw new BadRequest("Bad file contents."); + } + + $contents = base64_decode($arr[1]); + + if ($contents === false) { + throw new BadRequest("Could not decode file contents."); + } + } + + $data->set('contents', $contents); + + return $contents; + } + + /** + * @throws BadRequest + */ + private function checkMaxSize(string $contents, Data $data, mixed $field, mixed $role): void + { + $size = mb_strlen($contents, '8bit'); + + $dummy = $this->entityManager->getRepositoryByClass(Attachment::class)->getNew(); + + $dummy->set([ + 'parentType' => $data->get('parentType'), + 'relatedType' => $data->get('relatedType'), + 'field' => $field, + 'role' => $role, + ]); + + $maxSize = $this->detailsObtainer->getUploadMaxSize($dummy); + + if ($maxSize && $size > $maxSize * 1024 * 1024) { + throw new BadRequest("File size should not exceed $maxSize Mb."); + } + } +} diff --git a/application/Espo/Classes/Record/Attachment/UpdateInputFilter.php b/application/Espo/Classes/Record/Attachment/UpdateInputFilter.php new file mode 100644 index 0000000..8a7b983 --- /dev/null +++ b/application/Espo/Classes/Record/Attachment/UpdateInputFilter.php @@ -0,0 +1,49 @@ +. + * + * 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\Attachment; + +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; + +/** + * @noinspection PhpUnused + */ +class UpdateInputFilter implements Filter +{ + public function filter(Data $data): void + { + $data->clear('parentId'); + $data->clear('parentType'); + $data->clear('relatedId'); + $data->clear('relatedType'); + $data->clear('isBeingUploaded'); + $data->clear('storage'); + } +} diff --git a/application/Espo/Classes/Record/AuthToken/UpdateInputFilter.php b/application/Espo/Classes/Record/AuthToken/UpdateInputFilter.php new file mode 100644 index 0000000..d8e4c2b --- /dev/null +++ b/application/Espo/Classes/Record/AuthToken/UpdateInputFilter.php @@ -0,0 +1,52 @@ +. + * + * 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\AuthToken; + +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; + +/** + * @noinspection PhpUnused + */ +class UpdateInputFilter implements Filter +{ + public function filter(Data $data): void + { + foreach ($data->getAttributeList() as $attribute) { + if ($attribute !== 'isActive') { + $data->clear($attribute); + } + } + + if ($data->get('isActive')) { + $data->clear('isActive'); + } + } +} diff --git a/application/Espo/Classes/Record/Common/Lock/BeforeDeleteValidation.php b/application/Espo/Classes/Record/Common/Lock/BeforeDeleteValidation.php new file mode 100644 index 0000000..f1fb1c0 --- /dev/null +++ b/application/Espo/Classes/Record/Common/Lock/BeforeDeleteValidation.php @@ -0,0 +1,50 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Classes/Record/Common/Lock/BeforeSaveValidation.php b/application/Espo/Classes/Record/Common/Lock/BeforeSaveValidation.php new file mode 100644 index 0000000..d56abfa --- /dev/null +++ b/application/Espo/Classes/Record/Common/Lock/BeforeSaveValidation.php @@ -0,0 +1,52 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Classes/Record/Hooks/Pipeline/AfterDelete.php b/application/Espo/Classes/Record/Hooks/Pipeline/AfterDelete.php new file mode 100644 index 0000000..3d24932 --- /dev/null +++ b/application/Espo/Classes/Record/Hooks/Pipeline/AfterDelete.php @@ -0,0 +1,51 @@ +. + * + * 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\Hooks\Pipeline; + +use Espo\Core\Record\DeleteParams; +use Espo\Core\Record\Hook\DeleteHook; +use Espo\Entities\Pipeline; +use Espo\ORM\Entity; +use Espo\Tools\Pipeline\CacheClearer; + +/** + * @implements DeleteHook + */ +class AfterDelete implements DeleteHook +{ + public function __construct( + private CacheClearer $cacheClearer, + ) {} + + public function process(Entity $entity, DeleteParams $params): void + { + $this->cacheClearer->clear(); + } +} diff --git a/application/Espo/Classes/Record/Hooks/Pipeline/AfterSave.php b/application/Espo/Classes/Record/Hooks/Pipeline/AfterSave.php new file mode 100644 index 0000000..5a6787c --- /dev/null +++ b/application/Espo/Classes/Record/Hooks/Pipeline/AfterSave.php @@ -0,0 +1,50 @@ +. + * + * 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\Hooks\Pipeline; + +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Pipeline; +use Espo\ORM\Entity; +use Espo\Tools\Pipeline\CacheClearer; + +/** + * @implements SaveHook + */ +class AfterSave implements SaveHook +{ + public function __construct( + private CacheClearer $cacheClearer, + ) {} + + public function process(Entity $entity): void + { + $this->cacheClearer->clear(); + } +} diff --git a/application/Espo/Classes/Record/Hooks/Pipeline/BeforeDelete.php b/application/Espo/Classes/Record/Hooks/Pipeline/BeforeDelete.php new file mode 100644 index 0000000..bbbee81 --- /dev/null +++ b/application/Espo/Classes/Record/Hooks/Pipeline/BeforeDelete.php @@ -0,0 +1,70 @@ +. + * + * 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\Hooks\Pipeline; + +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Name\Field; +use Espo\Core\Record\DeleteParams; +use Espo\Core\Record\Hook\DeleteHook; +use Espo\Entities\Pipeline; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements DeleteHook + */ +class BeforeDelete implements DeleteHook +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function process(Entity $entity, DeleteParams $params): void + { + $entityType = $entity->getTargetEntityType(); + + if (!$this->entityManager->hasRepository($entityType)) { + return; + } + + $one = $this->entityManager + ->getRDBRepository($entityType) + ->where([Field::PIPELINE . 'Id' => $entity->getId()]) + ->findOne(); + + if ($one) { + throw Conflict::createWithBody( + 'cannotRemoveUsed', + Body::create()->withMessageTranslation('cannotRemoveUsed') + ); + } + } +} diff --git a/application/Espo/Classes/Record/Hooks/PipelineStage/AfterDelete.php b/application/Espo/Classes/Record/Hooks/PipelineStage/AfterDelete.php new file mode 100644 index 0000000..a8c26f8 --- /dev/null +++ b/application/Espo/Classes/Record/Hooks/PipelineStage/AfterDelete.php @@ -0,0 +1,50 @@ +. + * + * 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\Hooks\PipelineStage; + +use Espo\Core\Record\DeleteParams; +use Espo\Core\Record\Hook\DeleteHook; +use Espo\ORM\Entity; +use Espo\Tools\Pipeline\CacheClearer; + +/** + * @noinspection PhpUnused + */ +class AfterDelete implements DeleteHook +{ + public function __construct( + private CacheClearer $cacheClearer, + ) {} + + public function process(Entity $entity, DeleteParams $params): void + { + $this->cacheClearer->clear(); + } +} diff --git a/application/Espo/Classes/Record/Hooks/PipelineStage/AfterSave.php b/application/Espo/Classes/Record/Hooks/PipelineStage/AfterSave.php new file mode 100644 index 0000000..285decd --- /dev/null +++ b/application/Espo/Classes/Record/Hooks/PipelineStage/AfterSave.php @@ -0,0 +1,46 @@ +. + * + * 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\Hooks\PipelineStage; + +use Espo\Core\Record\Hook\SaveHook; +use Espo\ORM\Entity; +use Espo\Tools\Pipeline\CacheClearer; + +class AfterSave implements SaveHook +{ + public function __construct( + private CacheClearer $cacheClearer, + ) {} + + public function process(Entity $entity): void + { + $this->cacheClearer->clear(); + } +} diff --git a/application/Espo/Classes/Record/Hooks/PipelineStage/BeforeDelete.php b/application/Espo/Classes/Record/Hooks/PipelineStage/BeforeDelete.php new file mode 100644 index 0000000..1d32b2c --- /dev/null +++ b/application/Espo/Classes/Record/Hooks/PipelineStage/BeforeDelete.php @@ -0,0 +1,77 @@ +. + * + * 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\Hooks\PipelineStage; + +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Name\Field; +use Espo\Core\Record\DeleteParams; +use Espo\Core\Record\Hook\DeleteHook; +use Espo\Entities\PipelineStage; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Throwable; + +/** + * @implements DeleteHook + */ +class BeforeDelete implements DeleteHook +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function process(Entity $entity, DeleteParams $params): void + { + try { + $pipeline = $entity->getPipeline(); + } catch (Throwable) { + return; + } + + $entityType = $entity->getPipeline()->getTargetEntityType(); + + if (!$this->entityManager->hasRepository($entityType)) { + return; + } + + $one = $this->entityManager + ->getRDBRepository($entityType) + ->where([Field::PIPELINE_STAGE . 'Id' => $entity->getId()]) + ->findOne(); + + if ($one) { + throw Conflict::createWithBody( + 'cannotRemoveUsed', + Body::create()->withMessageTranslation('cannotRemoveUsed') + ); + } + } +} diff --git a/application/Espo/Classes/Record/InboundEmail/PasswordsInputFilter.php b/application/Espo/Classes/Record/InboundEmail/PasswordsInputFilter.php new file mode 100644 index 0000000..7ffea42 --- /dev/null +++ b/application/Espo/Classes/Record/InboundEmail/PasswordsInputFilter.php @@ -0,0 +1,71 @@ +. + * + * 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\InboundEmail; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; +use Espo\Core\Utils\Crypt; + +/** + * @noinspection PhpUnused + */ +class PasswordsInputFilter implements Filter +{ + public function __construct( + private Crypt $crypt + ) {} + + /** + * @throws BadRequest + */ + public function filter(Data $data): void + { + $password = $data->get('password'); + + if ($password !== null) { + if (!is_string($password)) { + throw new BadRequest(); + } + + $data->set('password', $this->crypt->encrypt($password)); + } + + $smtpPassword = $data->get('smtpPassword'); + + if ($smtpPassword !== null) { + if (!is_string($smtpPassword)) { + throw new BadRequest(); + } + + $data->set('smtpPassword', $this->crypt->encrypt($smtpPassword)); + } + } +} diff --git a/application/Espo/Classes/Record/LeadCapture/InputFilter.php b/application/Espo/Classes/Record/LeadCapture/InputFilter.php new file mode 100644 index 0000000..8b93781 --- /dev/null +++ b/application/Espo/Classes/Record/LeadCapture/InputFilter.php @@ -0,0 +1,54 @@ +. + * + * 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\LeadCapture; + +use Espo\Core\Name\Field; +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; +use Espo\Modules\Crm\Entities\Lead; +use Espo\Tools\Pipeline\MetadataProvider; + +/** + * @noinspection PhpUnused + */ +class InputFilter implements Filter +{ + public function __construct( + private MetadataProvider $metadataProvider, + ) {} + + public function filter(Data $data): void + { + if (!$this->metadataProvider->isEnabled(Lead::ENTITY_TYPE)) { + $data->clear(Field::PIPELINE . 'Id'); + $data->clear(Field::PIPELINE . 'Name'); + } + } +} diff --git a/application/Espo/Classes/Record/Note/OutputFilter.php b/application/Espo/Classes/Record/Note/OutputFilter.php new file mode 100644 index 0000000..3a036f5 --- /dev/null +++ b/application/Espo/Classes/Record/Note/OutputFilter.php @@ -0,0 +1,52 @@ +. + * + * 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\Note; + +use Espo\Core\Record\Output\Filter; +use Espo\Entities\Note; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Tools\Stream\NoteAccessControl; + +/** + * @implements Filter + */ +class OutputFilter implements Filter +{ + public function __construct( + private NoteAccessControl $noteAccessControl, + private User $user, + ) {} + + public function filter(Entity $entity): void + { + $this->noteAccessControl->apply($entity, $this->user); + } +} diff --git a/application/Espo/Classes/Record/Note/UpdateInputFilter.php b/application/Espo/Classes/Record/Note/UpdateInputFilter.php new file mode 100644 index 0000000..a65992e --- /dev/null +++ b/application/Espo/Classes/Record/Note/UpdateInputFilter.php @@ -0,0 +1,50 @@ +. + * + * 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\Note; + +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; + +/** + * @noinspection PhpUnused + */ +class UpdateInputFilter implements Filter +{ + public function filter(Data $data): void + { + $data->clear('parentId'); + $data->clear('parentType'); + $data->clear('targetType'); + $data->clear('usersIds'); + $data->clear('teamsIds'); + $data->clear('portalsIds'); + $data->clear('isGlobal'); + } +} diff --git a/application/Espo/Classes/Record/OAuthProvider/GeneralFilter.php b/application/Espo/Classes/Record/OAuthProvider/GeneralFilter.php new file mode 100644 index 0000000..ea73113 --- /dev/null +++ b/application/Espo/Classes/Record/OAuthProvider/GeneralFilter.php @@ -0,0 +1,71 @@ +. + * + * 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\OAuthProvider; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; +use Espo\Core\Utils\Crypt; + +/** + * @noinspection PhpUnused + */ +class GeneralFilter implements Filter +{ + private const ATTR_CLIENT_SECRET = 'clientSecret'; + + public function __construct(private Crypt $crypt) {} + + /** + * @throws BadRequest + */ + public function filter(Data $data): void + { + $this->processClientSecret($data); + } + + /** + * @throws BadRequest + */ + private function processClientSecret(Data $data): void + { + $value = $data->get(self::ATTR_CLIENT_SECRET); + + if ($value === null) { + return; + } + + if (!is_string($value)) { + throw new BadRequest(); + } + + $data->set(self::ATTR_CLIENT_SECRET, $this->crypt->encrypt($value)); + } +} diff --git a/application/Espo/Classes/Record/Portal/InputFilter.php b/application/Espo/Classes/Record/Portal/InputFilter.php new file mode 100644 index 0000000..f73f1f5 --- /dev/null +++ b/application/Espo/Classes/Record/Portal/InputFilter.php @@ -0,0 +1,59 @@ +. + * + * 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\Portal; + +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; +use Espo\Core\Utils\Config; +use Espo\Entities\User; + +/** + * @noinspection PhpUnused + */ +class InputFilter implements Filter +{ + public function __construct( + private User $user, + private Config $config + ) {} + + public function filter(Data $data): void + { + if (!$this->config->get('restrictedMode')) { + return; + } + + if ($this->user->isSuperAdmin()) { + return; + } + + $data->clear('customUrl'); + } +} diff --git a/application/Espo/Classes/Record/User/DeletedRestorer.php b/application/Espo/Classes/Record/User/DeletedRestorer.php new file mode 100644 index 0000000..50250c6 --- /dev/null +++ b/application/Espo/Classes/Record/User/DeletedRestorer.php @@ -0,0 +1,69 @@ +. + * + * 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\User; + +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Record\Deleted\DefaultRestorer; +use Espo\Core\Record\Deleted\Restorer; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Tools\User\UserUtil; + +/** + * @implements Restorer + */ +class DeletedRestorer implements Restorer +{ + public function __construct( + private DefaultRestorer $defaultRestorer, + private UserUtil $userUtil, + ) {} + + + /** + * @inheritDoc + */ + public function restore(Entity $entity): void + { + $this->processUserExistsChecking($entity); + + $this->defaultRestorer->restore($entity); + } + + /** + * @throws Conflict + */ + private function processUserExistsChecking(User $user): void + { + if ($this->userUtil->checkExists($user)) { + throw new Conflict('userNameExists'); + } + } +} diff --git a/application/Espo/Classes/Record/User/InputFilter.php b/application/Espo/Classes/Record/User/InputFilter.php new file mode 100644 index 0000000..b96ca15 --- /dev/null +++ b/application/Espo/Classes/Record/User/InputFilter.php @@ -0,0 +1,58 @@ +. + * + * 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\User; + +use Espo\Core\Acl; +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; +use Espo\Entities\Team as TeamEntity; +use Espo\Entities\User; + +/** + * @noinspection PhpUnused + */ +class InputFilter implements Filter +{ + public function __construct( + private User $user, + private Acl $acl, + ) {} + + public function filter(Data $data): void + { + if (!$this->user->isSuperAdmin()) { + $data->clear('isSuperAdmin'); + } + + if (!$this->user->isEffectiveAdmin() && !$this->acl->checkScope(TeamEntity::ENTITY_TYPE)) { + $data->clear('defaultTeamId'); + } + } +} diff --git a/application/Espo/Classes/Record/User/OutputFilter.php b/application/Espo/Classes/Record/User/OutputFilter.php new file mode 100644 index 0000000..76411ed --- /dev/null +++ b/application/Espo/Classes/Record/User/OutputFilter.php @@ -0,0 +1,74 @@ +. + * + * 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\User; + +use Espo\Core\Authentication\Logins\Hmac; +use Espo\Core\Record\Output\Filter; +use Espo\Core\Utils\ApiKey; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements Filter + */ +class OutputFilter implements Filter +{ + public function __construct( + private User $user, + private ApiKey $apiKey + ) {} + + public function filter(Entity $entity): void + { + $entity->clear('sendAccessInfo'); + + $this->filterApiUser($entity); + } + + private function filterApiUser(User $entity): void + { + if (!$entity->isApi()) { + return; + } + + if ($this->user->isEffectiveAdmin()) { + if ($entity->getAuthMethod() === Hmac::NAME) { + $secretKey = $this->apiKey->getSecretKeyForUserId($entity->getId()); + + $entity->set('secretKey', $secretKey); + } + + return; + } + + $entity->clear('apiKey'); + $entity->clear('secretKey'); + } +} diff --git a/application/Espo/Classes/Record/Webhook/DefaultsPopulator.php b/application/Espo/Classes/Record/Webhook/DefaultsPopulator.php new file mode 100644 index 0000000..f139bfa --- /dev/null +++ b/application/Espo/Classes/Record/Webhook/DefaultsPopulator.php @@ -0,0 +1,56 @@ +. + * + * 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\Webhook; + +use Espo\Core\Record\Defaults\DefaultPopulator; +use Espo\Core\Record\Defaults\Populator; +use Espo\Entities\User; +use Espo\Entities\Webhook; +use Espo\ORM\Entity; + +/** + * @implements Populator + */ +class DefaultsPopulator implements Populator +{ + public function __construct( + private DefaultPopulator $defaultsDefaultsPopulator, + private User $user + ) {} + + public function populate(Entity $entity): void + { + $this->defaultsDefaultsPopulator->populate($entity); + + if ($this->user->isApi()) { + $entity->set('userId', $this->user->getId()); + } + } +} diff --git a/application/Espo/Classes/Record/Webhook/UpdateInputFilter.php b/application/Espo/Classes/Record/Webhook/UpdateInputFilter.php new file mode 100644 index 0000000..76ba1ad --- /dev/null +++ b/application/Espo/Classes/Record/Webhook/UpdateInputFilter.php @@ -0,0 +1,51 @@ +. + * + * 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\Webhook; + +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; +use Espo\Entities\User; + +/** + * @noinspection PhpUnused + */ +class UpdateInputFilter implements Filter +{ + public function __construct( + private User $user + ) {} + + public function filter(Data $data): void + { + if (!$this->user->isEffectiveAdmin()) { + $data->clear('event'); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/AddressCountry/BeforeSave.php b/application/Espo/Classes/RecordHooks/AddressCountry/BeforeSave.php new file mode 100644 index 0000000..fbefee7 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/AddressCountry/BeforeSave.php @@ -0,0 +1,70 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\AddressCountry; + +use Espo\Core\Exceptions\ConflictSilent; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\AddressCountry; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements SaveHook + */ +class BeforeSave implements SaveHook +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function process(Entity $entity): void + { + $where = ['name' => $entity->getName()]; + + if (!$entity->isNew()) { + $where['id!='] = $entity->getId(); + } + + $one = $this->entityManager + ->getRDBRepositoryByClass(AddressCountry::class) + ->where($where) + ->findOne(); + + if (!$one) { + return; + } + + throw ConflictSilent::createWithBody( + 'duplicateError', + Body::create()->withMessageTranslation('duplicateConflict') + ); + } +} diff --git a/application/Espo/Classes/RecordHooks/Attachment/AfterCreate.php b/application/Espo/Classes/RecordHooks/Attachment/AfterCreate.php new file mode 100644 index 0000000..74f8f02 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Attachment/AfterCreate.php @@ -0,0 +1,45 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Attachment; + +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Attachment; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class AfterCreate implements SaveHook +{ + public function process(Entity $entity): void + { + $entity->clear('contents'); + } +} diff --git a/application/Espo/Classes/RecordHooks/Attachment/BeforeCreate.php b/application/Espo/Classes/RecordHooks/Attachment/BeforeCreate.php new file mode 100644 index 0000000..64bfc84 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Attachment/BeforeCreate.php @@ -0,0 +1,100 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Attachment; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Attachment; +use Espo\ORM\Entity; +use Espo\Tools\Attachment\Checker; +use Espo\Tools\Attachment\DetailsObtainer; + +/** + * @implements SaveHook + */ +class BeforeCreate implements SaveHook +{ + public function __construct( + private Config $config, + private Metadata $metadata, + private DetailsObtainer $detailsObtainer, + private Checker $checker + ) {} + + public function process(Entity $entity): void + { + $this->processStorage($entity); + $this->processRole($entity); + $this->processSize($entity); + + $this->checker->checkType($entity); + } + + private function processStorage(Attachment $entity): void + { + $storage = $entity->getStorage(); + + $availableStorageList = $this->config->get('attachmentAvailableStorageList') ?? []; + + if ( + $storage && + ( + !in_array($storage, $availableStorageList) || + !$this->metadata->get(['app', 'fileStorage', 'implementationClassNameMap', $storage]) + ) + ) { + $entity->clear('storage'); + } + } + + /** + * @throws Forbidden + */ + private function processSize(Attachment $entity): void + { + $size = $entity->getSize(); + + $maxSize = $this->detailsObtainer->getUploadMaxSize($entity); + + // Checking not actual file size but a set value. + if ($size && $size > $maxSize) { + throw new Forbidden("Attachment size exceeds `attachmentUploadMaxSize`."); + } + } + + private function processRole(Attachment $entity): void + { + if (!$entity->getRole()) { + $entity->setRole(Attachment::ROLE_ATTACHMENT); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php new file mode 100644 index 0000000..267df75 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php @@ -0,0 +1,66 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\CurrencyRecordRate; + +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Record\DeleteParams; +use Espo\Core\Record\Hook\DeleteHook; +use Espo\Core\Utils\Currency\DatabasePopulator; +use Espo\Core\WebSocket\Submission; +use Espo\Entities\CurrencyRecordRate; +use Espo\ORM\Entity; +use Espo\Tools\Currency\Exceptions\NotEnabled; +use Espo\Tools\Currency\SyncManager; + +/** + * @implements DeleteHook + */ +class AfterDelete implements DeleteHook +{ + public function __construct( + private SyncManager $syncManager, + private Submission $submission, + private DatabasePopulator $databasePopulator, + ) {} + + public function process(Entity $entity, DeleteParams $params): void + { + $code = $entity->getRecord()->getCode(); + + try { + $this->syncManager->updateCode($code); + } catch (NotEnabled $e) { + throw new Conflict($e->getMessage(), previous: $e); + } + + $this->databasePopulator->process(); + $this->submission->submit('appParamsUpdate'); + } +} diff --git a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php new file mode 100644 index 0000000..5b4bf16 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php @@ -0,0 +1,65 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\CurrencyRecordRate; + +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Currency\DatabasePopulator; +use Espo\Core\WebSocket\Submission; +use Espo\Entities\CurrencyRecordRate; +use Espo\ORM\Entity; +use Espo\Tools\Currency\Exceptions\NotEnabled; +use Espo\Tools\Currency\SyncManager; + +/** + * @implements SaveHook + */ +class AfterSave implements SaveHook +{ + public function __construct( + private SyncManager $syncManager, + private Submission $submission, + private DatabasePopulator $databasePopulator, + ) {} + + public function process(Entity $entity): void + { + $code = $entity->getRecord()->getCode(); + + try { + $this->syncManager->updateCode($code); + } catch (NotEnabled $e) { + throw new Conflict($e->getMessage(), previous: $e); + } + + $this->databasePopulator->process(); + $this->submission->submit('appParamsUpdate'); + } +} diff --git a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/BeforeSaveValidation.php b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/BeforeSaveValidation.php new file mode 100644 index 0000000..a50cdb4 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/BeforeSaveValidation.php @@ -0,0 +1,80 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\CurrencyRecordRate; + +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\CurrencyRecordRate; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements SaveHook + */ +class BeforeSaveValidation implements SaveHook +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function process(Entity $entity): void + { + $this->validateDate($entity); + } + + /** + * @throws Conflict + */ + private function validateDate(CurrencyRecordRate $entity): void + { + if (!$entity->isNew()) { + return; + } + + $recordId = $entity->getRecord()->getId(); + $date = $entity->getDate(); + + $one = $this->entityManager + ->getRDBRepositoryByClass(CurrencyRecordRate::class) + ->where([ + CurrencyRecordRate::ATTR_RECORD_ID => $recordId, + CurrencyRecordRate::FIELD_DATE => $date->toString(), + ]) + ->findOne(); + + if ($one) { + throw Conflict::createWithBody( + 'rateOnDateAlreadyExists', + Body::create()->withMessageTranslation('rateOnDateAlreadyExists', 'Currency') + ); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/Email/AfterUpdate.php b/application/Espo/Classes/RecordHooks/Email/AfterUpdate.php new file mode 100644 index 0000000..a6a01ac --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Email/AfterUpdate.php @@ -0,0 +1,64 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Email; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Mail\Exceptions\NoSmtp; +use Espo\Core\Mail\Exceptions\SendingError; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Tools\Email\SendService; + +/** + * @implements SaveHook + */ +class AfterUpdate implements SaveHook +{ + public function __construct( + private User $user, + private SendService $sendService + ) {} + + /** + * @throws BadRequest + * @throws Error + * @throws NoSmtp + * @throws SendingError + */ + public function process(Entity $entity): void + { + if ($entity->getStatus() === Email::STATUS_SENDING) { + $this->sendService->send($entity, $this->user); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/Email/BeforeCreate.php b/application/Espo/Classes/RecordHooks/Email/BeforeCreate.php new file mode 100644 index 0000000..aa6690d --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Email/BeforeCreate.php @@ -0,0 +1,50 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Email; + +use Espo\Core\Mail\EmailSender; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Email; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class BeforeCreate implements SaveHook +{ + public function process(Entity $entity): void + { + if ($entity->getStatus() === Email::STATUS_SENDING) { + $messageId = EmailSender::generateMessageId($entity); + + $entity->setMessageId('<' . $messageId . '>'); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/Email/BeforeSave.php b/application/Espo/Classes/RecordHooks/Email/BeforeSave.php new file mode 100644 index 0000000..d4fa21b --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Email/BeforeSave.php @@ -0,0 +1,76 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Email; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Email; +use Espo\ORM\Entity; +use Espo\Tools\Email\Util; + +/** + * @implements SaveHook + */ +class BeforeSave implements SaveHook +{ + public function process(Entity $entity): void + { + if ( + $entity->getStatus() !== Email::STATUS_DRAFT && + $entity->getSendAt() && + $entity->isAttributeChanged('sendAt') + ) { + throw new BadRequest("Cannot set send-at if status is not Draft."); + } + + $this->processBodyPlain($entity); + } + + private function processBodyPlain(Email $entity): void + { + if (!$entity->isAttributeChanged('body')) { + return; + } + + $body = $entity->getBody() ?: null; + + if (!$entity->isHtml()) { + $entity->setBodyPlain($body); + + return; + } + + if ($body) { + $body = Util::stripHtml($body) ?: null; + } + + $entity->setBodyPlain($body); + } +} diff --git a/application/Espo/Classes/RecordHooks/Email/BeforeUpdate.php b/application/Espo/Classes/RecordHooks/Email/BeforeUpdate.php new file mode 100644 index 0000000..d2048f2 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Email/BeforeUpdate.php @@ -0,0 +1,162 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Email; + +use Espo\Core\Mail\EmailSender; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\FieldUtil; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\SystemUser; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements SaveHook + */ +class BeforeUpdate implements SaveHook +{ + /** @var string[] */ + private $allowedForUpdateFieldList = [ + Field::PARENT, + Field::TEAMS, + Field::ASSIGNED_USER, + ]; + + public function __construct( + private User $user, + private EntityManager $entityManager, + private FieldUtil $fieldUtil, + private Metadata $metadata, + ) {} + + public function process(Entity $entity): void + { + $skipFilter = false; + + if ($this->user->isEffectiveAdmin()) { + $skipFilter = true; + } + + if ($this->isEmailManuallyArchived($entity)) { + $skipFilter = true; + } else if ($entity->isAttributeChanged('dateSent')) { + $entity->set('dateSent', $entity->getFetched('dateSent')); + } + + if ($entity->getStatus() === Email::STATUS_DRAFT) { + $skipFilter = true; + } + + if ( + $entity->getStatus() === Email::STATUS_SENDING && + $entity->getFetched('status') === Email::STATUS_DRAFT + ) { + $skipFilter = true; + } + + if ( + $entity->isAttributeChanged('status') && + $entity->getFetched('status') === Email::STATUS_ARCHIVED + ) { + $entity->setStatus(Email::STATUS_ARCHIVED); + } + + if (!$skipFilter) { + $this->clearEntityForUpdate($entity); + } + + if ($entity->getStatus() == Email::STATUS_SENDING) { + $messageId = EmailSender::generateMessageId($entity); + + $entity->setMessageId('<' . $messageId . '>'); + } + } + + private function isEmailManuallyArchived(Email $email): bool + { + if ($email->getStatus() !== Email::STATUS_ARCHIVED) { + return false; + } + + $userId = $email->getCreatedBy()?->getId(); + + if (!$userId) { + return false; + } + + $user = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->getById($userId); + + if (!$user) { + return true; + } + + return $user->getUserName() !== SystemUser::NAME; + } + + private function clearEntityForUpdate(Email $email): void + { + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity(Email::ENTITY_TYPE); + + foreach ($entityDefs->getFieldList() as $fieldDefs) { + $field = $fieldDefs->getName(); + + if ($fieldDefs->getParam('isCustom')) { + continue; + } + + if ( + $fieldDefs->getType() === FieldType::LINK_MULTIPLE && + $this->metadata->get("entityDefs.Email.links.$field.isCustom") + ) { + continue; + } + + if (in_array($field, $this->allowedForUpdateFieldList)) { + continue; + } + + $attributeList = $this->fieldUtil->getAttributeList(Email::ENTITY_TYPE, $field); + + foreach ($attributeList as $attribute) { + if ($email->isAttributeChanged($attribute) && $email->isAttributeWritten($attribute)) { + $email->set($attribute, $email->getFetched($attribute)); + } + } + } + } +} diff --git a/application/Espo/Classes/RecordHooks/Email/CheckFromAddress.php b/application/Espo/Classes/RecordHooks/Email/CheckFromAddress.php new file mode 100644 index 0000000..41a0cdf --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Email/CheckFromAddress.php @@ -0,0 +1,100 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Email; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Mail\Account\SendingAccountProvider; +use Espo\Core\Mail\ConfigDataProvider; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class CheckFromAddress implements SaveHook +{ + public function __construct( + private User $user, + private SendingAccountProvider $sendingAccountProvider, + private Acl $acl, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Entity $entity): void + { + if ($this->user->isEffectiveAdmin()) { + return; + } + + $fromAddress = $entity->getFromAddress(); + + // Should be after 'getFromAddress'. + if (!$entity->isAttributeChanged('from')) { + return; + } + + if (!$fromAddress) { + throw new BadRequest("No 'from' address"); + } + + if ($this->acl->checkScope('Import')) { + return; + } + + $fromAddress = strtolower($fromAddress); + + foreach ($this->user->getEmailAddressGroup()->getAddressList() as $address) { + if ($fromAddress === strtolower($address)) { + return; + } + } + + if ($this->sendingAccountProvider->getShared($this->user, $fromAddress)) { + return; + } + + $system = $this->sendingAccountProvider->getSystem(); + + if ( + $system && + $this->configDataProvider->isSystemOutboundAddressShared() && + $system->getEmailAddress() && + $fromAddress === strtolower($system->getEmailAddress()) + ) { + return; + } + + throw new Forbidden("Not allowed 'from' address."); + } +} diff --git a/application/Espo/Classes/RecordHooks/Email/MarkAsRead.php b/application/Espo/Classes/RecordHooks/Email/MarkAsRead.php new file mode 100644 index 0000000..a260cf8 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Email/MarkAsRead.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Email; + +use Espo\Core\Record\Hook\ReadHook; +use Espo\Core\Record\ReadParams; +use Espo\Entities\Email; +use Espo\ORM\Entity; +use Espo\Tools\Email\InboxService; + +/** + * @implements ReadHook + */ +class MarkAsRead implements ReadHook +{ + public function __construct( + private InboxService $inboxService + ) {} + + public function process(Entity $entity, ReadParams $params): void + { + if ($entity->isRead()) { + return; + } + + $this->inboxService->markAsRead($entity->getId()); + } +} diff --git a/application/Espo/Classes/RecordHooks/Email/MarkAsReadBeforeUpdate.php b/application/Espo/Classes/RecordHooks/Email/MarkAsReadBeforeUpdate.php new file mode 100644 index 0000000..5f2eed7 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Email/MarkAsReadBeforeUpdate.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Email; + +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Email; +use Espo\ORM\Entity; +use Espo\Tools\Email\InboxService; + +/** + * @implements SaveHook + */ +class MarkAsReadBeforeUpdate implements SaveHook +{ + public function __construct( + private InboxService $inboxService + ) {} + + public function process(Entity $entity): void + { + if ($entity->isRead()) { + return; + } + + $this->inboxService->markAsRead($entity->getId()); + } +} diff --git a/application/Espo/Classes/RecordHooks/EmailAccount/BeforeCreate.php b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeCreate.php new file mode 100644 index 0000000..ae9a4d8 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeCreate.php @@ -0,0 +1,69 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\EmailAccount; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Config; +use Espo\Entities\EmailAccount; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use const PHP_INT_MAX; + +/** + * @implements SaveHook + */ +class BeforeCreate implements SaveHook +{ + public function __construct( + private User $user, + private Config $config, + private EntityManager $entityManager + ) {} + + public function process(Entity $entity): void + { + if ($this->user->isEffectiveAdmin()) { + return; + } + + $entity->set('assignedUserId', $this->user->getId()); + + $count = $this->entityManager + ->getRDBRepository(EmailAccount::ENTITY_TYPE) + ->where(['assignedUserId' => $this->user->getId()]) + ->count(); + + if ($count >= $this->config->get('maxEmailAccountCount', PHP_INT_MAX)) { + throw new Forbidden("Email Account number for user limit exceeded."); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSave.php b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSave.php new file mode 100644 index 0000000..b9faa8e --- /dev/null +++ b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSave.php @@ -0,0 +1,99 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\EmailAccount; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\EmailAccount; +use Espo\Entities\EmailFolder; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use RuntimeException; +use stdClass; + +/** + * @implements SaveHook + */ +class BeforeSave implements SaveHook +{ + public function __construct( + private EntityManager $entityManager, + private Acl $acl, + ) {} + + public function process(Entity $entity): void + { + $this->checkFolderMap($entity); + } + + /** + * @throws Forbidden + */ + private function checkFolderMap(EmailAccount $entity): void + { + if (!$entity->isAttributeChanged('folderMap')) { + return; + } + + $map = $entity->get('folderMap'); + + if ($map === null) { + return; + } + + if (!$map instanceof stdClass) { + throw new RuntimeException("Bad folderMap."); + } + + foreach (get_object_vars($map) as $folderId) { + if ($folderId === null) { + continue; + } + + if (!is_string($folderId)) { + throw new RuntimeException("Bad folderMap item."); + } + + $folderEntry = $this->entityManager->getRepositoryByClass(EmailFolder::class)->getById($folderId); + + if (!$folderEntry) { + throw Forbidden::createWithBody('noFolder', + Body::create()->withMessageTranslation('noFolder', EmailAccount::ENTITY_TYPE) + ); + } + + if (!$this->acl->checkEntityRead($folderEntry)) { + throw new Forbidden("No access to mapped folder $folderId."); + } + } + } +} diff --git a/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSaveValidateHosts.php b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSaveValidateHosts.php new file mode 100644 index 0000000..30dc775 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSaveValidateHosts.php @@ -0,0 +1,124 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\EmailAccount; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Security\HostCheck; +use Espo\Entities\EmailAccount; +use Espo\Entities\InboundEmail; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class BeforeSaveValidateHosts implements SaveHook +{ + public function __construct( + private Config $config, + private HostCheck $hostCheck, + ) {} + + public function process(Entity $entity): void + { + if ($entity->isAttributeChanged('host') || $entity->isAttributeChanged('port')) { + $this->validateImap($entity); + } + + if ($entity->isAttributeChanged('smtpHost') || $entity->isAttributeChanged('smtpPort')) { + $this->validateSmtp($entity); + } + } + + /** + * @throws Forbidden + */ + private function validateImap(EmailAccount|InboundEmail $entity): void + { + $host = $entity->getHost(); + $port = $entity->getPort(); + + if ($host === null || $port === null) { + return; + } + + $address = $host . ':' . $port; + + if (in_array($address, $this->getAllowedAddressList())) { + return; + } + + if (!$this->hostCheck->isHostAndNotInternal($host)) { + $message = $this->composeErrorMessage($host, $address); + + throw new Forbidden($message); + } + } + + /** + * @throws Forbidden + */ + private function validateSmtp(EmailAccount|InboundEmail $entity): void + { + $host = $entity->getSmtpHost(); + $port = $entity->getSmtpPort(); + + if ($host === null || $port === null) { + return; + } + + $address = $host . ':' . $port; + + if (in_array($address, $this->getAllowedAddressList())) { + return; + } + + if (!$this->hostCheck->isHostAndNotInternal($host)) { + $message = $this->composeErrorMessage($host, $address); + + throw new Forbidden($message); + } + } + + /** + * @return string[] + */ + private function getAllowedAddressList(): array + { + return $this->config->get('emailServerAllowedAddressList') ?? []; + } + + private function composeErrorMessage(string $host, string $address): string + { + return "Host '$host' is not allowed as it's internal. " . + "To allow, add `$address` to the config parameter `emailServerAllowedAddressList`."; + } +} diff --git a/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php b/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php new file mode 100644 index 0000000..c19df66 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/EmailFilter/BeforeSave.php @@ -0,0 +1,125 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\EmailFilter; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\EmailAccount as EmailAccountEntity; +use Espo\Entities\EmailFilter; +use Espo\Entities\InboundEmail as InboundEmailEntity; +use Espo\Entities\User as UserEntity; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class BeforeSave implements SaveHook +{ + public function __construct( + private Acl $acl + ) {} + + /** + * @inheritDoc + */ + public function process(Entity $entity): void + { + // Check if own. + if ($entity->isNew() && !$this->acl->checkEntityEdit($entity)) { + throw new Forbidden(); + } + + $this->controlEntityValues($entity); + } + + /** + * @throws Forbidden + */ + private function controlEntityValues(EmailFilter $entity): void + { + if ($entity->isGlobal()) { + $entity->setMultiple([ + 'parentType' => null, + 'parentId' => null, + ]); + + if ($entity->getAction() !== EmailFilter::ACTION_SKIP) { + throw new Forbidden("Not allowed `action`."); + } + } + + if ($entity->getParentType() && !$entity->getParentId()) { + throw new Forbidden("Not allowed `parentId` value."); + } + + if ( + $entity->getParentType() === UserEntity::ENTITY_TYPE && + !in_array( + $entity->getAction(), + [ + EmailFilter::ACTION_NONE, + EmailFilter::ACTION_SKIP, + EmailFilter::ACTION_MOVE_TO_FOLDER, + ] + ) + ) { + throw new Forbidden("Not allowed `action`."); + } + + if ( + $entity->getParentType() === InboundEmailEntity::ENTITY_TYPE && + !in_array( + $entity->getAction(), + [ + EmailFilter::ACTION_SKIP, + EmailFilter::ACTION_MOVE_TO_GROUP_FOLDER, + ] + ) + ) { + throw new Forbidden("Not allowed `action`."); + } + + if ( + $entity->getParentType() === EmailAccountEntity::ENTITY_TYPE && + $entity->getAction() !== EmailFilter::ACTION_SKIP + ) { + throw new Forbidden("Not allowed `action`."); + } + + if ($entity->getAction() !== EmailFilter::ACTION_MOVE_TO_FOLDER) { + $entity->set('emailFolderId', null); + } + + if ($entity->getAction() !== EmailFilter::ACTION_MOVE_TO_GROUP_FOLDER) { + $entity->set('groupEmailFolderId', null); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/EmailFolder/BeforeCreate.php b/application/Espo/Classes/RecordHooks/EmailFolder/BeforeCreate.php new file mode 100644 index 0000000..eb59b19 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/EmailFolder/BeforeCreate.php @@ -0,0 +1,59 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\EmailFolder; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\EmailFolder; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class BeforeCreate implements SaveHook +{ + public function __construct( + private User $user, + private Acl $acl + ) {} + + public function process(Entity $entity): void + { + if (!$this->user->isEffectiveAdmin() || !$entity->get('assignedUserId')) { + $entity->set('assignedUserId', $this->user->getId()); + } + + if (!$this->acl->checkEntityEdit($entity)) { + throw new Forbidden(); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/Event/BeforeUpdatePreserveDuration.php b/application/Espo/Classes/RecordHooks/Event/BeforeUpdatePreserveDuration.php new file mode 100644 index 0000000..5771216 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Event/BeforeUpdatePreserveDuration.php @@ -0,0 +1,119 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Event; + +use Espo\Core\Record\Hook\UpdateHook; +use Espo\Core\Record\UpdateParams; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Field\DateTime; +use Espo\Core\Field\Date; + +use Espo\ORM\Entity; +use Espo\ORM\Defs as OrmDefs; + +/** + * @implements UpdateHook + */ +class BeforeUpdatePreserveDuration implements UpdateHook +{ + private OrmDefs $ormDefs; + + public function __construct(OrmDefs $ormDefs) + { + $this->ormDefs = $ormDefs; + } + + public function process(Entity $entity, UpdateParams $params): void + { + /** @var CoreEntity $entity */ + + if (!$entity->isAttributeChanged('dateStart') && !$entity->isAttributeChanged('dateStartDate')) { + return; + } + + if ($entity->isAttributeWritten('dateEnd') || $entity->isAttributeWritten('dateEndDate')) { + return; + } + + $preserveDurationDisabled = $this->ormDefs + ->getEntity($entity->getEntityType()) + ->getField('dateEnd') + ->getParam('preserveDurationDisabled'); + + if ($preserveDurationDisabled) { + return; + } + + $this->processDateTime($entity); + $this->processDate($entity); + } + + private function processDateTime(Entity $entity): void + { + $dateStartFetchedString = $entity->getFetched('dateStart'); + $dateStartString = $entity->get('dateStart'); + $dateEndString = $entity->get('dateEnd'); + + if (!$dateStartFetchedString || !$dateStartString || !$dateEndString) { + return; + } + + $dateStartFetched = DateTime::fromString($dateStartFetchedString); + $dateStart = DateTime::fromString($dateStartString); + $dateEnd = DateTime::fromString($dateEndString); + + $diff = $dateStartFetched->diff($dateEnd); + + $dateEndModified = $dateStart->add($diff); + + $entity->set('dateEnd', $dateEndModified->toString()); + } + + private function processDate(Entity $entity): void + { + $dateStartFetchedString = $entity->getFetched('dateStartDate'); + $dateStartString = $entity->get('dateStartDate'); + $dateEndString = $entity->get('dateEndDate'); + + if (!$dateStartFetchedString || !$dateStartString || !$dateEndString) { + return; + } + + $dateStartFetched = Date::fromString($dateStartFetchedString); + $dateStart = Date::fromString($dateStartString); + $dateEnd = Date::fromString($dateEndString); + + $diff = $dateStartFetched->diff($dateEnd); + + $dateEndModified = $dateStart->add($diff); + + $entity->set('dateEndDate', $dateEndModified->toString()); + } +} diff --git a/application/Espo/Classes/RecordHooks/LeadCapture/BeforeCreate.php b/application/Espo/Classes/RecordHooks/LeadCapture/BeforeCreate.php new file mode 100644 index 0000000..fac12b5 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/LeadCapture/BeforeCreate.php @@ -0,0 +1,52 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\LeadCapture; + +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\LeadCapture; +use Espo\ORM\Entity; +use Espo\Tools\LeadCapture\Service; + +/** + * @noinspection PhpUnused + * @implements SaveHook + */ +class BeforeCreate implements SaveHook +{ + public function __construct( + private Service $service + ) {} + + public function process(Entity $entity): void + { + $entity->setApiKey($this->service->generateApiKey()); + $entity->setFormId($this->service->generateFormId()); + } +} diff --git a/application/Espo/Classes/RecordHooks/Note/AfterCreate.php b/application/Espo/Classes/RecordHooks/Note/AfterCreate.php new file mode 100644 index 0000000..4d46b31 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Note/AfterCreate.php @@ -0,0 +1,90 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Note; + +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Note; +use Espo\Entities\Note as NoteEntity; +use Espo\Entities\Preferences; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Tools\Stream\Service; + +/** + * @implements SaveHook + * @noinspection PhpUnused + */ +class AfterCreate implements SaveHook +{ + public function __construct( + private EntityManager $entityManager, + private User $user, + private Metadata $metadata, + private Service $streamService + ) {} + + public function process(Entity $entity): void + { + $parentType = $entity->getParentType(); + $parentId = $entity->getParentId(); + + if ( + $entity->getType() !== NoteEntity::TYPE_POST || + !$parentType || + !$parentId + ) { + return; + } + + if (!$this->metadata->get(['scopes', $parentType, 'stream'])) { + return; + } + + $preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $this->user->getId()); + + if (!$preferences) { + return; + } + + if (!$preferences->get('followEntityOnStreamPost')) { + return; + } + + $parent = $this->entityManager->getEntityById($parentType, $parentId); + + if (!$parent || $this->user->isSystem() || $this->user->isApi()) { + return; + } + + $this->streamService->followEntity($parent, $this->user->getId()); + } +} diff --git a/application/Espo/Classes/RecordHooks/Note/AssignmentCheck.php b/application/Espo/Classes/RecordHooks/Note/AssignmentCheck.php new file mode 100644 index 0000000..d805a48 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Note/AssignmentCheck.php @@ -0,0 +1,198 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Note; + +use Espo\Core\Acl; +use Espo\Core\Acl\Permission; +use Espo\Core\Acl\Table as AclTable; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Name\Field; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Note; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\Repositories\User as UserRepository; + +/** + * @implements SaveHook + */ +class AssignmentCheck implements SaveHook +{ + public function __construct( + private User $user, + private Acl $acl, + private EntityManager $entityManager + ) {} + + public function process(Entity $entity): void + { + $targetType = $entity->getTargetType(); + + if (!$targetType) { + return; + } + + $userTeamIdList = $this->user->getTeamIdList(); + + $userIdList = $entity->getLinkMultipleIdList('users'); + $portalIdList = $entity->getLinkMultipleIdList('portals'); + $teamIdList = $entity->getLinkMultipleIdList(Field::TEAMS); + + /** @var iterable $targetUserList */ + $targetUserList = []; + + if ($targetType === Note::TARGET_USERS) { + /** @var iterable $targetUserList */ + $targetUserList = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->select([Attribute::ID, 'type']) + ->where([Attribute::ID => $userIdList]) + ->find(); + } + + $hasPortalTargetUser = false; + $allTargetUsersArePortal = true; + + foreach ($targetUserList as $user) { + if (!$user->isPortal()) { + $allTargetUsersArePortal = false; + } + + if ($user->isPortal()) { + $hasPortalTargetUser = true; + } + } + + $messagePermission = $this->acl->getPermissionLevel(Permission::MESSAGE); + + if ($messagePermission === AclTable::LEVEL_NO) { + if ( + $targetType !== Note::TARGET_SELF && + $targetType !== Note::TARGET_PORTALS && + !( + $targetType === Note::TARGET_USERS && + count($userIdList) === 1 && + $userIdList[0] === $this->user->getId() + ) && + !( + $targetType === Note::TARGET_USERS && $allTargetUsersArePortal + ) + ) { + throw new Forbidden('Not permitted to post to anybody except self.'); + } + } + + if ($targetType === Note::TARGET_TEAMS) { + if (empty($teamIdList)) { + throw new BadRequest("No team IDS."); + } + } + + if ($targetType === Note::TARGET_USERS) { + if (empty($userIdList)) { + throw new BadRequest("No user IDs."); + } + } + + if ($targetType === Note::TARGET_PORTALS) { + if (empty($portalIdList)) { + throw new BadRequest("No portal IDs."); + } + + if ($this->acl->getPermissionLevel(Permission::PORTAL) !== AclTable::LEVEL_YES) { + throw new Forbidden('Not permitted to post to portal users.'); + } + } + + if ( + $targetType === Note::TARGET_USERS && + $this->acl->getPermissionLevel(Permission::PORTAL) !== AclTable::LEVEL_YES + ) { + if ($hasPortalTargetUser) { + throw new Forbidden('Not permitted to post to portal users.'); + } + } + + if ($messagePermission === AclTable::LEVEL_TEAM) { + if ($targetType === Note::TARGET_ALL) { + throw new Forbidden('Not permitted to post to all.'); + } + } + + if ( + $messagePermission === AclTable::LEVEL_TEAM && + $targetType === Note::TARGET_TEAMS + ) { + if (empty($userTeamIdList)) { + throw new Forbidden('Not permitted to post to foreign teams.'); + } + + foreach ($teamIdList as $teamId) { + if (!in_array($teamId, $userTeamIdList)) { + throw new Forbidden("Not permitted to post to foreign teams."); + } + } + } + + if ( + $messagePermission === AclTable::LEVEL_TEAM && + $targetType === Note::TARGET_USERS + ) { + if (empty($userTeamIdList)) { + throw new Forbidden('Not permitted to post to users from foreign teams.'); + } + + foreach ($targetUserList as $user) { + if ($user->getId() === $this->user->getId()) { + continue; + } + + if ($user->isPortal()) { + continue; + } + + $inTeam = $this->getUserRepository()->checkBelongsToAnyOfTeams($user->getId(), $userTeamIdList); + + if (!$inTeam) { + throw new Forbidden('Not permitted to post to users from foreign teams.'); + } + } + } + } + + private function getUserRepository(): UserRepository + { + /** @var UserRepository */ + return $this->entityManager->getRepository(User::ENTITY_TYPE); + } +} diff --git a/application/Espo/Classes/RecordHooks/Note/BeforeCreate.php b/application/Espo/Classes/RecordHooks/Note/BeforeCreate.php new file mode 100644 index 0000000..6cf2f12 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Note/BeforeCreate.php @@ -0,0 +1,150 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Note; + +use Espo\Core\Acl; +use Espo\Core\Acl\Table as AclTable; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Note; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Tools\Stream\NoteUtil; + +/** + * @implements SaveHook + * @noinspection PhpUnused + */ +class BeforeCreate implements SaveHook +{ + public function __construct( + private EntityManager $entityManager, + private Acl $acl, + private User $user, + private NoteUtil $noteUtil, + private Metadata $metadata, + ) {} + + public function process(Entity $entity): void + { + $this->checkParent($entity); + + if (!$entity->isPost() && !$this->user->isEffectiveAdmin()) { + throw new Forbidden("Only 'Post' type allowed."); + } + + if ($this->user->isPortal()) { + $entity->setIsInternal(false); + } + + if ( + !$entity->isPost() || + !$entity->getParentType() || + !$this->metadata->get("streamDefs.{$entity->getParentType()}.allowInternalNotes") + ) { + if ($entity->isInternal()) { + throw new Forbidden("Internal note is not allowed."); + } + + $entity->setIsInternal(false); + } + + if ($entity->isPost()) { + $this->noteUtil->handlePostText($entity); + } + + $targetType = $entity->getTargetType(); + + $entity->clear(Note::FIELD_IS_PINNED); + $entity->clear('isGlobal'); + + switch ($targetType) { + case Note::TARGET_ALL: + + $entity->clear('usersIds'); + $entity->clear('teamsIds'); + $entity->clear('portalsIds'); + $entity->set('isGlobal', true); + + break; + + case Note::TARGET_SELF: + + $entity->clear('usersIds'); + $entity->clear('teamsIds'); + $entity->clear('portalsIds'); + $entity->setUsersIds([$this->user->getId()]); + $entity->set('isForSelf', true); + + break; + + case Note::TARGET_USERS: + + $entity->clear('teamsIds'); + $entity->clear('portalsIds'); + + break; + + case Note::TARGET_TEAMS: + + $entity->clear('usersIds'); + $entity->clear('portalsIds'); + + break; + + case Note::TARGET_PORTALS: + + $entity->clear('usersIds'); + $entity->clear('teamsIds'); + + break; + } + } + + /** + * @throws Forbidden + */ + private function checkParent(Note $entity): void + { + if (!$entity->getParentType() || !$entity->getParentId()) { + return; + } + + $parent = $this->entityManager->getEntityById($entity->getParentType(), $entity->getParentId()); + + if ($parent && $this->acl->check($parent, AclTable::ACTION_READ)) { + return; + } + + throw new Forbidden("No access to parent."); + } +} diff --git a/application/Espo/Classes/RecordHooks/Note/BeforeUpdate.php b/application/Espo/Classes/RecordHooks/Note/BeforeUpdate.php new file mode 100644 index 0000000..a6c3ef8 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Note/BeforeUpdate.php @@ -0,0 +1,68 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Note; + +use Espo\Core\Exceptions\ForbiddenSilent; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Note; +use Espo\ORM\Entity; +use Espo\Tools\Stream\NoteUtil; + +/** + * @implements SaveHook + * @noinspection PhpUnused + */ +class BeforeUpdate implements SaveHook +{ + public function __construct( + private NoteUtil $noteUtil, + ) {} + + public function process(Entity $entity): void + { + if (!$this->isEditableType($entity)) { + throw new ForbiddenSilent("Note is not editable."); + } + + if ($entity->isPost()) { + $this->noteUtil->handlePostText($entity); + } + + if (!$entity->isPost()) { + $entity->clear('post'); + $entity->clear('attachmentsIds'); + } + } + + private function isEditableType(Note $entity): bool + { + return $entity->getType() == Note::TYPE_POST; + } +} diff --git a/application/Espo/Classes/RecordHooks/Portal/AfterUpdate.php b/application/Espo/Classes/RecordHooks/Portal/AfterUpdate.php new file mode 100644 index 0000000..d765c81 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Portal/AfterUpdate.php @@ -0,0 +1,68 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Portal; + +use Espo\Core\Acl\Cache\Clearer; +use Espo\Core\DataManager; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Portal; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Repositories\Portal as PortalRepository; + +/** + * @implements SaveHook + */ +class AfterUpdate implements SaveHook +{ + public function __construct( + private Clearer $clearer, + private DataManager $dataManager, + private EntityManager $entityManager + ) {} + + public function process(Entity $entity): void + { + $this->getPortalRepository()->loadUrlField($entity); + + if (!$entity->isAttributeChanged('portalRolesIds')) { + return; + } + + $this->clearer->clearForAllPortalUsers(); + $this->dataManager->updateCacheTimestamp(); + } + + private function getPortalRepository(): PortalRepository + { + /** @var PortalRepository */ + return $this->entityManager->getRDBRepositoryByClass(Portal::class); + } +} diff --git a/application/Espo/Classes/RecordHooks/PortalRole/AfterSave.php b/application/Espo/Classes/RecordHooks/PortalRole/AfterSave.php new file mode 100644 index 0000000..2310905 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/PortalRole/AfterSave.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\PortalRole; + +use Espo\Core\Acl\Cache\Clearer; +use Espo\Core\DataManager; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\PortalRole; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class AfterSave implements SaveHook +{ + public function __construct( + private Clearer $clearer, + private DataManager $dataManager + ) {} + + public function process(Entity $entity): void + { + $this->clearer->clearForAllPortalUsers(); + $this->dataManager->updateCacheTimestamp(); + } +} diff --git a/application/Espo/Classes/RecordHooks/Role/AfterSave.php b/application/Espo/Classes/RecordHooks/Role/AfterSave.php new file mode 100644 index 0000000..331d69e --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Role/AfterSave.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Role; + +use Espo\Core\Acl\Cache\Clearer; +use Espo\Core\DataManager; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Role; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + */ +class AfterSave implements SaveHook +{ + public function __construct( + private Clearer $clearer, + private DataManager $dataManager + ) {} + + public function process(Entity $entity): void + { + $this->clearer->clearForAllInternalUsers(); + $this->dataManager->updateCacheTimestamp(); + } +} diff --git a/application/Espo/Classes/RecordHooks/Role/BeforeSaveValidate.php b/application/Espo/Classes/RecordHooks/Role/BeforeSaveValidate.php new file mode 100644 index 0000000..cdcae08 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Role/BeforeSaveValidate.php @@ -0,0 +1,256 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Role; + +use Espo\Core\Acl\Table; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Portal\Acl\Table as TablePortal; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Metadata; +use Espo\Entities\PortalRole; +use Espo\Entities\Role; +use Espo\ORM\Entity; +use stdClass; + +/** + * @noinspection PhpUnused + * @implements SaveHook + */ +class BeforeSaveValidate implements SaveHook +{ + /** @var string[] */ + private array $levelList = [ + Table::LEVEL_YES, + Table::LEVEL_ALL, + Table::LEVEL_TEAM, + Table::LEVEL_OWN, + Table::LEVEL_NO, + ]; + + /** @var string[] */ + private array $portalLevelList = [ + Table::LEVEL_YES, + Table::LEVEL_ALL, + TablePortal::LEVEL_ACCOUNT, + TablePortal::LEVEL_CONTACT, + Table::LEVEL_OWN, + Table::LEVEL_NO, + ]; + + public function __construct( + private Metadata $metadata + ) {} + + public function process(Entity $entity): void + { + $this->validateData($entity); + $this->validateFieldData($entity); + } + + /** + * @throws BadRequest + */ + private function validateData(Role|PortalRole $entity): void + { + if ($entity->get('data') === null) { + return; + } + + /** @var array $data */ + $data = get_object_vars($entity->get('data')); + + foreach ($data as $scope => $item) { + if (!is_bool($item) && !$item instanceof stdClass) { + throw new BadRequest("Bad data. Should be bool or object."); + } + + $this->validateDataItem($scope, $entity, $item); + } + } + + /** + * @throws BadRequest + */ + private function validateDataItem(string $scope, Role|PortalRole $entity, bool|stdClass $item): void + { + $key = $entity instanceof PortalRole ? 'aclPortal' : 'acl'; + + $type = $this->metadata->get("scopes.$scope.$key"); + + if ($type === Table\ScopeDataType::BOOLEAN) { + if (!is_bool($item)) { + throw new BadRequest("Bad data. Value for *$scope* should be be boolean."); + } + + return; + } + + if ($type === null) { + throw new BadRequest("Bad data. Scope *$scope* is not allowed."); + } + + if ($item === false) { + return; + } + + if (is_bool($item)) { + throw new BadRequest("Bad data. Value for *$scope* should be be false or object."); + } + + $actions = [ + Table::ACTION_CREATE, + Table::ACTION_READ, + Table::ACTION_EDIT, + Table::ACTION_DELETE, + Table::ACTION_STREAM, + ]; + + $isPortal = $entity instanceof PortalRole; + + foreach ($actions as $action) { + if (!property_exists($item, $action)) { + continue; + } + + $level = $item->$action; + + $this->checkActionLevel($scope, $action, $level, $isPortal); + } + } + + /** + * @throws BadRequest + */ + private function checkActionLevel(string $scope, string $action, string $level, bool $isPortal): void + { + if ($action === Table::ACTION_CREATE) { + if (!in_array($level, [Table::LEVEL_YES, Table::LEVEL_NO])) { + throw new BadRequest("Level `$level` is not allowed for action *$action* for *$scope*."); + } + + return; + } + + $mapKey = $isPortal ? 'aclPortalActionLevelListMap' : 'aclActionLevelListMap'; + $key = $isPortal ? 'aclPortalLevelList' : 'aclLevelList'; + $defaultLevels = $isPortal ? $this->portalLevelList : $this->levelList; + + $levels = $this->metadata->get("scopes.$scope.$mapKey.$action") ?? + $this->metadata->get("scopes.$scope.$key") ?? + $defaultLevels; + + if (in_array($level, $levels)) { + return; + } + + throw new BadRequest("Level `$level` is not allowed for action *$action* for *$scope*."); + } + + /** + * @throws BadRequest + */ + private function validateFieldData(Role|PortalRole $entity): void + { + if ($entity->get('fieldData') === null) { + return; + } + + /** @var array $data */ + $data = get_object_vars($entity->get('fieldData')); + + foreach ($data as $scope => $item) { + if (!$item instanceof stdClass) { + throw new BadRequest("Bad field-level data. Should be object."); + } + + $this->validateFieldDataItem($scope, $entity, $item); + } + } + + /** + * @throws BadRequest + */ + private function validateFieldDataItem(string $scope, PortalRole|Role $entity, stdClass $item): void + { + $disabledKey = $entity instanceof PortalRole ? 'aclPortalFieldLevelDisabled' : 'aclFieldLevelDisabled'; + $key = $entity instanceof PortalRole ? 'aclPortal' : 'acl'; + + if ( + !$this->metadata->get("scopes.$scope.entity") || + !$this->metadata->get("scopes.$scope.$key") || + $this->metadata->get("scopes.$scope.$disabledKey") + ) { + throw new BadRequest("Bad field-level data. Scope *$scope* is not allowed."); + } + + /** @var array $data */ + $data = get_object_vars($item); + + foreach ($data as $field => $fieldItem) { + if (!$fieldItem instanceof stdClass) { + throw new BadRequest("Data for field *$field*, scope *$scope* should be object."); + } + + $this->validateFieldDataItemItem($scope, $field, $fieldItem); + } + } + + /** + * @throws BadRequest + */ + private function validateFieldDataItemItem(string $scope, string $field, stdClass $item): void + { + if (!$this->metadata->get("entityDefs.$scope.fields.$field")) { + throw new BadRequest("Field *$field* does not exist in *$scope*."); + } + + $actions = [ + Table::ACTION_READ, + Table::ACTION_EDIT, + ]; + + $levels = [ + Table::LEVEL_YES, + Table::LEVEL_NO, + ]; + + foreach ($actions as $action) { + if (!property_exists($item, $action)) { + continue; + } + + $level = $item->$action; + + if (!in_array($level, $levels)) { + throw new BadRequest("Level `$level` is not allowed for *$scope*, field *$field*."); + } + } + } +} diff --git a/application/Espo/Classes/RecordHooks/Team/AfterUpdate.php b/application/Espo/Classes/RecordHooks/Team/AfterUpdate.php new file mode 100644 index 0000000..664cb60 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Team/AfterUpdate.php @@ -0,0 +1,58 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Team; + +use Espo\Core\Acl\Cache\Clearer; +use Espo\Core\DataManager; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\Team; +use Espo\ORM\Entity; + +/** + * @implements SaveHook + * @noinspection PhpUnused + */ +class AfterUpdate implements SaveHook +{ + public function __construct( + private Clearer $clearer, + private DataManager $dataManager + ) {} + + public function process(Entity $entity): void + { + if (!$entity->isAttributeChanged('rolesIds')) { + return; + } + + $this->clearer->clearForAllInternalUsers(); + $this->dataManager->updateCacheTimestamp(); + } +} diff --git a/application/Espo/Classes/RecordHooks/Team/BeforeLinkUserCheck.php b/application/Espo/Classes/RecordHooks/Team/BeforeLinkUserCheck.php new file mode 100644 index 0000000..b636a1f --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Team/BeforeLinkUserCheck.php @@ -0,0 +1,65 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Team; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\LinkHook; + +use Espo\ORM\Entity; + +use Espo\Entities\User; + +/** + * @implements LinkHook<\Espo\Entities\Team> + */ +class BeforeLinkUserCheck implements LinkHook +{ + public function process(Entity $entity, string $link, Entity $foreignEntity): void + { + if ($link !== 'users') { + return; + } + + assert($foreignEntity instanceof User); + + $this->processUserCheck($foreignEntity); + } + + private function processUserCheck(User $user): void + { + if ($user->isPortal()) { + throw new Forbidden("Can't add portal users to team."); + } + + if ($user->isSystem()) { + throw new Forbidden("Can't add system users to team."); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterLink.php b/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterLink.php new file mode 100644 index 0000000..239c9f7 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterLink.php @@ -0,0 +1,58 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Team; + +use Espo\Core\Acl\Cache\Clearer; +use Espo\Core\DataManager; +use Espo\Core\Record\Hook\LinkHook; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements LinkHook + */ +class ClearCacheAfterLink implements LinkHook +{ + public function __construct( + private Clearer $clearer, + private DataManager $dataManager + ) {} + + public function process(Entity $entity, string $link, Entity $foreignEntity): void + { + if ($link !== 'users' || !$foreignEntity instanceof User) { + return; + } + + $this->clearer->clearForUser($foreignEntity); + $this->dataManager->updateCacheTimestamp(); + } +} diff --git a/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php b/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php new file mode 100644 index 0000000..8133609 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Team/ClearCacheAfterUnlink.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Team; + +use Espo\Core\Acl\Cache\Clearer; +use Espo\Core\Record\Hook\UnlinkHook; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements UnlinkHook + */ +class ClearCacheAfterUnlink implements UnlinkHook +{ + public function __construct( + private Clearer $clearer, + ) {} + + public function process(Entity $entity, string $link, Entity $foreignEntity): void + { + if ($link !== 'users' || !$foreignEntity instanceof User) { + return; + } + + $this->clearer->clearForUser($foreignEntity); + } +} diff --git a/application/Espo/Classes/RecordHooks/Team/UnsetUserDefaultTeam.php b/application/Espo/Classes/RecordHooks/Team/UnsetUserDefaultTeam.php new file mode 100644 index 0000000..4271e03 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Team/UnsetUserDefaultTeam.php @@ -0,0 +1,60 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Team; + +use Espo\Core\Record\Hook\UnlinkHook; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements UnlinkHook + */ +class UnsetUserDefaultTeam implements UnlinkHook +{ + public function __construct(private EntityManager $entityManager) + {} + + public function process(Entity $entity, string $link, Entity $foreignEntity): void + { + if (!$foreignEntity instanceof User || $link !== 'users') { + return; + } + + if ($foreignEntity->getDefaultTeam()?->getId() !== $entity->getId()) { + return; + } + + $foreignEntity->setDefaultTeam(null); + + $this->entityManager->saveEntity($foreignEntity); + } +} diff --git a/application/Espo/Classes/RecordHooks/User/AfterUpdate.php b/application/Espo/Classes/RecordHooks/User/AfterUpdate.php new file mode 100644 index 0000000..64c3348 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/User/AfterUpdate.php @@ -0,0 +1,103 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\User; + +use Espo\Core\Acl\Cache\Clearer; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Entity; +use Espo\Entities\User; +use Espo\ORM\EntityManager; + +/** + * @implements SaveHook + * @noinspection PhpUnused + */ +class AfterUpdate implements SaveHook +{ + public function __construct( + private EntityManager $entityManager, + private Clearer $clearer, + ) {} + + public function process(Entity $entity): void + { + $this->processCache($entity); + $this->processContactName($entity); + } + + private function processCache(User $entity): void + { + if ( + $entity->isAttributeChanged('rolesIds') || + $entity->isAttributeChanged('teamsIds') || + $entity->isAttributeChanged('type') || + $entity->isAttributeChanged('portalRolesIds') || + $entity->isAttributeChanged('portalsIds') || + $entity->isAttributeChanged('contactId') || + $entity->isAttributeChanged('accountsIds') + ) { + $this->clearer->clearForUser($entity); + } + } + + private function processContactName(User $entity): void + { + if ( + !$entity->isPortal() || + !$entity->getContactId() || + !$entity->isAttributeChanged('firstName') && + !$entity->isAttributeChanged('lastName') && + !$entity->isAttributeChanged('salutationName') + ) { + return; + } + + $contact = $this->entityManager->getEntityById(Contact::ENTITY_TYPE, $entity->getContactId()); + + if (!$contact) { + return; + } + + if ($entity->isAttributeChanged('firstName')) { + $contact->set('firstName', $entity->get('firstName')); + } + + if ($entity->isAttributeChanged('lastName')) { + $contact->set('lastName', $entity->get('lastName')); + } + + if ($entity->isAttributeChanged('salutationName')) { + $contact->set('salutationName', $entity->get('salutationName')); + } + + $this->entityManager->saveEntity($contact); + } +} diff --git a/application/Espo/Classes/RecordHooks/User/BeforeCreate.php b/application/Espo/Classes/RecordHooks/User/BeforeCreate.php new file mode 100644 index 0000000..e467200 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/User/BeforeCreate.php @@ -0,0 +1,132 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\User; + +use Espo\Core\Authentication\Logins\Hmac; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Util; +use Espo\ORM\Entity; +use Espo\Entities\User; +use Espo\Tools\User\UserUtil; + +/** + * @implements SaveHook + * @noinspection PhpUnused + */ +class BeforeCreate implements SaveHook +{ + public function __construct( + private Config $config, + private User $user, + private UserUtil $util + ) {} + + public function process(Entity $entity): void + { + $this->processLimitChecking($entity); + $this->processUserExistsChecking($entity); + $this->processApi($entity); + $this->processTypeChecking($entity); + } + + /** + * @throws Conflict + */ + private function processUserExistsChecking(User $entity): void + { + if ($this->util->checkExists($entity)) { + throw new Conflict('userNameExists'); + } + } + + /** + * @throws Forbidden + */ + private function processLimitChecking(User $entity): void + { + $userLimit = $this->config->get('userLimit'); + $portalUserLimit = $this->config->get('portalUserLimit'); + + if ( + $userLimit && + !$this->user->isSuperAdmin() && + !$entity->isPortal() && !$entity->isApi() + ) { + $userCount = $this->util->getInternalCount(); + + if ($userCount >= $userLimit) { + throw new Forbidden("User limit $userLimit is reached."); + } + } + + if ( + $portalUserLimit && + !$this->user->isSuperAdmin() && + $entity->isPortal() + ) { + $portalUserCount = $this->util->getPortalCount(); + + if ($portalUserCount >= $portalUserLimit) { + throw new Forbidden("Portal user limit $portalUserLimit is reached."); + } + } + } + + private function processApi(User $entity): void + { + if (!$entity->isApi()) { + return; + } + + $entity->setApiKey(Util::generateApiKey()); + + if ($entity->getAuthMethod() === Hmac::NAME) { + $entity->setSecretKey(Util::generateSecretKey()); + } + } + + /** + * @throws Forbidden + */ + private function processTypeChecking(User $entity): void + { + if ( + !$entity->isAttributeChanged(User::ATTR_TYPE) || + in_array($entity->getType(), $this->util->getAllowedUserTypeList()) + ) { + return; + } + + throw new Forbidden("Not allowed 'type'."); + } +} diff --git a/application/Espo/Classes/RecordHooks/User/BeforeDelete.php b/application/Espo/Classes/RecordHooks/User/BeforeDelete.php new file mode 100644 index 0000000..2183e54 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/User/BeforeDelete.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\User; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\DeleteParams; +use Espo\Core\Record\Hook\DeleteHook; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @implements DeleteHook + */ +class BeforeDelete implements DeleteHook +{ + public function __construct( + private User $user, + ) {} + + public function process(Entity $entity, DeleteParams $params): void + { + if ($entity->getId() === $this->user->getId()) { + throw new Forbidden("Can't delete own user."); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/User/BeforeUpdate.php b/application/Espo/Classes/RecordHooks/User/BeforeUpdate.php new file mode 100644 index 0000000..a0b1ee1 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/User/BeforeUpdate.php @@ -0,0 +1,169 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\User; + +use Espo\Core\Authentication\Logins\Hmac; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Util; +use Espo\Entities\User as UserEntity; +use Espo\ORM\Entity; +use Espo\Entities\User; +use Espo\Tools\User\UserUtil; + +/** + * @implements SaveHook + * @noinspection PhpUnused + */ +class BeforeUpdate implements SaveHook +{ + public function __construct( + private Config $config, + private User $user, + private UserUtil $util + ) {} + + public function process(Entity $entity): void + { + $this->processLimitChecking($entity); + $this->processUserExistsChecking($entity); + $this->processApi($entity); + $this->processTypeChecking($entity); + } + + /** + * @throws Conflict + */ + private function processUserExistsChecking(User $entity): void + { + if (!$entity->isAttributeChanged('userName')) { + return; + } + + if ($this->util->checkExists($entity)) { + throw new Conflict('userNameExists'); + } + } + + /** + * @throws Forbidden + */ + private function processLimitChecking(User $entity): void + { + $userLimit = $this->config->get('userLimit'); + $portalUserLimit = $this->config->get('portalUserLimit'); + + if ( + $userLimit && + !$this->user->isSuperAdmin() && + ( + ( + $entity->isActive() && + $entity->isAttributeChanged('isActive') && + !$entity->isPortal() && + !$entity->isApi() + ) || + ( + !$entity->isPortal() && + !$entity->isApi() && + $entity->isAttributeChanged('type') && + ( + $entity->isRegular() || + $entity->isAdmin() + ) && + ( + $entity->getFetched('type') == UserEntity::TYPE_PORTAL || + $entity->getFetched('type') == UserEntity::TYPE_API + ) + ) + ) + ) { + $userCount = $this->util->getInternalCount(); + + if ($userCount >= $userLimit) { + throw new Forbidden("User limit $userLimit is reached."); + } + } + + if ( + $portalUserLimit && + !$this->user->isSuperAdmin() && + ( + ( + $entity->isActive() && + $entity->isAttributeChanged('isActive') && + $entity->isPortal() + ) || + ( + $entity->isPortal() && + $entity->isAttributeChanged('type') + ) + ) + ) { + $portalUserCount = $this->util->getPortalCount(); + + if ($portalUserCount >= $portalUserLimit) { + throw new Forbidden("Portal user limit $portalUserLimit is reached."); + } + } + } + + private function processApi(User $entity): void + { + if ( + !$entity->isApi() || + !$entity->isAttributeChanged('authMethod') || + $entity->getAuthMethod() !== Hmac::NAME + ) { + return; + } + + $secretKey = Util::generateSecretKey(); + + $entity->set('secretKey', $secretKey); + } + + /** + * @throws Forbidden + */ + private function processTypeChecking(User $entity): void + { + if ( + !$entity->isAttributeChanged(User::ATTR_TYPE) || + in_array($entity->getType(), $this->util->getAllowedUserTypeList()) + ) { + return; + } + + throw new Forbidden("Can't change type."); + } +} diff --git a/application/Espo/Classes/RecordHooks/Webhook/AfterDelete.php b/application/Espo/Classes/RecordHooks/Webhook/AfterDelete.php new file mode 100644 index 0000000..b85cf63 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Webhook/AfterDelete.php @@ -0,0 +1,62 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Webhook; + +use Espo\Core\Record\DeleteParams; +use Espo\Core\Record\Hook\DeleteHook; +use Espo\Core\Webhook\Manager; +use Espo\Entities\Webhook; +use Espo\ORM\Entity; + +/** + * @implements DeleteHook + * @noinspection PhpUnused + */ +class AfterDelete implements DeleteHook +{ + public function __construct( + private Manager $webhookManager + ) {} + + public function process(Entity $entity, DeleteParams $params): void + { + $event = $entity->getEvent(); + + if (!$event) { + return; + } + + if (!$entity->isActive()) { + return; + } + + $this->webhookManager->removeEvent($event); + } +} diff --git a/application/Espo/Classes/RecordHooks/Webhook/AfterSave.php b/application/Espo/Classes/RecordHooks/Webhook/AfterSave.php new file mode 100644 index 0000000..101c0a5 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Webhook/AfterSave.php @@ -0,0 +1,75 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Webhook; + +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Webhook\Manager; +use Espo\Entities\Webhook; +use Espo\ORM\Entity; +use RuntimeException; + +/** + * @implements SaveHook + */ +class AfterSave implements SaveHook +{ + public function __construct( + private Manager $webhookManager + ) {} + + public function process(Entity $entity): void + { + $event = $entity->getEvent(); + + if (!$event) { + throw new RuntimeException("No 'event'."); + } + + if ($entity->isNew()) { + if ($entity->isActive()) { + $this->webhookManager->addEvent($event); + } + + return; + } + + if (!$entity->isAttributeChanged('isActive')) { + return; + } + + if ($entity->isActive()) { + $this->webhookManager->addEvent($event); + + return; + } + + $this->webhookManager->removeEvent($event); + } +} diff --git a/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php b/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php new file mode 100644 index 0000000..61df14b --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Webhook/BeforeSave.php @@ -0,0 +1,189 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\Webhook; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\Entities\Webhook; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements SaveHook + */ +class BeforeSave implements SaveHook +{ + private const WEBHOOK_MAX_COUNT_PER_USER = 50; + + /** @var string[] */ + private $eventTypeList = [ + 'create', + 'update', + 'delete', + 'fieldUpdate', + ]; + + public function __construct( + private User $user, + private EntityManager $entityManager, + private Acl $acl, + private Metadata $metadata, + private Config $config + ) {} + + public function process(Entity $entity): void + { + if ($entity->skipOwn() && !$entity->getUserId()) { + $entity->setSkipOwn(false); + } + + $this->checkEntityUserIsApi($entity); + $this->processEntityEventData($entity); + + if ($entity->isNew() && !$this->user->isEffectiveAdmin()) { + $this->checkMaxCount(); + } + } + + /** + * @throws Forbidden + */ + private function checkEntityUserIsApi(Webhook $entity): void + { + $userId = $entity->getUserId(); + + if (!$userId) { + return; + } + + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if ($user && $user->isApi()) { + return; + } + + throw new Forbidden("User must be an API User."); + } + + /** + * @throws Forbidden + */ + private function processEntityEventData(Webhook $entity): void + { + $event = $entity->get('event'); + + if (!$event) { + throw new Forbidden("Event is empty."); + } + + if (!$entity->isNew() && $entity->isAttributeChanged('event')) { + throw new Forbidden("Event can't be changed."); + } + + $arr = explode('.', $event); + + if (count($arr) !== 2 && count($arr) !== 3) { + throw new Forbidden("Not supported event."); + } + + $entityType = $arr[0]; + $type = $arr[1]; + + $entity->set('entityType', $entityType); + $entity->set('type', $type); + + $field = null; + + if (!$entityType) { + throw new Forbidden("Entity Type is empty."); + } + + if (!$this->metadata->get(['scopes', $entityType, 'object'])) { + throw new Forbidden("Entity type is not available for Webhooks."); + } + + if (!$this->entityManager->hasRepository($entityType)) { + throw new Forbidden("Not existing Entity Type."); + } + + if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_READ)) { + throw new Forbidden("Entity type is forbidden."); + } + + if (!in_array($type, $this->eventTypeList)) { + throw new Forbidden("Not supported event."); + } + + if ($type === 'fieldUpdate') { + if (count($arr) == 3) { + $field = $arr[2]; + } + + $entity->set('field', $field); + + if (!$field) { + throw new Forbidden("Field is empty."); + } + + if (!$this->acl->checkField($entityType, $field)) { + throw new Forbidden("Field is forbidden."); + } + + if (!$this->metadata->get(['entityDefs', $entityType, 'fields', $field])) { + throw new Forbidden("Field does not exist."); + } + + return; + } + + $entity->set('field', null); + } + + /** + * @throws Forbidden + */ + private function checkMaxCount(): void + { + $maxCount = $this->config->get('webhookMaxCountPerUser', self::WEBHOOK_MAX_COUNT_PER_USER); + + $count = $this->entityManager + ->getRDBRepositoryByClass(Webhook::class) + ->where(['userId' => $this->user->getId()]) + ->count(); + + if ($maxCount && $count >= $maxCount) { + throw new Forbidden("Webhook number per user exceeded the limit."); + } + } +} diff --git a/application/Espo/Classes/Select/ActionHistoryRecord/AccessControlFilters/OnlyOwn.php b/application/Espo/Classes/Select/ActionHistoryRecord/AccessControlFilters/OnlyOwn.php new file mode 100644 index 0000000..291aa47 --- /dev/null +++ b/application/Espo/Classes/Select/ActionHistoryRecord/AccessControlFilters/OnlyOwn.php @@ -0,0 +1,47 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\ActionHistoryRecord\AccessControlFilters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\User; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class OnlyOwn implements Filter +{ + public function __construct(private User $user) + {} + + public function apply(QueryBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'userId' => $this->user->getId(), + ]); + } +} diff --git a/application/Espo/Classes/Select/ActionHistoryRecord/BoolFilters/OnlyMy.php b/application/Espo/Classes/Select/ActionHistoryRecord/BoolFilters/OnlyMy.php new file mode 100644 index 0000000..596285a --- /dev/null +++ b/application/Espo/Classes/Select/ActionHistoryRecord/BoolFilters/OnlyMy.php @@ -0,0 +1,51 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\ActionHistoryRecord\BoolFilters; + +use Espo\Core\Select\Bool\Filter; +use Espo\Entities\User; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class OnlyMy implements Filter +{ + public function __construct(private User $user) + {} + + public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + $item = WhereClause::fromRaw([ + 'userId' => $this->user->getId(), + ]); + + $orGroupBuilder->add($item); + } +} diff --git a/application/Espo/Classes/Select/AddressCountry/PreferredNameOrderer.php b/application/Espo/Classes/Select/AddressCountry/PreferredNameOrderer.php new file mode 100644 index 0000000..b39a53f --- /dev/null +++ b/application/Espo/Classes/Select/AddressCountry/PreferredNameOrderer.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\AddressCountry; + +use Espo\Core\Name\Field; +use Espo\Core\Select\Order\Item; +use Espo\Core\Select\Order\Orderer; +use Espo\ORM\Query\Part\Order; +use Espo\ORM\Query\SelectBuilder; + +class PreferredNameOrderer implements Orderer +{ + public function apply(SelectBuilder $queryBuilder, Item $item): void + { + $queryBuilder + ->order('isPreferred', $item->getOrder() === Order::ASC ? Order::DESC : Order::ASC) + ->order(Field::NAME, $item->getOrder()); + } +} diff --git a/application/Espo/Classes/Select/AppLogRecord/PrimaryFilters/Errors.php b/application/Espo/Classes/Select/AppLogRecord/PrimaryFilters/Errors.php new file mode 100644 index 0000000..1e06150 --- /dev/null +++ b/application/Espo/Classes/Select/AppLogRecord/PrimaryFilters/Errors.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\AppLogRecord\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; +use Psr\Log\LogLevel; + +class Errors implements Filter +{ + public function apply(QueryBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'level' => [ + ucfirst(LogLevel::ERROR), + ucfirst(LogLevel::EMERGENCY), + ucfirst(LogLevel::CRITICAL), + ucfirst(LogLevel::ALERT), + ] + ]); + } +} diff --git a/application/Espo/Classes/Select/Attachment/PrimaryFilters/Orphan.php b/application/Espo/Classes/Select/Attachment/PrimaryFilters/Orphan.php new file mode 100644 index 0000000..cc3e634 --- /dev/null +++ b/application/Espo/Classes/Select/Attachment/PrimaryFilters/Orphan.php @@ -0,0 +1,80 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Attachment\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\Attachment; +use Espo\Entities\Settings; +use Espo\ORM\Query\SelectBuilder; + +class Orphan implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'role' => [ + Attachment::ROLE_ATTACHMENT, + Attachment::ROLE_INLINE_ATTACHMENT, + ], + [ + 'OR' => [ + [ + 'parentType!=' => null, + 'parentId' => null, + 'relatedType' => null, + ], + [ + 'relatedType!=' => null, + 'relatedId' => null, + 'parentType' => null, + ], + ], + ], + [ + 'OR' => [ + 'relatedType!=' => Settings::ENTITY_TYPE, + 'relatedType' => null, + ], + ], + 'attachmentChild.id' => null, + ]); + + $queryBuilder->leftJoin( + Attachment::ENTITY_TYPE, + 'attachmentChild', + [ + 'attachmentChild.sourceId:' => 'attachment.id', + 'attachmentChild.deleted' => false, + ] + ); + + $queryBuilder->distinct(); + } +} diff --git a/application/Espo/Classes/Select/AuthLogRecord/PrimaryFilters/Accepted.php b/application/Espo/Classes/Select/AuthLogRecord/PrimaryFilters/Accepted.php new file mode 100644 index 0000000..2578e89 --- /dev/null +++ b/application/Espo/Classes/Select/AuthLogRecord/PrimaryFilters/Accepted.php @@ -0,0 +1,43 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\AuthLogRecord\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder; + +class Accepted implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'isDenied' => false, + ]); + } +} diff --git a/application/Espo/Classes/Select/AuthLogRecord/PrimaryFilters/Denied.php b/application/Espo/Classes/Select/AuthLogRecord/PrimaryFilters/Denied.php new file mode 100644 index 0000000..1f46a35 --- /dev/null +++ b/application/Espo/Classes/Select/AuthLogRecord/PrimaryFilters/Denied.php @@ -0,0 +1,43 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\AuthLogRecord\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder; + +class Denied implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'isDenied' => true, + ]); + } +} diff --git a/application/Espo/Classes/Select/AuthToken/PrimaryFilters/Active.php b/application/Espo/Classes/Select/AuthToken/PrimaryFilters/Active.php new file mode 100644 index 0000000..9f2db6d --- /dev/null +++ b/application/Espo/Classes/Select/AuthToken/PrimaryFilters/Active.php @@ -0,0 +1,43 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\AuthToken\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder; + +class Active implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'isActive' => true, + ]); + } +} diff --git a/application/Espo/Classes/Select/AuthToken/PrimaryFilters/Inactive.php b/application/Espo/Classes/Select/AuthToken/PrimaryFilters/Inactive.php new file mode 100644 index 0000000..4bdf3cb --- /dev/null +++ b/application/Espo/Classes/Select/AuthToken/PrimaryFilters/Inactive.php @@ -0,0 +1,43 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\AuthToken\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder; + +class Inactive implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'isActive' => false, + ]); + } +} diff --git a/application/Espo/Classes/Select/CurrencyRecord/PrimaryFilters/Active.php b/application/Espo/Classes/Select/CurrencyRecord/PrimaryFilters/Active.php new file mode 100644 index 0000000..90f8ced --- /dev/null +++ b/application/Espo/Classes/Select/CurrencyRecord/PrimaryFilters/Active.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\CurrencyRecord\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\CurrencyRecord; +use Espo\ORM\Query\SelectBuilder; + +class Active implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + CurrencyRecord::FIELD_STATUS => CurrencyRecord::STATUS_ACTIVE, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/AccessControlFilters/OnlyOwn.php b/application/Espo/Classes/Select/Email/AccessControlFilters/OnlyOwn.php new file mode 100644 index 0000000..347843c --- /dev/null +++ b/application/Espo/Classes/Select/Email/AccessControlFilters/OnlyOwn.php @@ -0,0 +1,52 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\AccessControlFilters; + +use Espo\Core\Select\AccessControl\Filter; + +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class OnlyOwn implements Filter +{ + public function __construct( + private User $user, + private JoinHelper $joinHelper + ) {} + + public function apply(QueryBuilder $queryBuilder): void + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + $queryBuilder->where([Email::ALIAS_INBOX . '.userId' => $this->user->getId()]); + } +} diff --git a/application/Espo/Classes/Select/Email/AccessControlFilters/OnlyTeam.php b/application/Espo/Classes/Select/Email/AccessControlFilters/OnlyTeam.php new file mode 100644 index 0000000..b2cf3cd --- /dev/null +++ b/application/Espo/Classes/Select/Email/AccessControlFilters/OnlyTeam.php @@ -0,0 +1,76 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\AccessControlFilters; + +use Espo\Core\Select\AccessControl\Filter; + +use Espo\Entities\Email; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class OnlyTeam implements Filter +{ + public function __construct(private User $user) + {} + + public function apply(QueryBuilder $queryBuilder): void + { + $subQuery = QueryBuilder::create() + ->select(Attribute::ID) + ->from(Email::ENTITY_TYPE) + ->leftJoin(Team::RELATIONSHIP_ENTITY_TEAM, 'entityTeam', [ + 'entityTeam.entityId:' => 'id', + 'entityTeam.entityType' => Email::ENTITY_TYPE, + 'entityTeam.deleted' => false, + ]) + ->leftJoin(Email::RELATIONSHIP_EMAIL_USER, Email::ALIAS_INBOX, [ + Email::ALIAS_INBOX . '.emailId:' => 'id', + Email::ALIAS_INBOX . '.deleted' => false, + Email::ALIAS_INBOX . '.userId' => $this->user->getId(), + ]) + ->where([ + 'OR' => [ + 'entityTeam.teamId' => $this->user->getTeamIdList(), + Email::ALIAS_INBOX . '.userId' => $this->user->getId(), + ] + ]) + ->build(); + + $queryBuilder->where( + Cond::in( + Cond::column(Attribute::ID), + $subQuery + ) + ); + } +} diff --git a/application/Espo/Classes/Select/Email/AccessControlFilters/PortalOnlyAccount.php b/application/Espo/Classes/Select/Email/AccessControlFilters/PortalOnlyAccount.php new file mode 100644 index 0000000..fd0cd0e --- /dev/null +++ b/application/Espo/Classes/Select/Email/AccessControlFilters/PortalOnlyAccount.php @@ -0,0 +1,69 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\AccessControlFilters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class PortalOnlyAccount implements Filter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function apply(QueryBuilder $queryBuilder): void + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + $orGroup = [ + Email::ALIAS_INBOX . '.userId' => $this->user->getId(), + ]; + + $accountIdList = $this->user->getAccounts()->getIdList(); + + if (count($accountIdList)) { + $orGroup['accountId'] = $accountIdList; + } + + $contactId = $this->user->getContactId(); + + if ($contactId) { + $orGroup[] = [ + 'parentId' => $contactId, + 'parentType' => Contact::ENTITY_TYPE, + ]; + } + + $queryBuilder->where(['OR' => $orGroup]); + } +} diff --git a/application/Espo/Classes/Select/Email/AccessControlFilters/PortalOnlyContact.php b/application/Espo/Classes/Select/Email/AccessControlFilters/PortalOnlyContact.php new file mode 100644 index 0000000..c80ae8b --- /dev/null +++ b/application/Espo/Classes/Select/Email/AccessControlFilters/PortalOnlyContact.php @@ -0,0 +1,66 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\AccessControlFilters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * @noinspection PhpUnused + */ +class PortalOnlyContact implements Filter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function apply(QueryBuilder $queryBuilder): void + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + $orGroup = [ + Email::ALIAS_INBOX . '.userId' => $this->user->getId(), + ]; + + $contactId = $this->user->getContactId(); + + if ($contactId) { + $orGroup[] = [ + 'parentId' => $contactId, + 'parentType' => Contact::ENTITY_TYPE, + ]; + } + + $queryBuilder->where(['OR' => $orGroup]); + } +} diff --git a/application/Espo/Classes/Select/Email/AdditionalAppliers/Main.php b/application/Espo/Classes/Select/Email/AdditionalAppliers/Main.php new file mode 100644 index 0000000..7bac650 --- /dev/null +++ b/application/Espo/Classes/Select/Email/AdditionalAppliers/Main.php @@ -0,0 +1,154 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\AdditionalAppliers; + +use Espo\Core\Select\Applier\AdditionalApplier; +use Espo\Core\Select\Primary\Filters\One; +use Espo\Core\Select\SearchParams; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\SelectBuilder; +use Espo\Tools\Email\Folder; + +class Main implements AdditionalApplier +{ + public function __construct( + private User $user, + private JoinHelper $joinHelper + ) {} + + public function apply(SelectBuilder $queryBuilder, SearchParams $searchParams): void + { + $folder = $this->retrieveFolder($searchParams); + + $this->applyIndexes($folder, $queryBuilder, $searchParams); + + if ($folder !== Folder::DRAFTS) { + $this->joinEmailUser($queryBuilder); + } + } + + private function applyIndexes(?string $folder, SelectBuilder $queryBuilder, SearchParams $searchParams): void + { + if ($searchParams->getPrimaryFilter() === One::NAME) { + return; + } + + if ($searchParams->getTextFilter()) { + return; + } + + if ($folder === Folder::IMPORTANT) { + return; + } + + if ($folder === Folder::DRAFTS) { + $queryBuilder->useIndex('createdById'); + + return; + } + + /*if ($this->checkApplyDateSentIndex($queryBuilder, $searchParams)) { + $queryBuilder->useIndex('dateSent'); + }*/ + } + + private function joinEmailUser(SelectBuilder $queryBuilder): void + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + if ($queryBuilder->build()->getSelect() === []) { + $queryBuilder->select('*'); + } + + $itemList = [ + Email::USERS_COLUMN_IS_READ, + Email::USERS_COLUMN_IS_IMPORTANT, + Email::USERS_COLUMN_IN_TRASH, + Email::USERS_COLUMN_IN_ARCHIVE, + Email::USERS_COLUMN_FOLDER_ID, + ]; + + foreach ($itemList as $item) { + $queryBuilder->select(Email::ALIAS_INBOX . '.' . $item, $item); + } + } + + private function retrieveFolder(SearchParams $searchParams): ?string + { + if (!$searchParams->getWhere()) { + return null; + } + + foreach ($searchParams->getWhere()->getItemList() as $item) { + if ($item->getType() === 'inFolder') { + return $item->getValue(); + } + } + + return null; + } + + /*private function checkApplyDateSentIndex(SelectBuilder $queryBuilder, SearchParams $searchParams): bool + { + if ($searchParams->getTextFilter()) { + return false; + } + + if ($searchParams->getOrderBy() && $searchParams->getOrderBy() !== 'dateSent') { + return false; + } + + $whereItemList = []; + + if ($searchParams->getWhere()) { + $whereItemList = $searchParams->getWhere()->getItemList(); + } + + foreach ($whereItemList as $item) { + $itemAttribute = $item->getAttribute(); + + if ( + $itemAttribute && + $itemAttribute !== 'folderId' && + !in_array($itemAttribute, ['teams', 'users', 'status']) + ) { + return false; + } + } + + if ($queryBuilder->hasJoinAlias('teamsAccess')) { + return false; + } + + return true; + }*/ +} diff --git a/application/Espo/Classes/Select/Email/BoolFilters/OnlyMy.php b/application/Espo/Classes/Select/Email/BoolFilters/OnlyMy.php new file mode 100644 index 0000000..b52b9a4 --- /dev/null +++ b/application/Espo/Classes/Select/Email/BoolFilters/OnlyMy.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\BoolFilters; + +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Core\Select\Bool\Filter; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class OnlyMy implements Filter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + $item = WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.userId' => $this->user->getId(), + ]); + + $orGroupBuilder->add($item); + } +} diff --git a/application/Espo/Classes/Select/Email/Helpers/EmailAddressHelper.php b/application/Espo/Classes/Select/Email/Helpers/EmailAddressHelper.php new file mode 100644 index 0000000..fb95302 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Helpers/EmailAddressHelper.php @@ -0,0 +1,59 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Helpers; + +use Espo\Entities\EmailAddress; +use Espo\ORM\EntityManager; + +class EmailAddressHelper +{ + private EntityManager $entityManager; + + public function __construct(EntityManager $entityManager) + { + $this->entityManager = $entityManager; + } + + public function getEmailAddressIdByValue(string $value): ?string + { + $emailAddress = $this->entityManager + ->getRDBRepository(EmailAddress::ENTITY_TYPE) + ->where([ + 'lower' => strtolower($value), + ]) + ->findOne(); + + if (!$emailAddress) { + return null; + } + + return $emailAddress->getId(); + } +} diff --git a/application/Espo/Classes/Select/Email/Helpers/JoinHelper.php b/application/Espo/Classes/Select/Email/Helpers/JoinHelper.php new file mode 100644 index 0000000..123809c --- /dev/null +++ b/application/Espo/Classes/Select/Email/Helpers/JoinHelper.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Helpers; + +use Espo\Entities\Email; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class JoinHelper +{ + public function joinEmailUser(QueryBuilder $queryBuilder, string $userId): void + { + if ($queryBuilder->hasJoinAlias(Email::ALIAS_INBOX)) { + return; + } + + $queryBuilder->leftJoin(Email::RELATIONSHIP_EMAIL_USER, Email::ALIAS_INBOX, [ + Email::ALIAS_INBOX . '.emailId:' => 'id', + Email::ALIAS_INBOX . '.deleted' => false, + Email::ALIAS_INBOX . '.userId' => $userId, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/TextFilter.php b/application/Espo/Classes/Select/Email/TextFilter.php new file mode 100644 index 0000000..973d751 --- /dev/null +++ b/application/Espo/Classes/Select/Email/TextFilter.php @@ -0,0 +1,128 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email; + +use Espo\Core\Select\Text\Filter; +use Espo\Core\Select\Text\Filter\Data; +use Espo\Core\Select\Text\DefaultFilter; +use Espo\Core\Select\Text\ConfigProvider; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\Entities\EmailAddress; + +/** + * @noinspection PhpUnused + */ +class TextFilter implements Filter +{ + public function __construct( + private DefaultFilter $defaultFilter, + private ConfigProvider $config, + private EntityManager $entityManager + ) {} + + public function apply(QueryBuilder $queryBuilder, Data $data): void + { + $filter = $data->getFilter(); + $ftWhereItem = $data->getFullTextSearchWhereItem(); + + if ( + mb_strlen($filter) < $this->config->getMinLengthForContentSearch() || + !str_contains($filter, '@') || + $data->forceFullTextSearch() + ) { + $this->defaultFilter->apply($queryBuilder, $data); + + return; + } + + $emailAddressId = $this->getEmailAddressIdByValue($filter); + + $orGroupBuilder = OrGroup::createBuilder(); + + if ($ftWhereItem && !$emailAddressId) { + $orGroupBuilder->add($ftWhereItem); + } + + if (!$emailAddressId) { + $orGroupBuilder->add( + Cond::equal(Expr::column('id'), null) + ); + + $queryBuilder->where($orGroupBuilder->build()); + + return; + } + + $orGroupBuilder + ->add( + Cond::equal( + Expr::column('fromEmailAddressId'), + $emailAddressId + ) + ) + ->add( + Cond::exists( + SelectBuilder::create() + ->from('EmailEmailAddress', 'sq') + ->where(['emailAddressId' => $emailAddressId]) + ->where( + Cond::equal( + Expr::column('sq.emailId'), + Expr::column('email.id') + ) + ) + ->build() + ) + ); + + $queryBuilder->where($orGroupBuilder->build()); + } + + private function getEmailAddressIdByValue(string $value): ?string + { + $emailAddress = $this->entityManager + ->getRDBRepository(EmailAddress::ENTITY_TYPE) + ->select(Attribute::ID) + ->where(['lower' => strtolower($value)]) + ->findOne(); + + if (!$emailAddress) { + return null; + } + + return $emailAddress->getId(); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/CcEquals.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/CcEquals.php new file mode 100644 index 0000000..362772c --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/CcEquals.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +class CcEquals extends ToEquals +{ + protected string $addressType = 'cc'; +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/EmailAddressEquals.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/EmailAddressEquals.php new file mode 100644 index 0000000..5ef60c5 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/EmailAddressEquals.php @@ -0,0 +1,83 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\EmailAddressHelper; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class EmailAddressEquals implements ItemConverter +{ + public function __construct( + private EmailAddressHelper $emailAddressHelper, + ) {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $value = $item->getValue(); + + if (!$value) { + return WhereClause::fromRaw([Attribute::ID => null]); + } + + $emailAddressId = $this->emailAddressHelper->getEmailAddressIdByValue($value); + + if (!$emailAddressId) { + return WhereClause::fromRaw([Attribute::ID => null]); + } + + return OrGroup::create( + Cond::equal( + Expr::column('fromEmailAddressId'), + $emailAddressId + ), + Cond::exists( + SelectBuilder::create() + ->from('EmailEmailAddress', 'sq') + ->where(['emailAddressId' => $emailAddressId]) + ->where( + Cond::equal( + Expr::column('sq.emailId'), + Expr::column('email.id') + ) + ) + ->build() + ) + ); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/FromEquals.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/FromEquals.php new file mode 100644 index 0000000..c615add --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/FromEquals.php @@ -0,0 +1,67 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\EmailAddressHelper; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class FromEquals implements ItemConverter +{ + public function __construct( + private EmailAddressHelper $emailAddressHelper + ) {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $value = $item->getValue(); + + if (!$value) { + return WhereClause::fromRaw([ + 'id' => null, + ]); + } + + $emailAddressId = $this->emailAddressHelper->getEmailAddressIdByValue($value); + + if (!$emailAddressId) { + return WhereClause::fromRaw([ + 'id' => null, + ]); + } + + return WhereClause::fromRaw([ + 'fromEmailAddressId' => $emailAddressId, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/InArchiveIsFalse.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/InArchiveIsFalse.php new file mode 100644 index 0000000..dcad7cf --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/InArchiveIsFalse.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class InArchiveIsFalse implements ItemConverter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.inArchive' => false, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/InArchiveIsTrue.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/InArchiveIsTrue.php new file mode 100644 index 0000000..0d41d01 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/InArchiveIsTrue.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class InArchiveIsTrue implements ItemConverter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.inArchive' => true, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/InFolder.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/InFolder.php new file mode 100644 index 0000000..7b1e174 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/InFolder.php @@ -0,0 +1,293 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Name\Link; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Core\Select\Where\Item; + +use Espo\Entities\Email; +use Espo\Entities\GroupEmailFolder; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\EntityManager; +use Espo\Entities\User; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Tools\Email\Folder; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class InFolder implements ItemConverter +{ + public function __construct( + private User $user, + private EntityManager $entityManager, + private JoinHelper $joinHelper, + private SelectBuilderFactory $selectBuilderFactory + ) {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $folderId = $item->getValue(); + + return match ($folderId) { + Folder::ALL => WhereClause::fromRaw([]), + Folder::INBOX => $this->convertInbox($queryBuilder), + Folder::IMPORTANT => $this->convertImportant($queryBuilder), + Folder::SENT => $this->convertSent($queryBuilder), + Folder::TRASH => $this->convertTrash($queryBuilder), + Folder::ARCHIVE => $this->convertArchive($queryBuilder), + Folder::DRAFTS => $this->convertDraft(), + default => $this->convertFolderId($queryBuilder, $folderId), + }; + } + + private function convertInbox(QueryBuilder $queryBuilder): WhereClauseItem + { + $this->joinEmailUser($queryBuilder); + + $groupEmailFoldersIds = $this->getUserGroupEmailFoldersIds(); + + $whereClause = [ + Email::ALIAS_INBOX . '.inTrash' => false, + Email::ALIAS_INBOX . '.inArchive' => false, + Email::ALIAS_INBOX . '.folderId' => null, + Email::ALIAS_INBOX . '.userId' => $this->user->getId(), + [ + 'status' => [ + Email::STATUS_ARCHIVED, + Email::STATUS_SENT, + ], + 'OR' => [ + 'groupFolderId' => null, + 'groupFolderId!=' => $groupEmailFoldersIds, + ] + ], + ]; + + $emailAddressIdList = $this->getEmailAddressIdList(); + + if ($emailAddressIdList !== []) { + $whereClause['fromEmailAddressId!='] = $emailAddressIdList; + + $whereClause[] = [ + 'OR' => [ + 'status' => Email::STATUS_ARCHIVED, + 'createdById!=' => $this->user->getId(), + ], + ]; + } else { + $whereClause[] = [ + 'status' => Email::STATUS_ARCHIVED, + 'createdById!=' => $this->user->getId(), + ]; + } + + return WhereClause::fromRaw($whereClause); + } + + private function convertSent(QueryBuilder $queryBuilder): WhereClauseItem + { + $this->joinEmailUser($queryBuilder); + + return WhereClause::fromRaw([ + 'OR' => [ + 'fromEmailAddressId' => $this->getEmailAddressIdList(), + [ + 'status' => Email::STATUS_SENT, + 'createdById' => $this->user->getId(), + ] + ], + [ + 'status!=' => Email::STATUS_DRAFT, + ], + Email::ALIAS_INBOX . '.inTrash' => false, + [ + 'OR' => [ + 'groupFolderId' => null, + 'groupFolderId!=' => Email::GROUP_STATUS_FOLDER_TRASH, + ] + ] + ]); + } + + private function convertImportant(QueryBuilder $queryBuilder): WhereClauseItem + { + $this->joinEmailUser($queryBuilder); + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.userId' => $this->user->getId(), + Email::ALIAS_INBOX . '.isImportant' => true, + ]); + } + + private function convertTrash(QueryBuilder $queryBuilder): WhereClauseItem + { + $this->joinEmailUser($queryBuilder); + + return WhereClause::fromRaw([ + 'OR' => [ + [ + Email::ALIAS_INBOX . '.userId' => $this->user->getId(), + Email::ALIAS_INBOX . '.inTrash' => true, + ], + [ + 'groupFolderId!=' => null, + 'groupStatusFolder' => Email::GROUP_STATUS_FOLDER_TRASH, + ], + ] + ]); + } + + private function convertArchive(QueryBuilder $queryBuilder): WhereClauseItem + { + $this->joinEmailUser($queryBuilder); + + return WhereClause::fromRaw([ + 'OR' => [ + [ + Email::ALIAS_INBOX . '.userId' => $this->user->getId(), + Email::ALIAS_INBOX . '.inArchive' => true, + ], + [ + 'groupFolderId!=' => null, + 'groupStatusFolder' => Email::GROUP_STATUS_FOLDER_ARCHIVE, + ], + ] + ]); + } + + private function convertDraft(): WhereClauseItem + { + return WhereClause::fromRaw([ + 'status' => Email::STATUS_DRAFT, + 'createdById' => $this->user->getId(), + ]); + } + + private function convertFolderId(QueryBuilder $queryBuilder, string $folderId): WhereClauseItem + { + $this->joinEmailUser($queryBuilder); + + if (str_starts_with($folderId, 'group:')) { + $groupFolderId = substr($folderId, 6); + + if ($groupFolderId === '') { + $groupFolderId = null; + } + + return WhereClause::fromRaw([ + 'groupFolderId' => $groupFolderId, + 'groupStatusFolder' => null, + 'fromEmailAddressId!=' => $this->getEmailAddressIdList(), + 'status' => [ + Email::STATUS_ARCHIVED, + Email::STATUS_SENT, + ], + 'OR' => [ + 'status' => Email::STATUS_ARCHIVED, + 'createdById!=' => $this->user->getId(), + ], + ]); + } + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.inTrash' => false, + Email::ALIAS_INBOX . '.inArchive' => false, + Email::ALIAS_INBOX . '.folderId' => $folderId, + 'groupFolderId' => null, + 'status' => [ + Email::STATUS_ARCHIVED, + Email::STATUS_SENT, + ], + ]); + } + + protected function joinEmailUser(QueryBuilder $queryBuilder): void + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + } + + /** + * @return string[] + */ + protected function getEmailAddressIdList(): array + { + $emailAddressList = $this->entityManager + ->getRelation($this->user, Link::EMAIL_ADDRESSES) + ->select([Attribute::ID]) + ->find(); + + $emailAddressIdList = []; + + foreach ($emailAddressList as $emailAddress) { + $emailAddressIdList[] = $emailAddress->getId(); + } + + return $emailAddressIdList; + } + + /** + * @return string[] + */ + private function getUserGroupEmailFoldersIds(): array + { + $selectBuilder = $this->selectBuilderFactory + ->create() + ->forUser($this->user) + ->from(GroupEmailFolder::ENTITY_TYPE) + ->withAccessControlFilter(); + + try { + $groupEmailFolders = $this->entityManager + ->getRDBRepositoryByClass(GroupEmailFolder::class) + ->clone($selectBuilder->build()) + ->select([Attribute::ID]) + ->find(); + } catch (BadRequest|Forbidden $e) { + throw new RuntimeException('', 0, $e); + } + + $groupEmailFoldersIds = []; + + foreach ($groupEmailFolders as $groupEmailFolder) { + $groupEmailFoldersIds[] = $groupEmailFolder->getId(); + } + + return $groupEmailFoldersIds; + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/InTrashIsFalse.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/InTrashIsFalse.php new file mode 100644 index 0000000..9ae24a0 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/InTrashIsFalse.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class InTrashIsFalse implements ItemConverter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.inTrash' => false, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/InTrashIsTrue.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/InTrashIsTrue.php new file mode 100644 index 0000000..4c51e55 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/InTrashIsTrue.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class InTrashIsTrue implements ItemConverter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.inTrash' => true, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/IsImportantIsFalse.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsImportantIsFalse.php new file mode 100644 index 0000000..d0cb829 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsImportantIsFalse.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class IsImportantIsFalse implements ItemConverter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.isImportant' => false, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/IsImportantIsTrue.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsImportantIsTrue.php new file mode 100644 index 0000000..300f0d2 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsImportantIsTrue.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class IsImportantIsTrue implements ItemConverter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.isImportant' => true, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotReadIsFalse.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotReadIsFalse.php new file mode 100644 index 0000000..15c007e --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotReadIsFalse.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class IsNotReadIsFalse implements ItemConverter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.isRead' => true, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotReadIsTrue.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotReadIsTrue.php new file mode 100644 index 0000000..6b81abd --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotReadIsTrue.php @@ -0,0 +1,58 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\JoinHelper; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class IsNotReadIsTrue implements ItemConverter +{ + public function __construct(private User $user, private JoinHelper $joinHelper) + {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $this->joinHelper->joinEmailUser($queryBuilder, $this->user->getId()); + + return WhereClause::fromRaw([ + Email::ALIAS_INBOX . '.isRead' => false, + 'OR' => [ + 'sentById' => null, + 'sentById!=' => $this->user->getId() + ], + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotRepliedIsFalse.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotRepliedIsFalse.php new file mode 100644 index 0000000..1b4e224 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotRepliedIsFalse.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class IsNotRepliedIsFalse implements ItemConverter +{ + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + return WhereClause::fromRaw([ + 'isReplied' => true, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotRepliedIsTrue.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotRepliedIsTrue.php new file mode 100644 index 0000000..b5611be --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/IsNotRepliedIsTrue.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class IsNotRepliedIsTrue implements ItemConverter +{ + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + return WhereClause::fromRaw([ + 'isReplied' => false, + ]); + } +} diff --git a/application/Espo/Classes/Select/Email/Where/ItemConverters/ToEquals.php b/application/Espo/Classes/Select/Email/Where/ItemConverters/ToEquals.php new file mode 100644 index 0000000..164fc03 --- /dev/null +++ b/application/Espo/Classes/Select/Email/Where/ItemConverters/ToEquals.php @@ -0,0 +1,81 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Email\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Classes\Select\Email\Helpers\EmailAddressHelper; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class ToEquals implements ItemConverter +{ + protected string $addressType = 'to'; + + public function __construct( + private EmailAddressHelper $emailAddressHelper, + ) {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $value = $item->getValue(); + + if (!$value) { + return WhereClause::fromRaw([Attribute::ID => null]); + } + + $emailAddressId = $this->emailAddressHelper->getEmailAddressIdByValue($value); + + if (!$emailAddressId) { + return WhereClause::fromRaw([Attribute::ID => null]); + } + + return Cond::exists( + SelectBuilder::create() + ->from('EmailEmailAddress', 'sq') + ->where([ + 'emailAddressId' => $emailAddressId, + 'addressType' => $this->addressType, + ]) + ->where( + Cond::equal( + Expr::column('sq.emailId'), + Expr::column('email.id') + ) + ) + ->build() + ); + } +} diff --git a/application/Espo/Classes/Select/EmailAccount/AccessControlFilters/Mandatory.php b/application/Espo/Classes/Select/EmailAccount/AccessControlFilters/Mandatory.php new file mode 100644 index 0000000..9743096 --- /dev/null +++ b/application/Espo/Classes/Select/EmailAccount/AccessControlFilters/Mandatory.php @@ -0,0 +1,51 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\EmailAccount\AccessControlFilters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\User; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class Mandatory implements Filter +{ + public function __construct(private User $user) + {} + + public function apply(QueryBuilder $queryBuilder): void + { + if ($this->user->isEffectiveAdmin()) { + return; + } + + $queryBuilder->where([ + 'assignedUserId' => $this->user->getId(), + ]); + } +} diff --git a/application/Espo/Classes/Select/EmailAccount/PrimaryFilters/Active.php b/application/Espo/Classes/Select/EmailAccount/PrimaryFilters/Active.php new file mode 100644 index 0000000..3f11523 --- /dev/null +++ b/application/Espo/Classes/Select/EmailAccount/PrimaryFilters/Active.php @@ -0,0 +1,42 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\EmailAccount\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\EmailFilter; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class Active implements Filter +{ + public function apply(QueryBuilder $queryBuilder): void + { + $queryBuilder->where(['status' => EmailFilter::STATUS_ACTIVE]); + } +} diff --git a/application/Espo/Classes/Select/EmailAddress/PrimaryFilters/Orphan.php b/application/Espo/Classes/Select/EmailAddress/PrimaryFilters/Orphan.php new file mode 100644 index 0000000..94f5116 --- /dev/null +++ b/application/Espo/Classes/Select/EmailAddress/PrimaryFilters/Orphan.php @@ -0,0 +1,72 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\EmailAddress\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; + +class Orphan implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder + ->distinct() + ->leftJoin( + 'EntityEmailAddress', + 'entityEmailAddress', + [ + 'emailAddressId:' => Attribute::ID, + Attribute::DELETED => false, + ] + ) + ->leftJoin( + 'EmailEmailAddress', + 'emailEmailAddress', + [ + 'emailAddressId:' => Attribute::ID, + Attribute::DELETED => false, + ] + ) + ->leftJoin( + 'Email', + 'email', + [ + 'fromEmailAddressId:' => Attribute::ID, + Attribute::DELETED => false, + ] + ) + ->where([ + 'entityEmailAddress.id' => null, + 'emailEmailAddress.id' => null, + 'email.id' => null, + ]); + } +} diff --git a/application/Espo/Classes/Select/EmailFilter/AccessControlFilters/OnlyOwn.php b/application/Espo/Classes/Select/EmailFilter/AccessControlFilters/OnlyOwn.php new file mode 100644 index 0000000..f131692 --- /dev/null +++ b/application/Espo/Classes/Select/EmailFilter/AccessControlFilters/OnlyOwn.php @@ -0,0 +1,87 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\EmailFilter\AccessControlFilters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\EmailAccount; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class OnlyOwn implements Filter +{ + private User $user; + private EntityManager $entityManager; + + public function __construct(User $user, EntityManager $entityManager) + { + $this->user = $user; + $this->entityManager = $entityManager; + } + + public function apply(QueryBuilder $queryBuilder): void + { + $part = []; + + $part[] = [ + 'parentType' => User::ENTITY_TYPE, + 'parentId' => $this->user->getId(), + ]; + + $idList = []; + + $emailAccountList = $this->entityManager + ->getRDBRepository(EmailAccount::ENTITY_TYPE) + ->select(Attribute::ID) + ->where([ + 'assignedUserId' => $this->user->getId(), + ]) + ->find(); + + foreach ($emailAccountList as $emailAccount) { + $idList[] = $emailAccount->getId(); + } + + if (count($idList)) { + $part = [ + 'OR' => [ + $part, + [ + 'parentType' => EmailAccount::ENTITY_TYPE, + 'parentId' => $idList, + ], + ] + ]; + } + + $queryBuilder->where($part); + } +} diff --git a/application/Espo/Classes/Select/EmailFilter/BoolFilters/OnlyMy.php b/application/Espo/Classes/Select/EmailFilter/BoolFilters/OnlyMy.php new file mode 100644 index 0000000..48c5fed --- /dev/null +++ b/application/Espo/Classes/Select/EmailFilter/BoolFilters/OnlyMy.php @@ -0,0 +1,83 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\EmailFilter\BoolFilters; + +use Espo\Core\Select\Bool\Filter; +use Espo\Entities\EmailAccount; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class OnlyMy implements Filter +{ + public function __construct(private User $user, private EntityManager $entityManager) + {} + + public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + $part = []; + + $part[] = [ + 'parentType' => User::ENTITY_TYPE, + 'parentId' => $this->user->getId(), + ]; + + $idList = []; + + $emailAccountList = $this->entityManager + ->getRDBRepository(EmailAccount::ENTITY_TYPE) + ->select(Attribute::ID) + ->where([ + 'assignedUserId' => $this->user->getId(), + ]) + ->find(); + + foreach ($emailAccountList as $emailAccount) { + $idList[] = $emailAccount->getId(); + } + + if (count($idList)) { + $part = [ + 'OR' => [ + $part, + [ + 'parentType' => EmailAccount::ENTITY_TYPE, + 'parentId' => $idList, + ], + ] + ]; + } + + $orGroupBuilder->add(WhereClause::fromRaw($part)); + } +} diff --git a/application/Espo/Classes/Select/EmailFolder/AccessControlFilters/Mandatory.php b/application/Espo/Classes/Select/EmailFolder/AccessControlFilters/Mandatory.php new file mode 100644 index 0000000..a1e2919 --- /dev/null +++ b/application/Espo/Classes/Select/EmailFolder/AccessControlFilters/Mandatory.php @@ -0,0 +1,51 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\EmailFolder\AccessControlFilters; + +use Espo\ORM\Query\SelectBuilder; +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\User; + +class Mandatory implements Filter +{ + public function __construct(private User $user) + {} + + public function apply(SelectBuilder $queryBuilder): void + { + if ($this->user->isEffectiveAdmin()) { + return; + } + + $queryBuilder->where([ + 'assignedUserId' => $this->user->getId(), + ]); + } +} diff --git a/application/Espo/Classes/Select/EmailTemplate/PrimaryFilters/Actual.php b/application/Espo/Classes/Select/EmailTemplate/PrimaryFilters/Actual.php new file mode 100644 index 0000000..59505df --- /dev/null +++ b/application/Espo/Classes/Select/EmailTemplate/PrimaryFilters/Actual.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\EmailTemplate\PrimaryFilters; + +use Espo\Entities\EmailTemplate; +use Espo\ORM\Query\SelectBuilder; + +use Espo\Core\Select\Primary\Filter; + +class Actual implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'oneOff!=' => true, + 'status' => EmailTemplate::STATUS_ACTIVE, + ]); + } +} diff --git a/application/Espo/Classes/Select/Event/PrimaryFilters/Held.php b/application/Espo/Classes/Select/Event/PrimaryFilters/Held.php new file mode 100644 index 0000000..27e9f6b --- /dev/null +++ b/application/Espo/Classes/Select/Event/PrimaryFilters/Held.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Event\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Query\SelectBuilder; + +class Held implements Filter +{ + public function __construct( + private string $entityType, + private Metadata $metadata + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + $statusList = $this->metadata->get(['scopes', $this->entityType, 'completedStatusList']) ?? []; + + $queryBuilder->where(['status' => $statusList]); + } +} diff --git a/application/Espo/Classes/Select/Event/PrimaryFilters/Planned.php b/application/Espo/Classes/Select/Event/PrimaryFilters/Planned.php new file mode 100644 index 0000000..9d877fb --- /dev/null +++ b/application/Espo/Classes/Select/Event/PrimaryFilters/Planned.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Event\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Query\SelectBuilder; + +class Planned implements Filter +{ + public function __construct( + private string $entityType, + private Metadata $metadata + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + $statusList = $this->metadata->get(['scopes', $this->entityType, 'activityStatusList']) ?? []; + + $queryBuilder->where(['status' => $statusList]); + } +} diff --git a/application/Espo/Classes/Select/Event/PrimaryFilters/Todays.php b/application/Espo/Classes/Select/Event/PrimaryFilters/Todays.php new file mode 100644 index 0000000..b9d6362 --- /dev/null +++ b/application/Espo/Classes/Select/Event/PrimaryFilters/Todays.php @@ -0,0 +1,69 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Event\PrimaryFilters; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Select\Primary\Filter; +use Espo\Core\Select\Helpers\UserTimeZoneProvider; +use Espo\Core\Select\Where\ConverterFactory; +use Espo\Core\Select\Where\Item; +use Espo\ORM\Query\SelectBuilder; +use Espo\Entities\User; +use RuntimeException; + +class Todays implements Filter +{ + public function __construct( + private User $user, + private UserTimeZoneProvider $userTimeZoneProvider, + private ConverterFactory $converterFactory, + private string $entityType + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + $item = Item::fromRaw([ + 'type' => Item\Type::TODAY, + 'attribute' => 'dateStart', + 'timeZone' => $this->userTimeZoneProvider->get(), + 'dateTime' => true, + ]); + + try { + $whereItem = $this->converterFactory + ->create($this->entityType, $this->user) + ->convert($queryBuilder, $item); + } catch (BadRequest $e) { + throw new RuntimeException('', 0, $e); + } + + $queryBuilder->where($whereItem); + } +} diff --git a/application/Espo/Classes/Select/GroupEmailFolder/AccessControlFilters/OnlyTeam.php b/application/Espo/Classes/Select/GroupEmailFolder/AccessControlFilters/OnlyTeam.php new file mode 100644 index 0000000..c415b87 --- /dev/null +++ b/application/Espo/Classes/Select/GroupEmailFolder/AccessControlFilters/OnlyTeam.php @@ -0,0 +1,56 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\GroupEmailFolder\AccessControlFilters; + +use Espo\Core\Name\Field; +use Espo\Core\Select\AccessControl\Filter; +use Espo\Core\Select\Helpers\RelationQueryHelper; +use Espo\Entities\GroupEmailFolder; +use Espo\Entities\User; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * @noinspection PhpUnused + */ +class OnlyTeam implements Filter +{ + public function __construct( + private User $user, + private RelationQueryHelper $relationQueryHelper, + ) {} + + public function apply(QueryBuilder $queryBuilder): void + { + $where = $this->relationQueryHelper + ->prepareLinkWhereMany(GroupEmailFolder::ENTITY_TYPE, Field::TEAMS, $this->user->getTeamIdList()); + + $queryBuilder->where($where); + } +} diff --git a/application/Espo/Classes/Select/Import/AccessControlFilters/Mandatory.php b/application/Espo/Classes/Select/Import/AccessControlFilters/Mandatory.php new file mode 100644 index 0000000..034a063 --- /dev/null +++ b/application/Espo/Classes/Select/Import/AccessControlFilters/Mandatory.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Import\AccessControlFilters; + +use Espo\ORM\Query\SelectBuilder; + +use Espo\Core\Select\AccessControl\Filter; + +use Espo\Entities\User; + +class Mandatory implements Filter +{ + public function __construct(private User $user) + {} + + public function apply(SelectBuilder $queryBuilder): void + { + if ($this->user->isEffectiveAdmin()) { + return; + } + + $queryBuilder->where([ + 'createdById' => $this->user->getId(), + ]); + } +} diff --git a/application/Espo/Classes/Select/ImportError/OrderItemConverters/ExportLineNumber.php b/application/Espo/Classes/Select/ImportError/OrderItemConverters/ExportLineNumber.php new file mode 100644 index 0000000..79030bc --- /dev/null +++ b/application/Espo/Classes/Select/ImportError/OrderItemConverters/ExportLineNumber.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\ImportError\OrderItemConverters; + +use Espo\Core\Select\Order\ItemConverter; +use Espo\Core\Select\Order\Item; + +use Espo\ORM\Query\Part\OrderList; +use Espo\ORM\Query\Part\Order; +use Espo\ORM\Query\Part\Expression as Expr; + +class ExportLineNumber implements ItemConverter +{ + public function convert(Item $item): OrderList + { + return OrderList::create([ + Order + ::create(Expr::column('exportRowIndex')) + ->withDirection($item->getOrder()) + ]); + } +} diff --git a/application/Espo/Classes/Select/ImportError/OrderItemConverters/LineNumber.php b/application/Espo/Classes/Select/ImportError/OrderItemConverters/LineNumber.php new file mode 100644 index 0000000..39b5fd5 --- /dev/null +++ b/application/Espo/Classes/Select/ImportError/OrderItemConverters/LineNumber.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\ImportError\OrderItemConverters; + +use Espo\Core\Select\Order\ItemConverter; +use Espo\Core\Select\Order\Item; + +use Espo\ORM\Query\Part\OrderList; +use Espo\ORM\Query\Part\Order; +use Espo\ORM\Query\Part\Expression as Expr; + +class LineNumber implements ItemConverter +{ + public function convert(Item $item): OrderList + { + return OrderList::create([ + Order + ::create(Expr::column('rowIndex')) + ->withDirection($item->getOrder()) + ]); + } +} diff --git a/application/Espo/Classes/Select/Note/BoolFilters/SkipOwn.php b/application/Espo/Classes/Select/Note/BoolFilters/SkipOwn.php new file mode 100644 index 0000000..2fc400d --- /dev/null +++ b/application/Espo/Classes/Select/Note/BoolFilters/SkipOwn.php @@ -0,0 +1,57 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Note\BoolFilters; + +use Espo\Core\Select\Bool\Filter; +use Espo\Entities\User; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class SkipOwn implements Filter +{ + private User $user; + + public function __construct(User $user) + { + $this->user = $user; + } + + public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + $orGroupBuilder->add( + Condition::notEqual( + Expression::column('createdById'), + $this->user->getId() + ) + ); + } +} diff --git a/application/Espo/Classes/Select/Note/PrimaryFilters/Posts.php b/application/Espo/Classes/Select/Note/PrimaryFilters/Posts.php new file mode 100644 index 0000000..09fd550 --- /dev/null +++ b/application/Espo/Classes/Select/Note/PrimaryFilters/Posts.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Note\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\Note; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class Posts implements Filter +{ + public function apply(QueryBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'type' => Note::TYPE_POST + ]); + } +} diff --git a/application/Espo/Classes/Select/Note/PrimaryFilters/Updates.php b/application/Espo/Classes/Select/Note/PrimaryFilters/Updates.php new file mode 100644 index 0000000..d3a2862 --- /dev/null +++ b/application/Espo/Classes/Select/Note/PrimaryFilters/Updates.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Note\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\Note; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class Updates implements Filter +{ + public function apply(QueryBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'type' => Note::TYPE_UPDATE, + ]); + } +} diff --git a/application/Espo/Classes/Select/OAuthClient/PrimaryFilters/Active.php b/application/Espo/Classes/Select/OAuthClient/PrimaryFilters/Active.php new file mode 100644 index 0000000..0e51259 --- /dev/null +++ b/application/Espo/Classes/Select/OAuthClient/PrimaryFilters/Active.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\OAuthClient\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Tools\OAuthServer\Entities\Client; +use Espo\ORM\Query\SelectBuilder; + +class Active implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + Client::FIELD_STATUS => Client::STATUS_ACTIVE, + ]); + } +} diff --git a/application/Espo/Classes/Select/OAuthClientSecret/PrimaryFilters/Active.php b/application/Espo/Classes/Select/OAuthClientSecret/PrimaryFilters/Active.php new file mode 100644 index 0000000..18a671f --- /dev/null +++ b/application/Espo/Classes/Select/OAuthClientSecret/PrimaryFilters/Active.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\OAuthClientSecret\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Tools\OAuthServer\Entities\ClientSecret; +use Espo\ORM\Query\SelectBuilder; + +class Active implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + ClientSecret::FIELD_STATUS => ClientSecret::STATUS_ACTIVE, + ]); + } +} diff --git a/application/Espo/Classes/Select/PhoneNumber/PrimaryFilters/Orphan.php b/application/Espo/Classes/Select/PhoneNumber/PrimaryFilters/Orphan.php new file mode 100644 index 0000000..022c5e5 --- /dev/null +++ b/application/Espo/Classes/Select/PhoneNumber/PrimaryFilters/Orphan.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\PhoneNumber\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; + +class Orphan implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'entityPhoneNumber.id' => null, + ]); + + $queryBuilder->leftJoin( + 'EntityPhoneNumber', + 'entityPhoneNumber', + [ + 'phoneNumberId:' => Attribute::ID, + Attribute::DELETED => false, + ] + ); + + $queryBuilder->distinct(); + } +} diff --git a/application/Espo/Classes/Select/Pipeline/AccessControlFilters/Mandatory.php b/application/Espo/Classes/Select/Pipeline/AccessControlFilters/Mandatory.php new file mode 100644 index 0000000..da579c3 --- /dev/null +++ b/application/Espo/Classes/Select/Pipeline/AccessControlFilters/Mandatory.php @@ -0,0 +1,72 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Pipeline\AccessControlFilters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\Pipeline; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; +use Espo\Tools\Pipeline\PipelineDataProvider; +use Espo\Tools\Pipeline\PipelineDataUserFilter; + +class Mandatory implements Filter +{ + public function __construct( + private User $user, + private PipelineDataProvider $pipelineDataProvider, + private PipelineDataUserFilter $filter, + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + if ($this->user->isEffectiveAdmin()) { + return; + } + + $data = $this->pipelineDataProvider->get(); + $data = $this->filter->filter($data); + + $entityTypes = array_keys($data); + + $ids = []; + + foreach ($data as $items) { + foreach ($items as $item) { + $ids[] = $item->id; + } + } + + $queryBuilder->where([ + Pipeline::FIELD_ENTITY_TYPE => $entityTypes, + Attribute::ID => $ids, + ]); + } +} diff --git a/application/Espo/Classes/Select/Pipeline/PrimaryFilters/Active.php b/application/Espo/Classes/Select/Pipeline/PrimaryFilters/Active.php new file mode 100644 index 0000000..d13b481 --- /dev/null +++ b/application/Espo/Classes/Select/Pipeline/PrimaryFilters/Active.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Pipeline\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\Pipeline; +use Espo\ORM\Query\SelectBuilder; + +class Active implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + Pipeline::FIELD_STATUS => Pipeline::STATUS_ACTIVE, + ]); + } +} diff --git a/application/Espo/Classes/Select/PipelineStage/AccessControlFilters/Mandatory.php b/application/Espo/Classes/Select/PipelineStage/AccessControlFilters/Mandatory.php new file mode 100644 index 0000000..19cc9ed --- /dev/null +++ b/application/Espo/Classes/Select/PipelineStage/AccessControlFilters/Mandatory.php @@ -0,0 +1,77 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\PipelineStage\AccessControlFilters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\Pipeline; +use Espo\Entities\PipelineStage; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; +use Espo\Tools\Pipeline\PipelineDataProvider; +use Espo\Tools\Pipeline\PipelineDataUserFilter; + +class Mandatory implements Filter +{ + public function __construct( + private User $user, + private PipelineDataProvider $pipelineDataProvider, + private PipelineDataUserFilter $filter, + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + if ($this->user->isEffectiveAdmin()) { + return; + } + + $data = $this->pipelineDataProvider->get(); + $data = $this->filter->filter($data); + + $entityTypes = array_keys($data); + + $ids = []; + + foreach ($data as $items) { + foreach ($items as $item) { + $ids[] = $item->id; + } + } + + $alias = 'pipelineAccess'; + + $queryBuilder + ->join(PipelineStage::FIELD_PIPELINE, $alias) + ->where([ + $alias . '.' . Pipeline::FIELD_ENTITY_TYPE => $entityTypes, + $alias . '.' . Attribute::ID => $ids, + ]); + } +} diff --git a/application/Espo/Classes/Select/PipelineStage/ListOrderOrderer.php b/application/Espo/Classes/Select/PipelineStage/ListOrderOrderer.php new file mode 100644 index 0000000..51a9ea3 --- /dev/null +++ b/application/Espo/Classes/Select/PipelineStage/ListOrderOrderer.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\PipelineStage; + +use Espo\Core\Select\Order\Item; +use Espo\Core\Select\Order\Orderer; +use Espo\Entities\PipelineStage; +use Espo\ORM\Query\SelectBuilder; + +/** + * @noinspection PhpUnused + */ +class ListOrderOrderer implements Orderer +{ + public function apply(SelectBuilder $queryBuilder, Item $item): void + { + $queryBuilder + ->leftJoin(PipelineStage::FIELD_PIPELINE) + ->order(PipelineStage::FIELD_PIPELINE . '.' . PipelineStage::FIELD_ORDER, $item->getOrder()) + ->order(PipelineStage::FIELD_ORDER, $item->getOrder()); + } +} diff --git a/application/Espo/Classes/Select/ScheduledJob/AccessControlFilters/Mandatory.php b/application/Espo/Classes/Select/ScheduledJob/AccessControlFilters/Mandatory.php new file mode 100644 index 0000000..d774811 --- /dev/null +++ b/application/Espo/Classes/Select/ScheduledJob/AccessControlFilters/Mandatory.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\ScheduledJob\AccessControlFilters; + +use Espo\ORM\Query\SelectBuilder; + +use Espo\Core\Select\AccessControl\Filter; + +class Mandatory implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'isInternal' => false + ]); + } +} diff --git a/application/Espo/Classes/Select/Team/AccessControlFilters/OnlyTeam.php b/application/Espo/Classes/Select/Team/AccessControlFilters/OnlyTeam.php new file mode 100644 index 0000000..417d825 --- /dev/null +++ b/application/Espo/Classes/Select/Team/AccessControlFilters/OnlyTeam.php @@ -0,0 +1,60 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Team\AccessControlFilters; + +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\Core\Select\AccessControl\Filter; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\SelectBuilder; + +/** + * @noinspection PhpUnused + */ +class OnlyTeam implements Filter +{ + public function __construct(private User $user) + {} + + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where( + Condition::in( + Expression::column('id'), + SelectBuilder::create() + ->from(Team::RELATIONSHIP_TEAM_USER) + ->select('teamId') + ->where(['userId' => $this->user->getId()]) + ->build() + ) + ); + } +} diff --git a/application/Espo/Classes/Select/Team/BoolFilters/OnlyMy.php b/application/Espo/Classes/Select/Team/BoolFilters/OnlyMy.php new file mode 100644 index 0000000..35cf208 --- /dev/null +++ b/application/Espo/Classes/Select/Team/BoolFilters/OnlyMy.php @@ -0,0 +1,61 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Team\BoolFilters; + +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\Core\Select\Bool\Filter; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\SelectBuilder; + +/** + * @noinspection PhpUnused + */ +class OnlyMy implements Filter +{ + public function __construct(private User $user) + {} + + public function apply(SelectBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + $orGroupBuilder->add( + Condition::in( + Expression::column('id'), + SelectBuilder::create() + ->from(Team::RELATIONSHIP_TEAM_USER) + ->select('teamId') + ->where(['userId' => $this->user->getId()]) + ->build() + ) + ); + } +} diff --git a/application/Espo/Classes/Select/Template/AccessControlFilters/Mandatory.php b/application/Espo/Classes/Select/Template/AccessControlFilters/Mandatory.php new file mode 100644 index 0000000..f814ffb --- /dev/null +++ b/application/Espo/Classes/Select/Template/AccessControlFilters/Mandatory.php @@ -0,0 +1,69 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Template\AccessControlFilters; + +use Espo\ORM\Defs; +use Espo\ORM\Query\SelectBuilder; +use Espo\Core\AclManager; +use Espo\Core\Select\AccessControl\Filter; + +use Espo\Entities\User; + +class Mandatory implements Filter +{ + public function __construct( + private User $user, + private Defs $defs, + private AclManager $aclManager + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + if ($this->user->isEffectiveAdmin()) { + return; + } + + $forbiddenEntityTypeList = []; + + foreach ($this->defs->getEntityTypeList() as $entityType) { + if (!$this->aclManager->tryCheck($this->user, $entityType)) { + $forbiddenEntityTypeList[] = $entityType; + } + } + + if (empty($forbiddenEntityTypeList)) { + return; + } + + $queryBuilder->where([ + 'entityType!=' => $forbiddenEntityTypeList, + ]); + } +} diff --git a/application/Espo/Classes/Select/Template/PrimaryFilters/Active.php b/application/Espo/Classes/Select/Template/PrimaryFilters/Active.php new file mode 100644 index 0000000..88694e7 --- /dev/null +++ b/application/Espo/Classes/Select/Template/PrimaryFilters/Active.php @@ -0,0 +1,42 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Template\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\Template; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class Active implements Filter +{ + public function apply(QueryBuilder $queryBuilder): void + { + $queryBuilder->where(['status' => Template::STATUS_ACTIVE]); + } +} diff --git a/application/Espo/Classes/Select/User/AccessControlFilters/Mandatory.php b/application/Espo/Classes/Select/User/AccessControlFilters/Mandatory.php new file mode 100644 index 0000000..faf9a32 --- /dev/null +++ b/application/Espo/Classes/Select/User/AccessControlFilters/Mandatory.php @@ -0,0 +1,74 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\AccessControlFilters; + +use Espo\Core\Acl\Permission; +use Espo\ORM\Query\SelectBuilder; +use Espo\Core\Acl\Table; +use Espo\Core\AclManager; +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\User; + +class Mandatory implements Filter +{ + public function __construct( + private User $user, + private AclManager $aclManager + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + if (!$this->user->isEffectiveAdmin()) { + $queryBuilder->where([ + 'isActive' => true, + 'type!=' => User::TYPE_API, + ]); + } + + if ($this->aclManager->getPermissionLevel($this->user, Permission::PORTAL) !== Table::LEVEL_YES) { + $queryBuilder->where([ + 'OR' => [ + 'type!=' => User::TYPE_PORTAL, + 'id' => $this->user->getId(), + ] + ]); + } + + if (!$this->user->isSuperAdmin()) { + $queryBuilder->where([ + 'type!=' => User::TYPE_SUPER_ADMIN, + ]); + } + + $queryBuilder->where([ + 'type!=' => User::TYPE_SYSTEM, + ]); + } +} diff --git a/application/Espo/Classes/Select/User/AccessControlFilters/OnlyOwn.php b/application/Espo/Classes/Select/User/AccessControlFilters/OnlyOwn.php new file mode 100644 index 0000000..2db8b66 --- /dev/null +++ b/application/Espo/Classes/Select/User/AccessControlFilters/OnlyOwn.php @@ -0,0 +1,62 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\AccessControlFilters; + +use Espo\Core\Acl\Permission; +use Espo\ORM\Query\SelectBuilder; +use Espo\Core\Acl\Table; +use Espo\Core\AclManager; +use Espo\Core\Select\AccessControl\Filter; + +use Espo\Entities\User; + +class OnlyOwn implements Filter +{ + public function __construct(private User $user, private AclManager $aclManager) + {} + + public function apply(SelectBuilder $queryBuilder): void + { + if ($this->aclManager->getPermissionLevel($this->user, Permission::PORTAL) === Table::LEVEL_YES) { + $queryBuilder->where([ + 'OR' => [ + 'id' => $this->user->getId(), + 'type' => User::TYPE_PORTAL, + ], + ]); + + return; + } + + $queryBuilder->where([ + 'id' => $this->user->getId(), + ]); + } +} diff --git a/application/Espo/Classes/Select/User/AccessControlFilters/OnlyTeam.php b/application/Espo/Classes/Select/User/AccessControlFilters/OnlyTeam.php new file mode 100644 index 0000000..76d53dd --- /dev/null +++ b/application/Espo/Classes/Select/User/AccessControlFilters/OnlyTeam.php @@ -0,0 +1,87 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\AccessControlFilters; + +use Espo\Core\Acl\Permission; +use Espo\Entities\Team; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\SelectBuilder; +use Espo\Core\Acl\Table; +use Espo\Core\AclManager; +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\User; + +/** + * @noinspection PhpUnused + */ +class OnlyTeam implements Filter +{ + public function __construct( + private User $user, + private AclManager $aclManager + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + $orGroupBuilder = OrGroup::createBuilder(); + + $orGroupBuilder + ->add( + Condition::equal( + Expression::column('id'), + $this->user->getId() + ) + ) + ->add( + Condition::in( + Expression::column('id'), + SelectBuilder::create() + ->from(Team::RELATIONSHIP_TEAM_USER) + ->select('userId') + ->where(['teamId' => $this->user->getTeamIdList()]) + ->build() + ) + ); + + + if ($this->aclManager->getPermissionLevel($this->user, Permission::PORTAL) === Table::LEVEL_YES) { + $orGroupBuilder->add( + Condition::equal( + Expression::column('type'), + User::TYPE_PORTAL + ) + ); + } + + $queryBuilder->where($orGroupBuilder->build()); + } +} diff --git a/application/Espo/Classes/Select/User/AccessControlFilters/PortalOnlyOwn.php b/application/Espo/Classes/Select/User/AccessControlFilters/PortalOnlyOwn.php new file mode 100644 index 0000000..a66cedf --- /dev/null +++ b/application/Espo/Classes/Select/User/AccessControlFilters/PortalOnlyOwn.php @@ -0,0 +1,48 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\AccessControlFilters; + +use Espo\ORM\Query\SelectBuilder; +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\User; + +class PortalOnlyOwn implements Filter +{ + + public function __construct(private User $user) + {} + + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'id' => $this->user->getId(), + ]); + } +} diff --git a/application/Espo/Classes/Select/User/BoolFilters/OnlyMe.php b/application/Espo/Classes/Select/User/BoolFilters/OnlyMe.php new file mode 100644 index 0000000..b59d62c --- /dev/null +++ b/application/Espo/Classes/Select/User/BoolFilters/OnlyMe.php @@ -0,0 +1,48 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\BoolFilters; + +use Espo\Core\Select\Bool\Filter; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class OnlyMe implements Filter +{ + public function __construct( + private User $user + ) {} + + public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + $queryBuilder->where([Attribute::ID => $this->user->getId()]); + } +} diff --git a/application/Espo/Classes/Select/User/BoolFilters/OnlyMyTeam.php b/application/Espo/Classes/Select/User/BoolFilters/OnlyMyTeam.php new file mode 100644 index 0000000..ebf03b2 --- /dev/null +++ b/application/Espo/Classes/Select/User/BoolFilters/OnlyMyTeam.php @@ -0,0 +1,73 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\BoolFilters; + +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\Core\Select\Bool\Filter; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\SelectBuilder; + +/** + * @noinspection PhpUnused + */ +class OnlyMyTeam implements Filter +{ + public function __construct(private User $user) + {} + + public function apply(SelectBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + $teamIdList = $this->user->getTeamIdList(); + + if (count($teamIdList) === 0) { + $orGroupBuilder->add( + WhereClause::fromRaw([Attribute::ID => null]) + ); + + return; + } + + $orGroupBuilder->add( + Condition::in( + Expression::column(Attribute::ID), + SelectBuilder::create() + ->from(Team::RELATIONSHIP_TEAM_USER) + ->select('userId') + ->where(['teamId' => $teamIdList]) + ->build() + ) + ); + } +} diff --git a/application/Espo/Classes/Select/User/OrderItemConverters/UserNameOwnFirst.php b/application/Espo/Classes/Select/User/OrderItemConverters/UserNameOwnFirst.php new file mode 100644 index 0000000..84701b8 --- /dev/null +++ b/application/Espo/Classes/Select/User/OrderItemConverters/UserNameOwnFirst.php @@ -0,0 +1,66 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\OrderItemConverters; + +use Espo\Core\Select\Order\ItemConverter; +use Espo\Core\Select\Order\Item; + +use Espo\ORM\Query\Part\OrderList; +use Espo\ORM\Query\Part\Order; +use Espo\ORM\Query\Part\Expression as Expr; + +use Espo\Entities\User; + +class UserNameOwnFirst implements ItemConverter +{ + private User $user; + + public function __construct(User $user) + { + $this->user = $user; + } + + public function convert(Item $item): OrderList + { + return OrderList::create([ + Order + ::create( + Expr::notEqual( + Expr::column('id'), + $this->user->getId() + ) + ) + ->withDirection($item->getOrder()), + Order + ::create(Expr::column('userName')) + ->withDirection($item->getOrder()), + ]); + } +} diff --git a/application/Espo/Classes/Select/User/PrimaryFilters/Active.php b/application/Espo/Classes/Select/User/PrimaryFilters/Active.php new file mode 100644 index 0000000..becc13c --- /dev/null +++ b/application/Espo/Classes/Select/User/PrimaryFilters/Active.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder; + +class Active implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'isActive' => true, + 'type' => ['regular', 'admin'], + ]); + } +} diff --git a/application/Espo/Classes/Select/User/PrimaryFilters/ActiveApi.php b/application/Espo/Classes/Select/User/PrimaryFilters/ActiveApi.php new file mode 100644 index 0000000..c682a80 --- /dev/null +++ b/application/Espo/Classes/Select/User/PrimaryFilters/ActiveApi.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder; + +class ActiveApi implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'isActive' => true, + 'type' => 'api', + ]); + } +} diff --git a/application/Espo/Classes/Select/User/PrimaryFilters/ActivePortal.php b/application/Espo/Classes/Select/User/PrimaryFilters/ActivePortal.php new file mode 100644 index 0000000..06e349a --- /dev/null +++ b/application/Espo/Classes/Select/User/PrimaryFilters/ActivePortal.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder; + +class ActivePortal implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'isActive' => true, + 'type' => 'portal', + ]); + } +} diff --git a/application/Espo/Classes/Select/User/PrimaryFilters/Api.php b/application/Espo/Classes/Select/User/PrimaryFilters/Api.php new file mode 100644 index 0000000..fb32a9e --- /dev/null +++ b/application/Espo/Classes/Select/User/PrimaryFilters/Api.php @@ -0,0 +1,43 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder; + +class Api implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'type' => 'api', + ]); + } +} diff --git a/application/Espo/Classes/Select/User/PrimaryFilters/Internal.php b/application/Espo/Classes/Select/User/PrimaryFilters/Internal.php new file mode 100644 index 0000000..f12462d --- /dev/null +++ b/application/Espo/Classes/Select/User/PrimaryFilters/Internal.php @@ -0,0 +1,48 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\User; +use Espo\ORM\Query\SelectBuilder; + +class Internal implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'type!=' => [ + User::TYPE_PORTAL, + User::TYPE_API, + User::TYPE_SYSTEM, + ], + ]); + } +} diff --git a/application/Espo/Classes/Select/User/PrimaryFilters/Portal.php b/application/Espo/Classes/Select/User/PrimaryFilters/Portal.php new file mode 100644 index 0000000..2009260 --- /dev/null +++ b/application/Espo/Classes/Select/User/PrimaryFilters/Portal.php @@ -0,0 +1,43 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\PrimaryFilters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder; + +class Portal implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([ + 'type' => 'portal', + ]); + } +} diff --git a/application/Espo/Classes/Select/User/Where/ItemConverters/IsOfType.php b/application/Espo/Classes/Select/User/Where/ItemConverters/IsOfType.php new file mode 100644 index 0000000..bcd3b7b --- /dev/null +++ b/application/Espo/Classes/Select/User/Where/ItemConverters/IsOfType.php @@ -0,0 +1,63 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\User\Where\ItemConverters; + +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class IsOfType implements ItemConverter +{ + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $type = $item->getValue(); + + return match ($type) { + 'internal' => WhereClause::fromRaw([ + 'type!=' => [ + User::TYPE_PORTAL, + User::TYPE_API, + User::TYPE_SYSTEM, + ], + ]), + User::TYPE_PORTAL => WhereClause::fromRaw([ + 'type' => User::TYPE_PORTAL, + ]), + User::TYPE_API => WhereClause::fromRaw([ + 'type' => User::TYPE_API, + ]), + default => WhereClause::fromRaw([Attribute::ID => null]), + }; + } +} diff --git a/application/Espo/Classes/Select/Webhook/AccessControlFilters/Mandatory.php b/application/Espo/Classes/Select/Webhook/AccessControlFilters/Mandatory.php new file mode 100644 index 0000000..d4b33d2 --- /dev/null +++ b/application/Espo/Classes/Select/Webhook/AccessControlFilters/Mandatory.php @@ -0,0 +1,65 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\Webhook\AccessControlFilters; + +use Espo\ORM\Query\SelectBuilder; + +use Espo\Core\Select\AccessControl\Filter; + +use Espo\Entities\User; + +class Mandatory implements Filter +{ + private $user; + + public function __construct(User $user) + { + $this->user = $user; + } + + public function apply(SelectBuilder $queryBuilder): void + { + if ($this->user->isEffectiveAdmin()) { + return; + } + + if (!$this->user->isApi()) { + $queryBuilder->where([ + 'id' => null, + ]); + + return; + } + + $queryBuilder->where([ + 'userId' => $this->user->getId() + ]); + } +} diff --git a/application/Espo/Classes/Select/WorkingTimeRange/PrimaryFilters/Actual.php b/application/Espo/Classes/Select/WorkingTimeRange/PrimaryFilters/Actual.php new file mode 100644 index 0000000..e493964 --- /dev/null +++ b/application/Espo/Classes/Select/WorkingTimeRange/PrimaryFilters/Actual.php @@ -0,0 +1,48 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Select\WorkingTimeRange\PrimaryFilters; + +use Espo\Core\Field\Date; +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class Actual implements Filter +{ + public function apply(QueryBuilder $queryBuilder): void + { + $queryBuilder->where( + Expression::greaterOrEqual( + Expression::column('dateEnd'), + Date::createToday()->toString() + ) + ); + } +} diff --git a/application/Espo/Classes/TemplateHelpers/CurrencySymbol.php b/application/Espo/Classes/TemplateHelpers/CurrencySymbol.php new file mode 100644 index 0000000..862a70b --- /dev/null +++ b/application/Espo/Classes/TemplateHelpers/CurrencySymbol.php @@ -0,0 +1,58 @@ +. + * + * 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\TemplateHelpers; + +use Espo\Core\Htmlizer\Helper; +use Espo\Core\Htmlizer\Helper\Data; +use Espo\Core\Htmlizer\Helper\Result; +use Espo\Core\Utils\Metadata; + +/** + * @noinspection PhpUnused + */ +class CurrencySymbol implements Helper +{ + public function __construct( + private Metadata $metadata, + ) {} + + public function render(Data $data): Result + { + $code = $data->getArgumentList()[0] ?? null; + + if (!$code || !is_string($code)) { + return Result::createEmpty(); + } + + $symbol = $this->metadata->get("app.currency.symbolMap.$code"); + + return Result::create($symbol); + } +} diff --git a/application/Espo/Classes/TemplateHelpers/GoogleMaps.php b/application/Espo/Classes/TemplateHelpers/GoogleMaps.php new file mode 100644 index 0000000..f0526d8 --- /dev/null +++ b/application/Espo/Classes/TemplateHelpers/GoogleMaps.php @@ -0,0 +1,219 @@ +. + * + * 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\TemplateHelpers; + +use Espo\Core\Htmlizer\Helper; +use Espo\Core\Htmlizer\Helper\Data; +use Espo\Core\Htmlizer\Helper\Result; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Log; +use const CURLOPT_FOLLOWLOCATION; +use const CURLOPT_HEADER; +use const CURLOPT_HTTPHEADER; +use const CURLOPT_RETURNTRANSFER; +use const CURLOPT_TIMEOUT; +use const CURLOPT_URL; +use const CURLOPT_USERAGENT; + +class GoogleMaps implements Helper +{ + private const DEFAULT_SIZE = '400x400'; + + public function __construct( + private Metadata $metadata, + private Config $config, + private Log $log, + ) {} + + public function render(Data $data): Result + { + $rootContext = $data->getRootContext(); + + $entityType = $rootContext['__entityType']; + + $field = $data->getOption('field'); + $size = $data->getOption('size') ?? self::DEFAULT_SIZE; + $zoom = $data->getOption('zoom'); + $language = $data->getOption('language') ?? $this->config->get('language'); + + if (strpos($size, 'x') === false) { + $size = $size . 'x' . $size; + } + + if ($field && $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']) !== 'address') { + $this->log->warning("Template helper _googleMapsImage: Specified field is not of address type."); + + return Result::createEmpty(); + } + + if ( + !$field && + !$data->hasOption('street') && + !$data->hasOption('city') && + !$data->hasOption('country') && + !$data->hasOption('state') && + !$data->hasOption('postalCode') + ) { + $field = ($entityType === 'Account') ? 'billingAddress' : 'address'; + } + + if ($field) { + $street = $rootContext[$field . 'Street'] ?? null; + $city = $rootContext[$field . 'City'] ?? null; + $country = $rootContext[$field . 'Country'] ?? null; + $state = $rootContext[$field . 'State'] ?? null; + $postalCode = $rootContext[$field . 'postalCode'] ?? null; + } else { + $street = $data->getOption('street'); + $city = $data->getOption('city'); + $country = $data->getOption('country'); + $state = $data->getOption('state'); + $postalCode = $data->getOption('postalCode'); + } + + $address = ''; + + if ($street) { + $address .= $street; + } + + if ($city) { + if ($address != '') { + $address .= ', '; + } + + $address .= $city; + } + + if ($state) { + if ($address != '') { + $address .= ', '; + } + + $address .= $state; + } + + if ($postalCode) { + if ($state || $city) { + $address .= ' '; + } + else if ($address) { + $address .= ', '; + } + + $address .= $postalCode; + } + + if ($country) { + if ($address != '') { + $address .= ', '; + } + + $address .= $country; + } + + $apiKey = $this->config->get('googleMapsApiKey'); + + if (!$apiKey) { + $this->log->error("Template helper _googleMapsImage: No Google Maps API key."); + + return Result::createEmpty(); + } + + $addressEncoded = urlencode($address); + + if (!$addressEncoded) { + $this->log->debug("Template helper _googleMapsImage: No address to display."); + + return Result::createEmpty(); + } + + $format = 'jpg;'; + + $url = "https://maps.googleapis.com/maps/api/staticmap?" . + 'center=' . $addressEncoded . + '&format=' . $format . + '&size=' . $size . + '&key=' . $apiKey; + + if ($zoom) { + $url .= '&zoom=' . $zoom; + } + + if ($language) { + $url .= '&language=' . $language; + } + + $this->log->debug("Template helper _googleMapsImage: URL: {$url}."); + + $image = $this->getImage($url); + + if (!$image) { + return Result::createEmpty(); + } + + list($width, $height) = explode('x', $size); + + $src = '@' . base64_encode($image); /** @phpstan-ignore-line */ + + $tag = ""; + + return Result::createSafeString($tag); + } + + /** + * @param non-empty-string $url + * @return string|bool + */ + private function getImage(string $url) + { + $headers = [ + 'Accept: image/jpeg, image/pjpeg', + 'Connection: Keep-Alive', + ]; + + $agent = 'Mozilla/5.0'; + + $c = curl_init(); + + curl_setopt($c, CURLOPT_URL, $url); + curl_setopt($c, CURLOPT_HTTPHEADER, $headers); + curl_setopt($c, CURLOPT_HEADER, false); + curl_setopt($c, CURLOPT_USERAGENT, $agent); + curl_setopt($c, CURLOPT_TIMEOUT, 10); + curl_setopt($c, CURLOPT_RETURNTRANSFER, true); + curl_setopt($c, CURLOPT_FOLLOWLOCATION, true); + + $raw = curl_exec($c); + + return $raw; + } +} diff --git a/application/Espo/Classes/TemplateHelpers/MarkdownText.php b/application/Espo/Classes/TemplateHelpers/MarkdownText.php new file mode 100644 index 0000000..5b777e3 --- /dev/null +++ b/application/Espo/Classes/TemplateHelpers/MarkdownText.php @@ -0,0 +1,51 @@ +. + * + * 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\TemplateHelpers; + +use Espo\Core\Htmlizer\Helper; +use Espo\Core\Htmlizer\Helper\Data; +use Espo\Core\Htmlizer\Helper\Result; +use Espo\Core\Utils\Markdown\Markdown; + +class MarkdownText implements Helper +{ + public function render(Data $data): Result + { + $value = $data->getArgumentList()[0] ?? null; + + if (!$value || !is_string($value)) { + return Result::createEmpty(); + } + + $transformed = Markdown::transform($value); + + return Result::createSafeString($transformed); + } +} diff --git a/application/Espo/Classes/TemplateHelpers/TableTag.php b/application/Espo/Classes/TemplateHelpers/TableTag.php new file mode 100644 index 0000000..087fda8 --- /dev/null +++ b/application/Espo/Classes/TemplateHelpers/TableTag.php @@ -0,0 +1,62 @@ +. + * + * 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\TemplateHelpers; + +use Espo\Core\Htmlizer\Helper; +use Espo\Core\Htmlizer\Helper\Data; +use Espo\Core\Htmlizer\Helper\Result; + +class TableTag implements Helper +{ + public function render(Data $data): Result + { + $border = $data->getOption('border') ?? '0.5pt'; + $cellpadding = $data->getOption('cellpadding') ?? '2'; + $width = $data->getOption('width') ?? null; + + $attributesPart = ""; + + if ($width) { + $attributesPart .= " width=\"{$width}\""; + } + + $function = $data->getFunction(); + + $content = $function !== null ? $function() : ''; + + $style = "border: {$border}; border-spacing: 0; border-collapse: collapse;"; + + return Result::createSafeString( + "" . + $content . + "
" + ); + } +} diff --git a/application/Espo/Classes/TemplateHelpers/TdTag.php b/application/Espo/Classes/TemplateHelpers/TdTag.php new file mode 100644 index 0000000..db1dfeb --- /dev/null +++ b/application/Espo/Classes/TemplateHelpers/TdTag.php @@ -0,0 +1,62 @@ +. + * + * 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\TemplateHelpers; + +use Espo\Core\Htmlizer\Helper; +use Espo\Core\Htmlizer\Helper\Data; +use Espo\Core\Htmlizer\Helper\Result; + +class TdTag implements Helper +{ + public function render(Data $data): Result + { + $align = strtolower($data->getOption('align') ?? 'left'); + + if (!in_array($align, ['left', 'right', 'center'])) { + $align = 'left'; + } + + $width = $data->getOption('width') ?? null; + + $attributesPart = "align=\"{$align}\""; + + if ($width) { + $attributesPart .= " width=\"{$width}\""; + } + + $function = $data->getFunction(); + + $content = $function !== null ? $function() : ''; + + return Result::createSafeString( + "{$content}" + ); + } +} diff --git a/application/Espo/Classes/TemplateHelpers/TrTag.php b/application/Espo/Classes/TemplateHelpers/TrTag.php new file mode 100644 index 0000000..43c74dc --- /dev/null +++ b/application/Espo/Classes/TemplateHelpers/TrTag.php @@ -0,0 +1,48 @@ +. + * + * 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\TemplateHelpers; + +use Espo\Core\Htmlizer\Helper; +use Espo\Core\Htmlizer\Helper\Data; +use Espo\Core\Htmlizer\Helper\Result; + +class TrTag implements Helper +{ + public function render(Data $data): Result + { + $function = $data->getFunction(); + + $content = $function !== null ? $function() : ''; + + return Result::createSafeString( + "" . $content . "" + ); + } +} diff --git a/application/Espo/Controllers/ActionHistoryRecord.php b/application/Espo/Controllers/ActionHistoryRecord.php new file mode 100644 index 0000000..016167f --- /dev/null +++ b/application/Espo/Controllers/ActionHistoryRecord.php @@ -0,0 +1,50 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; + +use stdClass; + +class ActionHistoryRecord extends RecordBase +{ + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/AddressCountry.php b/application/Espo/Controllers/AddressCountry.php new file mode 100644 index 0000000..f5bad85 --- /dev/null +++ b/application/Espo/Controllers/AddressCountry.php @@ -0,0 +1,50 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\RecordBase; +use Espo\Tools\Address\CountryDefaultsPopulator; + +class AddressCountry extends RecordBase +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + public function postActionPopulateDefaults(): bool + { + $populate = $this->injectableFactory->create(CountryDefaultsPopulator::class); + + $populate->populate(); + + return true; + } +} diff --git a/application/Espo/Controllers/Admin.php b/application/Espo/Controllers/Admin.php new file mode 100644 index 0000000..320630b --- /dev/null +++ b/application/Espo/Controllers/Admin.php @@ -0,0 +1,197 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Container; +use Espo\Core\DataManager; +use Espo\Core\Api\Request; +use Espo\Core\Utils\Config; +use Espo\Tools\AdminNotifications\Manager; +use Espo\Core\Utils\SystemRequirements; +use Espo\Core\Utils\ScheduledJob; +use Espo\Core\Upgrades\UpgradeManager; +use Espo\Entities\User; + +class Admin +{ + /** + * @throws Forbidden + */ + public function __construct( + private Container $container, + private Config $config, + private User $user, + private Manager $adminNotificationManager, + private SystemRequirements $systemRequirements, + private ScheduledJob $scheduledJob, + private DataManager $dataManager, + private Config\SystemConfig $systemConfig, + ) { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + } + + /** + * @throws Error + */ + public function postActionRebuild(): bool + { + $this->dataManager->rebuild(); + + return true; + } + + /** + * @throws Error + */ + public function postActionClearCache(): bool + { + $this->dataManager->clearCache(); + + return true; + } + + /** + * @return string[] + */ + public function getActionJobs(): array + { + return $this->scheduledJob->getAvailableList(); + } + + /** + * @return object{ + * id: string, + * version: string, + * } + * @throws Forbidden + * @throws Error + * @throws BadRequest + */ + public function postActionUploadUpgradePackage(Request $request): object + { + $this->assertUpgradeAllowed(); + + $data = $request->getBodyContents(); + + if (!$data) { + throw new BadRequest(); + } + + $upgradeManager = new UpgradeManager($this->container); + + $upgradeId = $upgradeManager->upload($data); + $manifest = $upgradeManager->getManifest(); + + return (object) [ + 'id' => $upgradeId, + 'version' => $manifest['version'], + ]; + } + + /** + * @throws Forbidden + * @throws Error + */ + public function postActionRunUpgrade(Request $request): bool + { + $data = $request->getParsedBody(); + + $this->assertUpgradeAllowed(); + + $upgradeManager = new UpgradeManager($this->container); + + $upgradeManager->install(get_object_vars($data)); + + return true; + } + + /** + * @return object{ + * message: string, + * command: string, + * } + */ + public function getActionCronMessage(): object + { + return (object) $this->scheduledJob->getSetupMessage(); + } + + /** + * @return array + */ + public function getActionAdminNotificationList(): array + { + return $this->adminNotificationManager->getNotificationList(); + } + + /** + * @return object{ + * php: array>, + * database: array>, + * permission: array>, + * } + * @throws Forbidden + */ + public function getActionSystemRequirementList(): object + { + if (!$this->user->isSuperAdmin() && $this->systemConfig->isRestrictedMode()) { + throw new Forbidden(); + } + + return (object) $this->systemRequirements->getAllRequiredList(); + } + + /** + * @throws Forbidden + */ + private function assertUpgradeAllowed(): void + { + if ($this->config->get('restrictedMode')) { + throw new Forbidden("Not allowed in restricted mode."); + } + + if ($this->config->get('adminUpgrade') !== true) { + throw new Forbidden("Cannot upgrade via the UI as `adminUpgrade` is not enabled."); + } + + if ($this->config->get('adminUpgradeDisabled')) { + throw new Forbidden("Disabled with `adminUpgradeDisabled` parameter."); + } + } +} diff --git a/application/Espo/Controllers/ApiIndex.php b/application/Espo/Controllers/ApiIndex.php new file mode 100644 index 0000000..85ceec7 --- /dev/null +++ b/application/Espo/Controllers/ApiIndex.php @@ -0,0 +1,42 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Utils\Json; + +class ApiIndex +{ + public function getActionIndex(Request $request, Response $response): void + { + $response->writeBody(Json::encode("EspoCRM REST API")); + } +} diff --git a/application/Espo/Controllers/AppLogRecord.php b/application/Espo/Controllers/AppLogRecord.php new file mode 100644 index 0000000..29cc51d --- /dev/null +++ b/application/Espo/Controllers/AppLogRecord.php @@ -0,0 +1,76 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Controllers\Record; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use stdClass; + +class AppLogRecord extends Record +{ + protected function checkAccess(): bool + { + if (!$this->user->isEffectiveAdmin()) { + return false; + } + + if (!$this->config->get('restrictedMode')) { + return true; + } + + if ($this->config->get('appLogAdminAllowed')) { + return true; + } + + return $this->user->isSuperAdmin(); + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function postActionCreateLink(Request $request): bool + { + throw new Forbidden(); + } + + public function deleteActionRemoveLink(Request $request): bool + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/AppSecret.php b/application/Espo/Controllers/AppSecret.php new file mode 100644 index 0000000..ed3dbae --- /dev/null +++ b/application/Espo/Controllers/AppSecret.php @@ -0,0 +1,43 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\Record; + +/** + * @noinspection PhpUnused + */ +class AppSecret extends Record +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } +} diff --git a/application/Espo/Controllers/Attachment.php b/application/Espo/Controllers/Attachment.php new file mode 100644 index 0000000..24800f6 --- /dev/null +++ b/application/Espo/Controllers/Attachment.php @@ -0,0 +1,49 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Exceptions\Forbidden; + +use stdClass; + +class Attachment extends RecordBase +{ + public function getActionList(Request $request, Response $response): stdClass + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + return parent::getActionList($request, $response); + } +} diff --git a/application/Espo/Controllers/AuthLogRecord.php b/application/Espo/Controllers/AuthLogRecord.php new file mode 100644 index 0000000..132676c --- /dev/null +++ b/application/Espo/Controllers/AuthLogRecord.php @@ -0,0 +1,65 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Controllers\Record; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; + +use stdClass; + +class AuthLogRecord extends Record +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function postActionCreateLink(Request $request): bool + { + throw new Forbidden(); + } + + public function deleteActionRemoveLink(Request $request): bool + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/AuthToken.php b/application/Espo/Controllers/AuthToken.php new file mode 100644 index 0000000..2783a36 --- /dev/null +++ b/application/Espo/Controllers/AuthToken.php @@ -0,0 +1,52 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Controllers\Record; +use Espo\Core\Api\Request; + +class AuthToken extends Record +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + public function postActionCreateLink(Request $request): bool + { + throw new Forbidden(); + } + + public function deleteActionRemoveLink(Request $request): bool + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/AuthenticationProvider.php b/application/Espo/Controllers/AuthenticationProvider.php new file mode 100644 index 0000000..1e712ef --- /dev/null +++ b/application/Espo/Controllers/AuthenticationProvider.php @@ -0,0 +1,44 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\RecordBase; + +class AuthenticationProvider extends RecordBase +{ + protected function checkAccess(): bool + { + if (!$this->user->isEffectiveAdmin()) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Controllers/CurrencyRecord.php b/application/Espo/Controllers/CurrencyRecord.php new file mode 100644 index 0000000..a73e940 --- /dev/null +++ b/application/Espo/Controllers/CurrencyRecord.php @@ -0,0 +1,56 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\Record; +use Espo\Core\Exceptions\Forbidden; + +/** + * @noinspection PhpUnused + */ +class CurrencyRecord extends Record +{ + public function postActionCreate(Request $request, Response $response): never + { + throw new Forbidden(); + } + + public function putActionUpdate(Request $request, Response $response): never + { + throw new Forbidden(); + } + + public function deleteActionDelete(Request $request, Response $response): never + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/CurrencyRecordRate.php b/application/Espo/Controllers/CurrencyRecordRate.php new file mode 100644 index 0000000..3bd21ac --- /dev/null +++ b/application/Espo/Controllers/CurrencyRecordRate.php @@ -0,0 +1,38 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\RecordBase; + +/** + * @noinspection PhpUnused + */ +class CurrencyRecordRate extends RecordBase +{} diff --git a/application/Espo/Controllers/DashboardTemplate.php b/application/Espo/Controllers/DashboardTemplate.php new file mode 100644 index 0000000..8758b06 --- /dev/null +++ b/application/Espo/Controllers/DashboardTemplate.php @@ -0,0 +1,103 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\BadRequest; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Tools\Dashboard\Service; + +use Espo\Core\Api\Request; +use Espo\Core\Controllers\Record; + +class DashboardTemplate extends Record +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function postActionDeployToUsers(Request $request): bool + { + $data = $request->getParsedBody(); + + if (empty($data->id)) { + throw new BadRequest(); + } + + if (empty($data->userIdList)) { + throw new BadRequest(); + } + + $this->getDashboardTemplateService()->deployTemplateToUsers( + $data->id, + $data->userIdList, + !empty($data->append) + ); + + return true; + } + + /** + * @throws BadRequest + * @throws NotFound + */ + public function postActionDeployToTeam(Request $request): bool + { + $data = $request->getParsedBody(); + + if (empty($data->id)) { + throw new BadRequest(); + } + + if (empty($data->teamId)) { + throw new BadRequest(); + } + + $this->getDashboardTemplateService()->deployTemplateToTeam( + $data->id, + $data->teamId, + !empty($data->append) + ); + + return true; + } + + private function getDashboardTemplateService(): Service + { + return $this->injectableFactory->create(Service::class); + } +} diff --git a/application/Espo/Controllers/DataPrivacy.php b/application/Espo/Controllers/DataPrivacy.php new file mode 100644 index 0000000..bf0b8bc --- /dev/null +++ b/application/Espo/Controllers/DataPrivacy.php @@ -0,0 +1,76 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; + +use Espo\Core\Acl; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; + +use Espo\Core\Exceptions\NotFound; +use Espo\Tools\DataPrivacy\Erasor; + +class DataPrivacy +{ + /** + * @throws Forbidden + */ + public function __construct(private Erasor $erasor, private Acl $acl) + { + if ($this->acl->getPermissionLevel(Acl\Permission::DATA_PRIVACY) === Acl\Table::LEVEL_NO) { + throw new Forbidden(); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function postActionErase(Request $request, Response $response): void + { + $data = $request->getParsedBody(); + + if ( + empty($data->entityType) || + empty($data->id) || + empty($data->fieldList) || + !is_array($data->fieldList) + ) { + throw new BadRequest(); + } + + $this->erasor->erase($data->entityType, $data->id, $data->fieldList); + + $response->writeBody('true'); + } +} diff --git a/application/Espo/Controllers/Email.php b/application/Espo/Controllers/Email.php new file mode 100644 index 0000000..6ca95bc --- /dev/null +++ b/application/Espo/Controllers/Email.php @@ -0,0 +1,35 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\Record; + +class Email extends Record +{} diff --git a/application/Espo/Controllers/EmailAccount.php b/application/Espo/Controllers/EmailAccount.php new file mode 100644 index 0000000..9613936 --- /dev/null +++ b/application/Espo/Controllers/EmailAccount.php @@ -0,0 +1,99 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Mail\Account\PersonalAccount\Service; +use Espo\Core\Mail\Account\Storage\Params as StorageParams; + +use Espo\Core\Controllers\Record; +use Espo\Core\Api\Request; + +class EmailAccount extends Record +{ + protected function checkAccess(): bool + { + return $this->acl->check('EmailAccountScope'); + } + + /** + * @return string[] + * @throws Forbidden + * @throws Error + */ + public function postActionGetFolders(Request $request): array + { + $data = $request->getParsedBody(); + + $params = StorageParams::createBuilder() + ->setHost($data->host ?? null) + ->setPort($data->port ?? null) + ->setSecurity($data->security ?? null) + ->setUsername($data->username ?? null) + ->setPassword($data->password ?? null) + ->setId($data->id ?? null) + ->setEmailAddress($data->emailAddress ?? null) + ->setUserId($data->userId ?? null) + ->build(); + + return $this->getEmailAccountService()->getFolderList($params); + } + + /** + * @throws Error + * @throws Forbidden + */ + public function postActionTestConnection(Request $request): bool + { + $data = $request->getParsedBody(); + + $params = StorageParams::createBuilder() + ->setHost($data->host ?? null) + ->setPort($data->port ?? null) + ->setSecurity($data->security ?? null) + ->setUsername($data->username ?? null) + ->setPassword($data->password ?? null) + ->setId($data->id ?? null) + ->setEmailAddress($data->emailAddress ?? null) + ->setUserId($data->userId ?? null) + ->build(); + + $this->getEmailAccountService()->testConnection($params); + + return true; + } + + private function getEmailAccountService(): Service + { + /** @var Service */ + return $this->injectableFactory->create(Service::class); + } +} diff --git a/application/Espo/Controllers/EmailAddress.php b/application/Espo/Controllers/EmailAddress.php new file mode 100644 index 0000000..7087380 --- /dev/null +++ b/application/Espo/Controllers/EmailAddress.php @@ -0,0 +1,35 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\RecordBase; + +class EmailAddress extends RecordBase +{} diff --git a/application/Espo/Controllers/EmailFilter.php b/application/Espo/Controllers/EmailFilter.php new file mode 100644 index 0000000..473a8d3 --- /dev/null +++ b/application/Espo/Controllers/EmailFilter.php @@ -0,0 +1,34 @@ +. + * + * 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\Controllers; + +class EmailFilter extends \Espo\Core\Controllers\Record +{ +} diff --git a/application/Espo/Controllers/EmailFolder.php b/application/Espo/Controllers/EmailFolder.php new file mode 100644 index 0000000..826123d --- /dev/null +++ b/application/Espo/Controllers/EmailFolder.php @@ -0,0 +1,98 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Api\Request; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Tools\EmailFolder\Service; +use stdClass; + +class EmailFolder extends RecordBase +{ + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + */ + public function postActionMoveUp(Request $request): bool + { + $data = $request->getParsedBody(); + + if (empty($data->id)) { + throw new BadRequest(); + } + + $this->getEmailFolderService()->moveUp($data->id); + + return true; + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + */ + public function postActionMoveDown(Request $request): bool + { + $data = $request->getParsedBody(); + + if (empty($data->id)) { + throw new BadRequest(); + } + + $this->getEmailFolderService()->moveDown($data->id); + + return true; + } + + /** + * @throws Forbidden + * @throws NotFound + */ + public function getActionListAll(Request $request): stdClass + { + $userId = $request->getQueryParam('userId'); + + $list = $this->getEmailFolderService()->listAll($userId); + + return (object) ['list' => $list]; + } + + private function getEmailFolderService(): Service + { + return $this->injectableFactory->create(Service::class); + } +} diff --git a/application/Espo/Controllers/EmailTemplate.php b/application/Espo/Controllers/EmailTemplate.php new file mode 100644 index 0000000..8f2b77e --- /dev/null +++ b/application/Espo/Controllers/EmailTemplate.php @@ -0,0 +1,35 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\Record; + +class EmailTemplate extends Record +{} diff --git a/application/Espo/Controllers/EmailTemplateCategory.php b/application/Espo/Controllers/EmailTemplateCategory.php new file mode 100644 index 0000000..c79450e --- /dev/null +++ b/application/Espo/Controllers/EmailTemplateCategory.php @@ -0,0 +1,34 @@ +. + * + * 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\Controllers; + +class EmailTemplateCategory extends \Espo\Core\Templates\Controllers\CategoryTree +{ +} diff --git a/application/Espo/Controllers/EntityManager.php b/application/Espo/Controllers/EntityManager.php new file mode 100644 index 0000000..bcb1e75 --- /dev/null +++ b/application/Espo/Controllers/EntityManager.php @@ -0,0 +1,583 @@ +. + * + * 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\Controllers; + +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; +use Espo\Core\Exceptions\Forbidden; +use Espo\Tools\ExportCustom\ExportCustom; +use Espo\Tools\ExportCustom\Params as ExportCustomParams; +use Espo\Tools\ExportCustom\Service as ExportCustomService; +use Espo\Tools\LinkManager\LinkManager; +use stdClass; + +class EntityManager +{ + /** + * @throws Forbidden + */ + public function __construct( + private User $user, + private EntityManagerTool $entityManagerTool, + private LinkManager $linkManager, + private InjectableFactory $injectableFactory + ) { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + } + + /** + * @throws BadRequest + * @throws Error + * @throws Conflict + */ + public function postActionCreateEntity(Request $request): stdClass + { + $data = $request->getParsedBody(); + + $data = get_object_vars($data); + + if (empty($data['name']) || empty($data['type'])) { + throw new BadRequest(); + } + + $name = $data['name']; + $type = $data['type']; + + if (!is_string($name) || !is_string($type)) { + throw new BadRequest(); + } + + $params = []; + + if (!empty($data['labelSingular'])) { + $params['labelSingular'] = $data['labelSingular']; + } + + if (!empty($data['labelPlural'])) { + $params['labelPlural'] = $data['labelPlural']; + } + + if (!empty($data['stream'])) { + $params['stream'] = $data['stream']; + } + + if (!empty($data['disabled'])) { + $params['disabled'] = $data['disabled']; + } + + if (!empty($data['sortBy'])) { + $params['sortBy'] = $data['sortBy']; + } + + if (!empty($data['sortDirection'])) { + $params['asc'] = $data['sortDirection'] === 'asc'; + } + + if (isset($data['textFilterFields']) && is_array($data['textFilterFields'])) { + $params['textFilterFields'] = $data['textFilterFields']; + } + + if (!empty($data['color'])) { + $params['color'] = $data['color']; + } + + if (!empty($data['iconClass'])) { + $params['iconClass'] = $data['iconClass']; + } + + if (isset($data['fullTextSearch'])) { + $params['fullTextSearch'] = $data['fullTextSearch']; + } + + if (isset($data['countDisabled'])) { + $params['countDisabled'] = $data['countDisabled']; + } + + if (isset($data['optimisticConcurrencyControl'])) { + $params['optimisticConcurrencyControl'] = $data['optimisticConcurrencyControl']; + } + + $params['kanbanViewMode'] = !empty($data['kanbanViewMode']); + + if (!empty($data['kanbanStatusIgnoreList'])) { + $params['kanbanStatusIgnoreList'] = $data['kanbanStatusIgnoreList']; + } + + $name = $this->entityManagerTool->create($name, $type, $params); + + return (object) ['name' => $name]; + } + + /** + * @throws BadRequest + * @throws Error + */ + public function postActionUpdateEntity(Request $request): bool + { + $data = $request->getParsedBody(); + + $data = get_object_vars($data); + + if (empty($data['name'])) { + throw new BadRequest(); + } + + $name = $data['name']; + + if (!is_string($name)) { + throw new BadRequest(); + } + + $this->entityManagerTool->update($name, $data); + + return true; + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + */ + public function postActionRemoveEntity(Request $request): bool + { + $data = $request->getParsedBody(); + + $data = get_object_vars($data); + + if (empty($data['name'])) { + throw new BadRequest(); + } + + $name = $data['name']; + + if (!is_string($name)) { + throw new BadRequest(); + } + + $this->entityManagerTool->delete($name); + + return true; + } + + /** + * @throws BadRequest + * @throws Error + * @throws Conflict + */ + public function postActionCreateLink(Request $request): bool + { + $data = $request->getParsedBody(); + + $data = get_object_vars($data); + + $paramList = [ + 'entity', + 'link', + 'linkForeign', + 'label', + 'linkType', + ]; + + $additionalParamList = [ + 'entityForeign', + 'relationName', + 'labelForeign', + ]; + + $params = []; + + foreach ($paramList as $item) { + if (empty($data[$item])) { + throw new BadRequest(); + } + + $params[$item] = htmlspecialchars($data[$item]); + } + + foreach ($additionalParamList as $item) { + $params[$item] = $data[$item]; + + if (is_string($params[$item])) { + $params[$item] = htmlspecialchars($params[$item]); + } + } + + $params['labelForeign'] = $params['labelForeign'] ?? $params['linkForeign']; + + if (array_key_exists('linkMultipleField', $data)) { + $params['linkMultipleField'] = $data['linkMultipleField']; + } + + if (array_key_exists('linkMultipleFieldForeign', $data)) { + $params['linkMultipleFieldForeign'] = $data['linkMultipleFieldForeign']; + } + + if (array_key_exists('audited', $data)) { + $params['audited'] = $data['audited']; + } + + if (array_key_exists('auditedForeign', $data)) { + $params['auditedForeign'] = $data['auditedForeign']; + } + + if (array_key_exists('parentEntityTypeList', $data)) { + $params['parentEntityTypeList'] = $data['parentEntityTypeList']; + } + + if (array_key_exists('foreignLinkEntityTypeList', $data)) { + $params['foreignLinkEntityTypeList'] = $data['foreignLinkEntityTypeList']; + } + + if (array_key_exists('layout', $data)) { + $params['layout'] = $data['layout']; + } + + if (array_key_exists('layoutForeign', $data)) { + $params['layoutForeign'] = $data['layoutForeign']; + } + + if (array_key_exists('selectFilter', $data)) { + $params['selectFilter'] = $data['selectFilter']; + } + + if (array_key_exists('selectFilterForeign', $data)) { + $params['selectFilterForeign'] = $data['selectFilterForeign']; + } + + /** @var array{ + * linkType: string, + * entity: string, + * link: string, + * entityForeign: string, + * linkForeign: string, + * label: string, + * labelForeign: string, + * relationName?: ?string, + * linkMultipleField?: bool, + * linkMultipleFieldForeign?: bool, + * audited?: bool, + * auditedForeign?: bool, + * layout?: string, + * layoutForeign?: string, + * selectFilter?: string, + * selectFilterForeign?: string, + * } $params + */ + + $this->linkManager->create($params); + + return true; + } + + /** + * @throws BadRequest + * @throws Error + * @throws Forbidden + */ + public function postActionUpdateLink(Request $request): bool + { + $data = $request->getParsedBody(); + + $data = get_object_vars($data); + + $paramList = [ + 'entity', + 'entityForeign', + 'link', + 'linkForeign', + 'label', + 'labelForeign', + ]; + + $params = []; + + foreach ($paramList as $item) { + if (array_key_exists($item, $data)) { + $params[$item] = htmlspecialchars($data[$item]); + } + } + + if (array_key_exists('linkMultipleField', $data)) { + $params['linkMultipleField'] = $data['linkMultipleField']; + } + if (array_key_exists('linkMultipleFieldForeign', $data)) { + $params['linkMultipleFieldForeign'] = $data['linkMultipleFieldForeign']; + } + + if (array_key_exists('audited', $data)) { + $params['audited'] = $data['audited']; + } + + if (array_key_exists('auditedForeign', $data)) { + $params['auditedForeign'] = $data['auditedForeign']; + } + + if (array_key_exists('parentEntityTypeList', $data)) { + $params['parentEntityTypeList'] = $data['parentEntityTypeList']; + } + + if (array_key_exists('foreignLinkEntityTypeList', $data)) { + $params['foreignLinkEntityTypeList'] = $data['foreignLinkEntityTypeList']; + } + + if (array_key_exists('layout', $data)) { + $params['layout'] = $data['layout']; + } + + if (array_key_exists('layoutForeign', $data)) { + $params['layoutForeign'] = $data['layoutForeign']; + } + + if (array_key_exists('selectFilter', $data)) { + $params['selectFilter'] = $data['selectFilter']; + } + + if (array_key_exists('selectFilterForeign', $data)) { + $params['selectFilterForeign'] = $data['selectFilterForeign']; + } + + /** + * @var array{ + * entity: string, + * link: string, + * entityForeign?: ?string, + * linkForeign?: ?string, + * label?: string, + * labelForeign?: string, + * linkMultipleField?: bool, + * linkMultipleFieldForeign?: bool, + * audited?: bool, + * auditedForeign?: bool, + * parentEntityTypeList?: string[], + * foreignLinkEntityTypeList?: string[], + * layout?: string, + * layoutForeign?: string, + * selectFilter?: string, + * selectFilterForeign?: string, + * } $params + */ + + $this->linkManager->update($params); + + return true; + } + + /** + * @throws BadRequest + * @throws Error + * @throws Forbidden + */ + public function postActionRemoveLink(Request $request): bool + { + $data = $request->getParsedBody(); + + $data = get_object_vars($data); + + $paramList = [ + 'entity', + 'link', + ]; + + $params = []; + + foreach ($paramList as $item) { + $params[$item] = htmlspecialchars($data[$item]); + } + + /** + * @var array{ + * entity?: string, + * link?: string, + * } $params + */ + + $this->linkManager->delete($params); + + return true; + } + + + /** + * @throws BadRequest + * @throws Error + * @throws Forbidden + */ + public function postActionUpdateLinkParams(Request $request): bool + { + $entityType = $request->getParsedBody()->entityType ?? throw new BadRequest("No entityType."); + $link = $request->getParsedBody()->link ?? throw new BadRequest("No link."); + $rawParams = $request->getParsedBody()->params ?? throw new BadRequest("No params."); + + if (!is_string($entityType) || !is_string($link) || !$rawParams instanceof stdClass) { + throw new BadRequest(); + } + + /** @var array{readOnly?: bool, cascadeRemocal?: bool} $params */ + $params = []; + + 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); + + return true; + } + + /** + * @throws BadRequest + * @throws Error + */ + public function postActionResetLinkParamsToDefault(Request $request): bool + { + $entityType = $request->getParsedBody()->entityType ?? throw new BadRequest("No entityType."); + $link = $request->getParsedBody()->link ?? throw new BadRequest("No link."); + + if (!is_string($entityType) || !is_string($link)) { + throw new BadRequest(); + } + + $this->linkManager->resetToDefault($entityType, $link); + + return true; + } + + /** + * @throws BadRequest + * @throws Error + */ + public function postActionFormula(Request $request): bool + { + $data = $request->getParsedBody(); + + if (empty($data->scope)) { + throw new BadRequest(); + } + + if (!property_exists($data, 'data')) { + throw new BadRequest(); + } + + $formulaData = get_object_vars($data->data); + + $this->entityManagerTool->setFormulaData($data->scope, $formulaData); + + return true; + } + + /** + * @throws BadRequest + */ + public function postActionResetFormulaToDefault(Request $request): bool + { + $data = $request->getParsedBody(); + + $scope = $data->scope ?? null; + $type = $data->type ?? null; + + if (!$scope || !$type) { + throw new BadRequest(); + } + + $this->entityManagerTool->resetFormulaToDefault($scope, $type); + + return true; + } + + /** + * @throws BadRequest + * @throws Error + */ + public function postActionResetToDefault(Request $request): bool + { + $data = $request->getParsedBody(); + + if (empty($data->scope)) { + throw new BadRequest(); + } + + $this->entityManagerTool->resetToDefaults($data->scope); + + return true; + } + + /** + * @throws BadRequest + */ + public function postActionExportCustom(Request $request): stdClass + { + $data = $request->getParsedBody(); + + $name = $data->name ?? null; + $version = $data->version ?? null; + $author = $data->author ?? null; + $module = $data->module ?? null; + $description = $data->description ?? null; + + if ( + !is_string($name) || + !is_string($version) || + !is_string($author) || + !is_string($module) || + !is_string($description) && !is_null($description) + ) { + throw new BadRequest(); + } + + $params = new ExportCustomParams( + name: $name, + module: $module, + version: $version, + author: $author, + description: $description + ); + + $export = $this->injectableFactory->create(ExportCustom::class); + $service = $this->injectableFactory->create(ExportCustomService::class); + + $result = $export->process($params); + + $service->storeToConfig($params); + + return (object) ['id' => $result->getAttachmentId()]; + } +} diff --git a/application/Espo/Controllers/Extension.php b/application/Espo/Controllers/Extension.php new file mode 100644 index 0000000..252fb1a --- /dev/null +++ b/application/Espo/Controllers/Extension.php @@ -0,0 +1,179 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Upgrades\ExtensionManager; + +use stdClass; + +class Extension extends RecordBase +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + */ + public function postActionUpload(Request $request): stdClass + { + $this->assertUploadAllowed(); + + $body = $request->getBodyContents(); + + if ($body === null) { + throw new BadRequest(); + } + + $manager = $this->createManager(); + + $id = $manager->upload($body); + + $manifest = $manager->getManifest(); + + return (object) [ + 'id' => $id, + 'version' => $manifest['version'], + 'name' => $manifest['name'], + 'description' => $manifest['description'], + ]; + } + + /** + * @throws Forbidden + * @throws Error + */ + public function postActionInstall(Request $request): bool + { + $this->assertInstallAllowed(); + + $data = $request->getParsedBody(); + + $manager = $this->createManager(); + + $manager->install(get_object_vars($data)); + + return true; + } + + /** + * @throws Forbidden + * @throws Error + */ + public function postActionUninstall(Request $request): bool + { + $this->assertInstallAllowed(); + + $data = $request->getParsedBody(); + + $manager = $this->createManager(); + + $manager->uninstall(get_object_vars($data)); + + return true; + } + + /** + * @throws Forbidden + * @throws Error + */ + public function deleteActionDelete(Request $request, Response $response): bool + { + $params = $request->getRouteParams(); + + $this->assertUploadAllowed(); + + $manager = $this->createManager(); + + $manager->delete($params); + + return true; + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + private function createManager(): ExtensionManager + { + return $this->injectableFactory->create(ExtensionManager::class); + } + + /** + * @throws Forbidden + */ + private function assertInstallAllowed(): void + { + if ($this->config->get('restrictedMode')) { + throw new Forbidden("Not allowed in restricted mode."); + } + + if ($this->config->get('adminExtensionManage') !== true) { + throw new Forbidden("Cannot manage extensions as `adminExtensionManage` is not enabled."); + } + } + + /** + * @throws Forbidden + */ + private function assertUploadAllowed(): void + { + if ($this->config->get('restrictedMode')) { + throw new Forbidden("Not allowed in restricted mode."); + } + + if ($this->config->get('adminExtensionUpload') !== true) { + throw new Forbidden("Cannot upload extensions as `adminExtensionUpload` is not enabled."); + } + + if ($this->config->get('adminExtensionManage') !== true) { + throw new Forbidden("Cannot manage extensions as `adminExtensionManage` is not enabled."); + } + + if ($this->config->get('adminUpgradeDisabled')) { + throw new Forbidden("Disabled with `adminUpgradeDisabled` parameter."); + } + } +} diff --git a/application/Espo/Controllers/ExternalAccount.php b/application/Espo/Controllers/ExternalAccount.php new file mode 100644 index 0000000..f595372 --- /dev/null +++ b/application/Espo/Controllers/ExternalAccount.php @@ -0,0 +1,107 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\NotFound; +use Espo\Entities\ExternalAccount as ExternalAccountEntity; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Record\ReadParams; +use Espo\Tools\ExternalAccount\Service; +use stdClass; + +class ExternalAccount extends RecordBase +{ + protected function checkAccess(): bool + { + return $this->acl->checkScope(ExternalAccountEntity::ENTITY_TYPE); + } + + public function getActionList(Request $request, Response $response): stdClass + { + return $this->createService()->getList(); + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function getActionGetOAuth2Info(Request $request): ?stdClass + { + $id = $request->getQueryParam('id') ?? throw new BadRequest(); + + return $this->createService()->getActionGetOAuth2Info($id); + } + + public function getActionRead(Request $request, Response $response): stdClass + { + $id = $request->getRouteParam('id') ?: throw new BadRequest(); + + return $this->getRecordService() + ->read($id, ReadParams::create()) + ->getValueMap(); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + $id = $request->getRouteParam('id') ?? throw new BadRequest(); + + $data = $request->getParsedBody(); + + return $this->createService()->update($id, $data); + } + + /** + * @throws Forbidden + * @throws NotFound + * @throws Error + * @throws BadRequest + */ + public function postActionAuthorizationCode(Request $request): bool + { + $data = $request->getParsedBody(); + + $id = $data->id ?? throw new BadRequest("No ID."); + $code = $data->code ?? throw new BadRequest("No code."); + + $this->createService()->authorizationCode($id, $code); + + return true; + } + + private function createService(): Service + { + return $this->injectableFactory->create(Service::class); + } +} diff --git a/application/Espo/Controllers/FieldManager.php b/application/Espo/Controllers/FieldManager.php new file mode 100644 index 0000000..598b1e7 --- /dev/null +++ b/application/Espo/Controllers/FieldManager.php @@ -0,0 +1,209 @@ +. + * + * 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\Controllers; + +use Espo\Entities\User; +use Espo\Tools\FieldManager\FieldManager as FieldManagerTool; +use Espo\Core\Api\Request; +use Espo\Core\DataManager; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; + +/** + * @noinspection PhpUnused + */ +class FieldManager +{ + /** + * @throws Forbidden + */ + public function __construct( + private User $user, + private DataManager $dataManager, + private FieldManagerTool $fieldManagerTool, + ) { + $this->checkControllerAccess(); + } + + /** + * @throws Forbidden + */ + protected function checkControllerAccess(): void + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + } + + /** + * @return array + * @throws BadRequest + * @throws Error + */ + public function getActionRead(Request $request): array + { + $scope = $request->getRouteParam('scope'); + $name = $request->getRouteParam('name'); + + if (!$scope || !$name) { + throw new BadRequest(); + } + + return $this->fieldManagerTool->read($scope, $name); + } + + /** + * @return array + * @throws BadRequest + * @throws Conflict + * @throws Error + */ + public function postActionCreate(Request $request): array + { + $data = $request->getParsedBody(); + + $scope = $request->getRouteParam('scope'); + $name = $data->name ?? null; + + if (!$scope || !$name) { + throw new BadRequest(); + } + + $fieldManagerTool = $this->fieldManagerTool; + + $name = $fieldManagerTool->create($scope, $name, get_object_vars($data)); + + try { + $this->rebuild($scope); + } catch (Error $e) { + $fieldManagerTool->delete($scope, $name); + + throw new Error($e->getMessage()); + } + + return $fieldManagerTool->read($scope, $name); + } + + /** + * @return array + * @throws BadRequest + * @throws Error + */ + public function patchActionUpdate(Request $request): array + { + return $this->putActionUpdate($request); + } + + /** + * @return array + * @throws BadRequest + * @throws Error + */ + public function putActionUpdate(Request $request): array + { + $data = $request->getParsedBody(); + + $scope = $request->getRouteParam('scope'); + $name = $request->getRouteParam('name'); + + if (!$scope || !$name) { + throw new BadRequest(); + } + + $fieldManagerTool = $this->fieldManagerTool; + + $fieldManagerTool->update($scope, $name, get_object_vars($data)); + + if ($fieldManagerTool->isChanged()) { + $this->rebuild($scope); + } else { + $this->dataManager->clearCache(); + } + + return $fieldManagerTool->read($scope, $name); + } + + /** + * @throws BadRequest + * @throws Error + */ + public function deleteActionDelete(Request $request): bool + { + $scope = $request->getRouteParam('scope'); + $name = $request->getRouteParam('name'); + + if (!$scope || !$name) { + throw new BadRequest(); + } + + $this->fieldManagerTool->delete($scope, $name); + + $this->dataManager->clearCache(); + $this->dataManager->rebuildMetadata(); + + return true; + } + + /** + * @throws BadRequest + * @throws Error + */ + public function postActionResetToDefault(Request $request): bool + { + $data = $request->getParsedBody(); + + $scope = $data->scope ?? null; + $name = $data->name ?? null; + + if (!$scope || !$name) { + throw new BadRequest(); + } + + if (!is_string($scope) || !is_string($name)) { + throw new BadRequest(); + } + + $this->fieldManagerTool->resetToDefault($scope, $name); + + $this->rebuild($scope); + + return true; + } + + /** + * @throws Error + */ + private function rebuild(string $scope): void + { + $this->dataManager->rebuild([$scope]); + } +} diff --git a/application/Espo/Controllers/Formula.php b/application/Espo/Controllers/Formula.php new file mode 100644 index 0000000..0c17a40 --- /dev/null +++ b/application/Espo/Controllers/Formula.php @@ -0,0 +1,99 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\RequestWrapper; +use Espo\Core\Api\Route\ContentType; +use Espo\Core\Exceptions\NotFoundSilent; +use Espo\Tools\Formula\Service; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\ForbiddenSilent; +use Espo\Entities\User; +use Espo\Core\Field\LinkParent; + +use stdClass; + +class Formula +{ + + /** + * @throws ForbiddenSilent + */ + public function __construct( + private Service $service, + User $user, + ) { + if (!$user->isEffectiveAdmin()) { + throw new ForbiddenSilent(); + } + } + + /** + * @throws BadRequest + */ + public function postActionCheckSyntax(Request $request): stdClass + { + $expression = $request->getParsedBody()->expression ?? null; + + if (!$expression || !is_string($expression)) { + throw new BadRequest("No or non-string expression."); + } + + return $this->service->checkSyntax($expression)->toStdClass(); + } + + /** + * @throws BadRequest + * @throws NotFoundSilent + */ + public function postActionRun(Request $request): stdClass + { + if ($request->getContentType() !== ContentType::APPLICATION_JSON) { + throw new BadRequest(); + } + + $expression = $request->getParsedBody()->expression ?? null; + $targetType = $request->getParsedBody()->targetType ?? null; + $targetId = $request->getParsedBody()->targetId ?? null; + + if (!$expression || !is_string($expression)) { + throw new BadRequest("No or non-string expression."); + } + + $targetLink = null; + + if ($targetType && $targetId) { + $targetLink = LinkParent::create($targetType, $targetId); + } + + return $this->service->run($expression, $targetLink)->toStdClass(); + } +} diff --git a/application/Espo/Controllers/GroupEmailFolder.php b/application/Espo/Controllers/GroupEmailFolder.php new file mode 100644 index 0000000..14e1511 --- /dev/null +++ b/application/Espo/Controllers/GroupEmailFolder.php @@ -0,0 +1,76 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Controllers\Record; +use Espo\Core\Api\Request; +use Espo\Tools\EmailFolder\GroupFolderService as Service; + +class GroupEmailFolder extends Record +{ + /** + * @throws BadRequest + */ + public function postActionMoveUp(Request $request): bool + { + $data = $request->getParsedBody(); + + if (empty($data->id)) { + throw new BadRequest(); + } + + $this->getEmailFolderService()->moveUp($data->id); + + return true; + } + + /** + * @throws BadRequest + */ + public function postActionMoveDown(Request $request): bool + { + $data = $request->getParsedBody(); + + if (empty($data->id)) { + throw new BadRequest(); + } + + $this->getEmailFolderService()->moveDown($data->id); + + return true; + } + + private function getEmailFolderService(): Service + { + /** @var Service */ + return $this->injectableFactory->create(Service::class); + } +} diff --git a/application/Espo/Controllers/I18n.php b/application/Espo/Controllers/I18n.php new file mode 100644 index 0000000..7e138c8 --- /dev/null +++ b/application/Espo/Controllers/I18n.php @@ -0,0 +1,54 @@ +. + * + * 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\Controllers; + +use Espo\Tools\App\LanguageService as Service; + +use Espo\Core\Api\Request; + +class I18n +{ + private Service $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + /** + * @return array + */ + public function getActionRead(Request $request): array + { + $default = $request->getQueryParam('default') === 'true'; + + return $this->service->getDataForFrontend($default); + } +} diff --git a/application/Espo/Controllers/Import.php b/application/Espo/Controllers/Import.php new file mode 100644 index 0000000..7934ef9 --- /dev/null +++ b/application/Espo/Controllers/Import.php @@ -0,0 +1,59 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\Record; +use stdClass; + +class Import extends Record +{ + protected function checkAccess(): bool + { + return $this->acl->check('Import'); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function postActionCreateLink(Request $request): bool + { + throw new Forbidden(); + } + + public function deleteActionRemoveLink(Request $request): bool + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/ImportError.php b/application/Espo/Controllers/ImportError.php new file mode 100644 index 0000000..71155a4 --- /dev/null +++ b/application/Espo/Controllers/ImportError.php @@ -0,0 +1,36 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\Record; + +class ImportError extends Record +{ +} diff --git a/application/Espo/Controllers/InboundEmail.php b/application/Espo/Controllers/InboundEmail.php new file mode 100644 index 0000000..de2a3cb --- /dev/null +++ b/application/Espo/Controllers/InboundEmail.php @@ -0,0 +1,97 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Mail\Account\GroupAccount\Service; +use Espo\Core\Mail\Account\Storage\Params as StorageParams; + +use Espo\Core\Controllers\Record; +use Espo\Core\Api\Request; +use Espo\Core\Mail\Exceptions\ImapError; + +class InboundEmail extends Record +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + /** + * @return string[] + * @throws Error + * @throws ImapError + * @throws Forbidden + */ + public function postActionGetFolders(Request $request): array + { + $data = $request->getParsedBody(); + + $params = StorageParams::createBuilder() + ->setHost($data->host ?? null) + ->setPort($data->port ?? null) + ->setSecurity($data->security ?? null) + ->setUsername($data->username ?? null) + ->setPassword($data->password ?? null) + ->setId($data->id ?? null) + ->build(); + + return $this->getInboundEmailService()->getFolderList($params); + } + + /** + * @throws Error + * @throws Forbidden + */ + public function postActionTestConnection(Request $request): bool + { + $data = $request->getParsedBody(); + + $params = StorageParams::createBuilder() + ->setHost($data->host ?? null) + ->setPort($data->port ?? null) + ->setSecurity($data->security ?? null) + ->setUsername($data->username ?? null) + ->setPassword($data->password ?? null) + ->setId($data->id ?? null) + ->build(); + + $this->getInboundEmailService()->testConnection($params); + + return true; + } + + private function getInboundEmailService(): Service + { + /** @var Service */ + return $this->injectableFactory->create(Service::class); + } +} diff --git a/application/Espo/Controllers/Integration.php b/application/Espo/Controllers/Integration.php new file mode 100644 index 0000000..522c8b3 --- /dev/null +++ b/application/Espo/Controllers/Integration.php @@ -0,0 +1,84 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\NotFound; +use Espo\Services\Integration as Service; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Api\Request; +use Espo\Entities\User; + +use stdClass; + +class Integration +{ + + /** + * @throws Forbidden + */ + public function __construct( + private Service $service, + private User $user, + ) { + + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + } + + /** + * @throws Forbidden + * @throws NotFound + */ + public function getActionRead(Request $request): stdClass + { + /** @var string $id */ + $id = $request->getRouteParam('id'); + + $entity = $this->service->read($id); + + return $entity->getValueMap(); + } + + /** + * @throws Forbidden + * @throws NotFound + */ + public function putActionUpdate(Request $request): stdClass + { + /** @var string $id */ + $id = $request->getRouteParam('id'); + $data = $request->getParsedBody(); + + $entity = $this->service->update($id, $data); + + return $entity->getValueMap(); + } +} diff --git a/application/Espo/Controllers/Job.php b/application/Espo/Controllers/Job.php new file mode 100644 index 0000000..ecb80c1 --- /dev/null +++ b/application/Espo/Controllers/Job.php @@ -0,0 +1,55 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; + +use stdClass; + +class Job extends RecordBase +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/LabelManager.php b/application/Espo/Controllers/LabelManager.php new file mode 100644 index 0000000..2f39258 --- /dev/null +++ b/application/Espo/Controllers/LabelManager.php @@ -0,0 +1,124 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\DataManager; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Entities\User; +use Espo\Tools\LabelManager\LabelManager as LabelManagerTool; + +use stdClass; + +/** + * @noinspection PhpUnused + */ +class LabelManager +{ + /** + * @throws Forbidden + */ + public function __construct( + private User $user, + private DataManager $dataManager, + private LabelManagerTool $labelManagerTool + ) { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + } + + /** + * @return string[] + */ + public function postActionGetScopeList(): array + { + return $this->labelManagerTool->getScopeList(); + } + + /** + * @throws BadRequest + */ + public function postActionGetScopeData(Request $request): stdClass + { + $data = $request->getParsedBody(); + + $language = $data->language ?? null; + $scope = $data->scope ?? null; + + if (!$scope || !$language) { + throw new BadRequest(); + } + + if (!is_string($scope) || !is_string($language)) { + throw new BadRequest(); + } + + if (basename($scope) !== $scope || basename($language) !== $language) { + throw new BadRequest(); + } + + return $this->labelManagerTool->getScopeData($language, $scope); + } + + /** + * @throws BadRequest + * @throws Error + */ + public function postActionSaveLabels(Request $request): stdClass + { + $data = $request->getParsedBody(); + + $language = $data->language ?? null; + $scope = $data->scope ?? null; + + if (!$scope || !$language || !isset($data->labels)) { + throw new BadRequest(); + } + + if (!is_string($scope) || !is_string($language)) { + throw new BadRequest(); + } + + if (basename($scope) !== $scope || basename($language) !== $language) { + throw new BadRequest(); + } + + $labels = get_object_vars($data->labels); + + $returnData = $this->labelManagerTool->saveLabels($language, $scope, $labels); + + $this->dataManager->clearCache(); + + return $returnData; + } +} diff --git a/application/Espo/Controllers/LastViewed.php b/application/Espo/Controllers/LastViewed.php new file mode 100644 index 0000000..f21196f --- /dev/null +++ b/application/Espo/Controllers/LastViewed.php @@ -0,0 +1,63 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\SearchParamsFetcher; +use Espo\Tools\ActionHistory\Service as Service; + +use stdClass; + +/** + * @noinspection PhpUnused + */ +class LastViewed +{ + public function __construct(private SearchParamsFetcher $searchParamsFetcher, private Service $service) + {} + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function getActionIndex(Request $request): stdClass + { + $searchParams = $this->searchParamsFetcher->fetch($request); + + $offset = $searchParams->getOffset(); + $maxSize = $searchParams->getMaxSize(); + + $result = $this->service->getLastViewed($maxSize, $offset); + + return $result->toApiOutput(); + } +} diff --git a/application/Espo/Controllers/Layout.php b/application/Espo/Controllers/Layout.php new file mode 100644 index 0000000..76c4222 --- /dev/null +++ b/application/Espo/Controllers/Layout.php @@ -0,0 +1,222 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Api\Request; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\InjectableFactory; +use Espo\Tools\Layout\CustomLayoutService; +use Espo\Tools\Layout\LayoutDefs; +use Espo\Tools\Layout\Service as Service; +use Espo\Entities\User; +use stdClass; + +class Layout +{ + public function __construct( + private User $user, + private Service $service, + private InjectableFactory $injectableFactory + ) {} + + /** + * @return mixed + * @throws Forbidden + * @throws NotFound + * @throws Error + * @throws BadRequest + */ + public function getActionRead(Request $request) + { + $params = $request->getRouteParams(); + + $scope = $params['scope'] ?? null; + $name = $params['name'] ?? null; + + if (!$scope || !$name) { + throw new BadRequest(); + } + + return $this->service->getForFrontend($scope, $name); + } + + /** + * @return mixed + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + * @throws Error + */ + public function putActionUpdate(Request $request) + { + $params = $request->getRouteParams(); + + $data = json_decode($request->getBodyContents() ?? 'null'); + + if (is_object($data)) { + $data = get_object_vars($data); + } + + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $scope = $params['scope'] ?? null; + $name = $params['name'] ?? null; + $setId = $params['setId'] ?? null; + + if (!$scope || !$name) { + throw new BadRequest(); + } + + return $this->service->update($scope, $name, $setId, $data); + } + + /** + * @return array|stdClass|null + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + * @throws Error + */ + public function postActionResetToDefault(Request $request) + { + $data = $request->getParsedBody(); + + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + if (empty($data->scope) || empty($data->name)) { + throw new BadRequest(); + } + + return $this->service->resetToDefault($data->scope, $data->name, $data->setId ?? null); + } + + /** + * @return array|stdClass|null + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + * @throws Error + */ + public function getActionGetOriginal(Request $request) + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $scope = $request->getQueryParam('scope'); + $name = $request->getQueryParam('name'); + $setId = $request->getQueryParam('setId'); + + if (!$scope || !$name) { + throw new BadRequest("No `scope` or `name` parameter."); + } + + return $this->service->getOriginal($scope, $name, $setId); + } + + /** + * @throws Forbidden + * @throws BadRequest + * @throws Conflict + */ + public function postActionCreate(Request $request): bool + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $body = $request->getParsedBody(); + + $scope = $body->scope ?? null; + $name = $body->name ?? null; + $type = $body->type ?? null; + $label = $body->label ?? null; + + if ( + !is_string($scope) || + !is_string($name) || + !is_string($type) || + !is_string($label) || + !$scope || + !$name || + !$type || + !$label + ) { + throw new BadRequest(); + } + + $defs = new LayoutDefs($scope, $name, $type, $label); + + $service = $this->injectableFactory->create(CustomLayoutService::class); + + $service->create($defs); + + return true; + } + + /** + * @throws Forbidden + * @throws BadRequest + */ + public function postActionDelete(Request $request): bool + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $body = $request->getParsedBody(); + + $scope = $body->scope ?? null; + $name = $body->name ?? null; + + if ( + !is_string($scope) || + !is_string($name) || + !$scope || + !$name + ) { + throw new BadRequest(); + } + + $service = $this->injectableFactory->create(CustomLayoutService::class); + + $service->delete($scope, $name); + + return true; + } +} diff --git a/application/Espo/Controllers/LayoutSet.php b/application/Espo/Controllers/LayoutSet.php new file mode 100644 index 0000000..07a6d4f --- /dev/null +++ b/application/Espo/Controllers/LayoutSet.php @@ -0,0 +1,40 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\Record; + +class LayoutSet extends Record +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } +} diff --git a/application/Espo/Controllers/Ldap.php b/application/Espo/Controllers/Ldap.php new file mode 100644 index 0000000..f385fc8 --- /dev/null +++ b/application/Espo/Controllers/Ldap.php @@ -0,0 +1,84 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Authentication\Ldap\Utils as LDAPUtils; +use Espo\Core\Authentication\Ldap\Client as LDAPClient; +use Espo\Core\Api\Request; +use Espo\Core\Utils\Config; +use Espo\Entities\User; + +use Laminas\Ldap\Exception\LdapException; + + +class Ldap +{ + private User $user; + private Config $config; + + public function __construct( + User $user, + Config $config + ) { + $this->user = $user; + $this->config = $config; + } + + /** + * @throws Forbidden + * @throws LdapException + */ + public function postActionTestConnection(Request $request): bool + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $data = $request->getParsedBody(); + + if (!isset($data->password)) { + $data->password = $this->config->get('ldapPassword'); + } + + $ldapUtils = new LDAPUtils(); + + $options = $ldapUtils->normalizeOptions( + get_object_vars($data) + ); + + $ldapClient = new LDAPClient($options); + + // An exception thrown if no connection. + $ldapClient->bind(); + + return true; + } +} diff --git a/application/Espo/Controllers/LeadCapture.php b/application/Espo/Controllers/LeadCapture.php new file mode 100644 index 0000000..74df029 --- /dev/null +++ b/application/Espo/Controllers/LeadCapture.php @@ -0,0 +1,153 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\NotFound; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\Record; +use Espo\Core\Exceptions\Error; + +use Espo\Tools\LeadCapture\Service; +use Espo\Tools\LeadCapture\CaptureService as CaptureService; +use stdClass; + +class LeadCapture extends Record +{ + /** + * @throws BadRequest + * @throws NotFound + * @throws Error + */ + public function postActionLeadCapture(Request $request, Response $response): bool + { + $data = $request->getParsedBody(); + $apiKey = $request->getRouteParam('apiKey'); + + if (!$apiKey) { + throw new BadRequest('No API key provided.'); + } + + $allowOrigin = $this->config->get('leadCaptureAllowOrigin', '*'); + + $response->setHeader('Access-Control-Allow-Origin', $allowOrigin); + + $this->getCaptureService()->capture($apiKey, $data); + + return true; + } + + /** + * @throws BadRequest + * @throws NotFound + */ + public function optionsActionLeadCapture(Request $request, Response $response): bool + { + $apiKey = $request->getRouteParam('apiKey'); + + if (!$apiKey) { + throw new BadRequest('No API key provided.'); + } + + if (!$this->getLeadCaptureService()->isApiKeyValid($apiKey)) { + throw new NotFound(); + } + + $allowOrigin = $this->config->get('leadCaptureAllowOrigin', '*'); + + $response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept'); + $response->setHeader('Access-Control-Allow-Origin', $allowOrigin); + $response->setHeader('Access-Control-Allow-Methods', 'POST'); + + return true; + } + + /** + * @throws BadRequest + * @throws NotFound + * @throws Forbidden + */ + public function postActionGenerateNewApiKey(Request $request): stdClass + { + $data = $request->getParsedBody(); + + if (empty($data->id)) { + throw new BadRequest(); + } + + return $this->getLeadCaptureService() + ->generateNewApiKeyForEntity($data->id) + ->getValueMap(); + } + + /** + * @throws BadRequest + * @throws NotFound + * @throws Forbidden + */ + public function postActionGenerateNewFormId(Request $request): stdClass + { + $data = $request->getParsedBody(); + + if (empty($data->id)) { + throw new BadRequest(); + } + + return $this->getLeadCaptureService() + ->generateNewFormIdForEntity($data->id) + ->getValueMap(); + } + + /** + * @return stdClass[] + * @throws Forbidden + */ + public function getActionSmtpAccountDataList(): array + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + return $this->getLeadCaptureService()->getSmtpAccountDataList(); + } + + private function getCaptureService(): CaptureService + { + return $this->injectableFactory->create(CaptureService::class); + } + + private function getLeadCaptureService(): Service + { + return $this->injectableFactory->create(Service::class); + } +} diff --git a/application/Espo/Controllers/LeadCaptureLogRecord.php b/application/Espo/Controllers/LeadCaptureLogRecord.php new file mode 100644 index 0000000..f602227 --- /dev/null +++ b/application/Espo/Controllers/LeadCaptureLogRecord.php @@ -0,0 +1,35 @@ +. + * + * 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\Controllers; + +class LeadCaptureLogRecord extends \Espo\Core\Controllers\Record +{ + +} diff --git a/application/Espo/Controllers/Metadata.php b/application/Espo/Controllers/Metadata.php new file mode 100644 index 0000000..59ca7ee --- /dev/null +++ b/application/Espo/Controllers/Metadata.php @@ -0,0 +1,84 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Response; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Api\Request; +use Espo\Core\Utils\Json; +use Espo\Core\Utils\Metadata as MetadataUtil; +use Espo\Entities\User as UserEntity; +use Espo\Tools\App\MetadataService as Service; + +use stdClass; + +class Metadata +{ + private Service $service; + private MetadataUtil $metadata; + private UserEntity $user; + + public function __construct( + Service $service, + MetadataUtil $metadata, + UserEntity $user + ) { + $this->service = $service; + $this->metadata = $metadata; + $this->user = $user; + } + + public function getActionRead(Request $request): mixed + { + $key = $request->getQueryParam('key'); + + if (is_string($key)) { + return $this->service->getDataForFrontendByKey($key); + } + + return $this->service->getDataForFrontend(); + } + + /** + * @throws Forbidden + */ + public function getActionGet(Request $request, Response $response): void + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $key = $request->getQueryParam('key'); + + $value = $this->metadata->get($key); + + $response->writeBody(Json::encode($value)); + } +} diff --git a/application/Espo/Controllers/Note.php b/application/Espo/Controllers/Note.php new file mode 100644 index 0000000..ce2f79c --- /dev/null +++ b/application/Espo/Controllers/Note.php @@ -0,0 +1,37 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\RecordBase; + +class Note extends RecordBase +{ + +} diff --git a/application/Espo/Controllers/Notification.php b/application/Espo/Controllers/Notification.php new file mode 100644 index 0000000..3284180 --- /dev/null +++ b/application/Espo/Controllers/Notification.php @@ -0,0 +1,106 @@ +. + * + * 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\Controllers; + +use Espo\Core\Name\Field; +use Espo\Tools\Notification\RecordService as Service; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Select\SearchParams; +use Espo\Core\Select\Where\Item as WhereItem; + +use stdClass; + +class Notification extends RecordBase +{ + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + */ + public function getActionList(Request $request, Response $response): stdClass + { + $searchParamsAux = $this->searchParamsFetcher->fetch($request); + + $offset = $searchParamsAux->getOffset(); + $maxSize = $searchParamsAux->getMaxSize(); + + $after = $request->getQueryParam('after'); + $beforeNumber = $request->getQueryParam('beforeNumber'); + + $searchParams = SearchParams + ::create() + ->withOffset($offset) + ->withMaxSize($maxSize); + + if ($after) { + $searchParams = $searchParams + ->withWhereAdded( + WhereItem + ::createBuilder() + ->setAttribute(Field::CREATED_AT) + ->setType(WhereItem\Type::AFTER) + ->setValue($after) + ->build() + ); + } + + $recordCollection = $this->getNotificationService()->get($this->user, $searchParams, $beforeNumber); + + return $recordCollection->toApiOutput(); + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function getActionNotReadCount(): int + { + return $this->getNotificationService()->getNotReadCount($this->user); + } + + public function postActionMarkAllRead(Request $request): bool + { + $userId = $this->user->getId(); + + $this->getNotificationService()->markAllRead($userId); + + return true; + } + + private function getNotificationService(): Service + { + return $this->injectableFactory->create(Service::class); + } +} diff --git a/application/Espo/Controllers/OAuthAccessToken.php b/application/Espo/Controllers/OAuthAccessToken.php new file mode 100644 index 0000000..9b1cf2e --- /dev/null +++ b/application/Espo/Controllers/OAuthAccessToken.php @@ -0,0 +1,52 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Exceptions\Forbidden; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class OAuthAccessToken extends RecordBase +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/OAuthAccount.php b/application/Espo/Controllers/OAuthAccount.php new file mode 100644 index 0000000..d6d8e39 --- /dev/null +++ b/application/Espo/Controllers/OAuthAccount.php @@ -0,0 +1,47 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\RecordBase; + +/** + * @noinspection PhpUnused + */ +class OAuthAccount extends RecordBase +{ + protected function checkAccess(): bool + { + if (!$this->user->isEffectiveAdmin()) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Controllers/OAuthAuthorizationCode.php b/application/Espo/Controllers/OAuthAuthorizationCode.php new file mode 100644 index 0000000..1c2c0d7 --- /dev/null +++ b/application/Espo/Controllers/OAuthAuthorizationCode.php @@ -0,0 +1,52 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Exceptions\Forbidden; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class OAuthAuthorizationCode extends RecordBase +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/OAuthClient.php b/application/Espo/Controllers/OAuthClient.php new file mode 100644 index 0000000..36851d8 --- /dev/null +++ b/application/Espo/Controllers/OAuthClient.php @@ -0,0 +1,43 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\Record; + +/** + * @noinspection PhpUnused + */ +class OAuthClient extends Record +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } +} diff --git a/application/Espo/Controllers/OAuthClientSecret.php b/application/Espo/Controllers/OAuthClientSecret.php new file mode 100644 index 0000000..30b3e8c --- /dev/null +++ b/application/Espo/Controllers/OAuthClientSecret.php @@ -0,0 +1,43 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\RecordBase; + +/** + * @noinspection PhpUnused + */ +class OAuthClientSecret extends RecordBase +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } +} diff --git a/application/Espo/Controllers/OAuthProvider.php b/application/Espo/Controllers/OAuthProvider.php new file mode 100644 index 0000000..c89135b --- /dev/null +++ b/application/Espo/Controllers/OAuthProvider.php @@ -0,0 +1,47 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\Record; + +/** + * @noinspection PhpUnused + */ +class OAuthProvider extends Record +{ + protected function checkAccess(): bool + { + if (!$this->user->isEffectiveAdmin()) { + return false; + } + + return true; + } +} diff --git a/application/Espo/Controllers/OAuthRefreshToken.php b/application/Espo/Controllers/OAuthRefreshToken.php new file mode 100644 index 0000000..2cc5ebf --- /dev/null +++ b/application/Espo/Controllers/OAuthRefreshToken.php @@ -0,0 +1,52 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Exceptions\Forbidden; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class OAuthRefreshToken extends RecordBase +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/Oidc.php b/application/Espo/Controllers/Oidc.php new file mode 100644 index 0000000..911ba98 --- /dev/null +++ b/application/Espo/Controllers/Oidc.php @@ -0,0 +1,78 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\ForbiddenSilent; +use Espo\Core\Utils\Json; +use Espo\Tools\Oidc\Service; + +class Oidc +{ + private Service $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + /** + * @throws Forbidden + * @throws Error + */ + public function getActionAuthorizationData(Request $request, Response $response): void + { + $data = $this->service->getAuthorizationData(); + + $response->writeBody(Json::encode($data)); + } + + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function postActionBackchannelLogout(Request $request, Response $response): void + { + $token = $request->getParsedBody()->logout_token ?? null; + + if (!$token || !is_string($token)) { + throw new BadRequest(); + } + + $this->service->backchannelLogout($token); + + $response->writeBody('true'); + } +} diff --git a/application/Espo/Controllers/Pdf.php b/application/Espo/Controllers/Pdf.php new file mode 100644 index 0000000..ea667ad --- /dev/null +++ b/application/Espo/Controllers/Pdf.php @@ -0,0 +1,92 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; + +use Espo\Core\Acl; +use Espo\Core\Api\Request; + +use Espo\Core\Exceptions\NotFound; +use Espo\Entities\Template as TemplateEntity; +use Espo\Tools\Pdf\MassService; + +use stdClass; + +class Pdf +{ + private MassService $service; + private Acl $acl; + + public function __construct(MassService $service, Acl $acl) + { + $this->service = $service; + $this->acl = $acl; + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + */ + public function postActionMassPrint(Request $request): stdClass + { + $data = $request->getParsedBody(); + + if (empty($data->idList) || !is_array($data->idList)) { + throw new BadRequest(); + } + + if (empty($data->entityType)) { + throw new BadRequest(); + } + + if (empty($data->templateId)) { + throw new BadRequest(); + } + + if (!$this->acl->checkScope(TemplateEntity::ENTITY_TYPE)) { + throw new Forbidden(); + } + + if (!$this->acl->checkScope($data->entityType)) { + throw new Forbidden(); + } + + $id = $this->service->generate($data->entityType, $data->idList, $data->templateId); + + return (object) [ + 'id' => $id, + ]; + } +} diff --git a/application/Espo/Controllers/PhoneNumber.php b/application/Espo/Controllers/PhoneNumber.php new file mode 100644 index 0000000..5814aa7 --- /dev/null +++ b/application/Espo/Controllers/PhoneNumber.php @@ -0,0 +1,35 @@ +. + * + * 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\Controllers; + +class PhoneNumber extends \Espo\Core\Controllers\RecordBase +{ + +} diff --git a/application/Espo/Controllers/Pipeline.php b/application/Espo/Controllers/Pipeline.php new file mode 100644 index 0000000..c49241d --- /dev/null +++ b/application/Espo/Controllers/Pipeline.php @@ -0,0 +1,38 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\Record; + +/** + * @noinspection PhpUnused + */ +class Pipeline extends Record +{} diff --git a/application/Espo/Controllers/PipelineStage.php b/application/Espo/Controllers/PipelineStage.php new file mode 100644 index 0000000..ab0dbf0 --- /dev/null +++ b/application/Espo/Controllers/PipelineStage.php @@ -0,0 +1,38 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\RecordBase; + +/** + * @noinspection PhpUnused + */ +class PipelineStage extends RecordBase +{} diff --git a/application/Espo/Controllers/PopupNotification.php b/application/Espo/Controllers/PopupNotification.php new file mode 100644 index 0000000..ae3c99f --- /dev/null +++ b/application/Espo/Controllers/PopupNotification.php @@ -0,0 +1,66 @@ +. + * + * 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\Controllers; + +use Espo\Tools\PopupNotification\Service as Service; + +use stdClass; + +class PopupNotification +{ + private Service $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + public function getActionGrouped(): stdClass + { + $grouped = $this->service->getGrouped(); + + $result = (object) []; + + foreach ($grouped as $type => $itemList) { + $rawList = array_map( + function ($item) { + return (object) [ + 'id' => $item->getId(), + 'data' => $item->getData(), + ]; + }, + $itemList + ); + $result->$type = $rawList; + } + + return $result; + } +} diff --git a/application/Espo/Controllers/Portal.php b/application/Espo/Controllers/Portal.php new file mode 100644 index 0000000..9e7ae1f --- /dev/null +++ b/application/Espo/Controllers/Portal.php @@ -0,0 +1,44 @@ +. + * + * 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\Controllers; + +use Espo\Core\Acl\Permission; +use Espo\Core\Acl\Table; +use Espo\Core\Controllers\Record; + +class Portal extends Record +{ + protected function checkAccess(): bool + { + $level = $this->acl->getPermissionLevel(Permission::PORTAL); + + return $level === Table::LEVEL_YES; + } +} diff --git a/application/Espo/Controllers/PortalRole.php b/application/Espo/Controllers/PortalRole.php new file mode 100644 index 0000000..f94f67b --- /dev/null +++ b/application/Espo/Controllers/PortalRole.php @@ -0,0 +1,34 @@ +. + * + * 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\Controllers; + +class PortalRole extends \Espo\Core\Controllers\Record +{ +} diff --git a/application/Espo/Controllers/Preferences.php b/application/Espo/Controllers/Preferences.php new file mode 100644 index 0000000..7ee5c1e --- /dev/null +++ b/application/Espo/Controllers/Preferences.php @@ -0,0 +1,123 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Exceptions\BadRequest; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Tools\App\PreferencesService as Service; + +use stdClass; + +class Preferences +{ + private Service $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function getActionRead(Request $request): stdClass + { + $userId = $request->getRouteParam('id'); + + if (!$userId) { + throw new BadRequest(); + } + + return $this->service->read($userId)->getValueMap(); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function deleteActionDelete(Request $request): stdClass + { + $userId = $request->getRouteParam('id'); + + if (!$userId) { + throw new BadRequest(); + } + + $this->service->resetToDefaults($userId); + + return $this->service + ->read($userId) + ->getValueMap(); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function putActionUpdate(Request $request): stdClass + { + $userId = $request->getRouteParam('id'); + + if (!$userId) { + throw new BadRequest(); + } + + $data = $request->getParsedBody(); + + return $this->service + ->update($userId, $data) + ->getValueMap(); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function postActionResetDashboard(Request $request): stdClass + { + $data = $request->getParsedBody(); + + $userId = $data->id ?? null; + + if (!$userId) { + throw new BadRequest(); + } + + return $this->service->resetDashboard($userId); + } +} diff --git a/application/Espo/Controllers/Role.php b/application/Espo/Controllers/Role.php new file mode 100644 index 0000000..91b9662 --- /dev/null +++ b/application/Espo/Controllers/Role.php @@ -0,0 +1,34 @@ +. + * + * 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\Controllers; + +class Role extends \Espo\Core\Controllers\Record +{ +} diff --git a/application/Espo/Controllers/ScheduledJob.php b/application/Espo/Controllers/ScheduledJob.php new file mode 100644 index 0000000..923bc02 --- /dev/null +++ b/application/Espo/Controllers/ScheduledJob.php @@ -0,0 +1,40 @@ +. + * + * 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\Controllers; + +use Espo\Core\Controllers\Record; + +class ScheduledJob extends Record +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } +} diff --git a/application/Espo/Controllers/ScheduledJobLogRecord.php b/application/Espo/Controllers/ScheduledJobLogRecord.php new file mode 100644 index 0000000..d76a1f5 --- /dev/null +++ b/application/Espo/Controllers/ScheduledJobLogRecord.php @@ -0,0 +1,38 @@ +. + * + * 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\Controllers; + +class ScheduledJobLogRecord extends \Espo\Core\Controllers\Record +{ + protected function checkAccess(): bool + { + return $this->user->isEffectiveAdmin(); + } +} diff --git a/application/Espo/Controllers/Settings.php b/application/Espo/Controllers/Settings.php new file mode 100644 index 0000000..c4e46c6 --- /dev/null +++ b/application/Espo/Controllers/Settings.php @@ -0,0 +1,75 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Api\Request; +use Espo\Tools\App\SettingsService as Service; +use Espo\Entities\User; + +use stdClass; + +class Settings +{ + public function __construct( + private Service $service, + private User $user, + ) {} + + public function getActionRead(): stdClass + { + return $this->getConfigData(); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + */ + public function putActionUpdate(Request $request): stdClass + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $data = $request->getParsedBody(); + + $this->service->setConfigData($data); + + return $this->getConfigData(); + } + + private function getConfigData(): stdClass + { + return $this->service->getConfigData(); + } +} diff --git a/application/Espo/Controllers/Stream.php b/application/Espo/Controllers/Stream.php new file mode 100644 index 0000000..6d956e2 --- /dev/null +++ b/application/Espo/Controllers/Stream.php @@ -0,0 +1,218 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\BadRequest; + +use Espo\Core\Api\Request; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Field\DateTime; +use Espo\Core\Name\Field; +use Espo\Core\Record\SearchParamsFetcher; + +use Espo\Core\Select\SearchParams; +use Espo\Core\Select\Where\Item as WhereItem; +use Espo\Entities\User as UserEntity; +use Espo\Tools\Stream\RecordService; + +use Espo\Tools\Stream\UserRecordService; +use Espo\Tools\UserReaction\ReactionStreamService; +use stdClass; + +class Stream +{ + public static string $defaultAction = 'list'; + + public function __construct( + private RecordService $service, + private UserRecordService $userRecordService, + private SearchParamsFetcher $searchParamsFetcher, + private ReactionStreamService $reactionStreamService, + ) {} + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function getActionList(Request $request): stdClass + { + $id = $request->getRouteParam('id'); + $scope = $request->getRouteParam('scope'); + + if ($scope === null) { + throw new BadRequest(); + } + + $searchParams = $this->fetchSearchParams($request); + + if ($scope === UserEntity::ENTITY_TYPE) { + $collection = $this->userRecordService->find($id, $searchParams); + + $reactionsCheckDate = DateTime::createNow(); + + $output = $collection->toApiOutput(); + + $output->reactionsCheckDate = $reactionsCheckDate->toString(); + $output->updatedReactions = $this->getReactionUpdates($request, $id); + + return $output; + } + + if ($id === null) { + throw new BadRequest(); + } + + $collection = $this->service->find($scope, $id, $searchParams); + $pinnedCollection = $this->service->getPinned($scope, $id); + + $output = $collection->toApiOutput(); + + $output->pinnedList = $pinnedCollection->getValueMapList(); + + return $output; + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function getActionListPosts(Request $request): stdClass + { + $id = $request->getRouteParam('id'); + $scope = $request->getRouteParam('scope'); + + if ($scope === null) { + throw new BadRequest(); + } + + if ($id === null && $scope !== UserEntity::ENTITY_TYPE) { + throw new BadRequest("No ID."); + } + + $searchParams = $this->fetchSearchParams($request) + ->withPrimaryFilter('posts'); + + $result = $scope === UserEntity::ENTITY_TYPE ? + $this->userRecordService->find($id, $searchParams) : + $this->service->find($scope, $id ?? '', $searchParams); + + return $result->toApiOutput(); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function getActionListUpdates(Request $request): stdClass + { + $id = $request->getRouteParam('id'); + $scope = $request->getRouteParam('scope'); + + if ($scope === null || $id === null) { + throw new BadRequest(); + } + + $searchParams = $this->fetchSearchParams($request); + + $result = $this->service->findUpdates($scope, $id, $searchParams); + + return $result->toApiOutput(); + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function fetchSearchParams(Request $request): SearchParams + { + $searchParams = $this->searchParamsFetcher->fetch($request); + + $after = $request->getQueryParam('after'); + $filter = $request->getQueryParam('filter'); + + if ($after) { + $searchParams = $searchParams + ->withWhereAdded( + WhereItem + ::createBuilder() + ->setAttribute(Field::CREATED_AT) + ->setType(WhereItem\Type::AFTER) + ->setValue($after) + ->build() + ); + } + + if ($filter) { + $searchParams = $searchParams->withPrimaryFilter($filter); + } + + if ($request->getQueryParam('skipOwn') === 'true') { + $searchParams = $searchParams->withBoolFilterAdded('skipOwn'); + } + + $beforeNumber = $request->getQueryParam('beforeNumber'); + + if ($beforeNumber) { + $searchParams = $searchParams + ->withWhereAdded( + WhereItem + ::createBuilder() + ->setAttribute('number') + ->setType(WhereItem\Type::LESS_THAN) + ->setValue($beforeNumber) + ->build() + ); + } + + return $searchParams; + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + * @return stdClass[] + */ + private function getReactionUpdates(Request $request, ?string $id): array + { + $reactionsAfter = $request->getQueryParam('reactionsAfter'); + $noteIds = explode(',', $request->getQueryParam('reactionsCheckNoteIds') ?? ''); + + if (!$reactionsAfter || !$noteIds) { + return []; + } + + return $this->reactionStreamService->getReactionUpdates(DateTime::fromString($reactionsAfter), $noteIds, $id); + } +} diff --git a/application/Espo/Controllers/Team.php b/application/Espo/Controllers/Team.php new file mode 100644 index 0000000..2ae3f13 --- /dev/null +++ b/application/Espo/Controllers/Team.php @@ -0,0 +1,34 @@ +. + * + * 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\Controllers; + +class Team extends \Espo\Core\Controllers\Record +{ +} diff --git a/application/Espo/Controllers/Template.php b/application/Espo/Controllers/Template.php new file mode 100644 index 0000000..511c84f --- /dev/null +++ b/application/Espo/Controllers/Template.php @@ -0,0 +1,35 @@ +. + * + * 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\Controllers; + +class Template extends \Espo\Core\Controllers\Record +{ + +} diff --git a/application/Espo/Controllers/TemplateManager.php b/application/Espo/Controllers/TemplateManager.php new file mode 100644 index 0000000..0602b5a --- /dev/null +++ b/application/Espo/Controllers/TemplateManager.php @@ -0,0 +1,170 @@ +. + * + * 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\Controllers; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\TemplateFileManager; +use Espo\Core\ApplicationState; +use Espo\Core\Api\Request; + +use stdClass; + +/** + * @noinspection PhpUnused + * @todo Move to a service class. + */ +class TemplateManager +{ + /** + * @throws Forbidden + */ + public function __construct( + private Metadata $metadata, + private TemplateFileManager $templateFileManager, + private ApplicationState $applicationState, + private Config $config, + private Acl $acl, + ) { + + if (!$this->acl->checkAdmin()) { + throw new Forbidden(); + } + } + + /** + * @throws BadRequest + */ + public function getActionGetTemplate(Request $request): stdClass + { + $name = $request->getQueryParam('name'); + + if (empty($name)) { + throw new BadRequest(); + } + + $scope = $request->getQueryParam('scope'); + + $hasSubject = !$this->metadata->get(['app', 'templates', $name, 'noSubject']); + + $templateFileManager = $this->templateFileManager; + + $returnData = (object) []; + + $returnData->body = $templateFileManager->getTemplate($name, 'body', $scope); + + if ($hasSubject) { + $returnData->subject = $templateFileManager->getTemplate($name, 'subject', $scope); + } + + return $returnData; + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function postActionSaveTemplate(Request $request): bool + { + $data = $request->getParsedBody(); + + $scope = null; + + if (empty($data->name)) { + /** @noinspection PhpUnhandledExceptionInspection */ + throw new BadRequest(); + } + + if ( + $data->name === 'passwordChangeLink' && + $this->config->get('restrictedMode') && + !$this->applicationState->getUser()->isSuperAdmin() + ) { + throw new Forbidden(); + } + + if (!empty($data->scope)) { + $scope = $data->scope; + } + + $templateFileManager = $this->templateFileManager; + + if (isset($data->subject)) { + $templateFileManager->saveTemplate($data->name, 'subject', $data->subject, $scope); + } + + if (isset($data->body)) { + $templateFileManager->saveTemplate($data->name, 'body', $data->body, $scope); + } + + return true; + } + + /** + * @throws BadRequest + */ + public function postActionResetTemplate(Request $request): stdClass + { + $data = $request->getParsedBody(); + + $scope = null; + + if (empty($data->name)) { + throw new BadRequest(); + } + + if (!empty($data->scope)) { + $scope = $data->scope; + } + + $hasSubject = !$this->metadata->get(['app', 'templates', $data->name, 'noSubject']); + + $templateFileManager = $this->templateFileManager; + + if ($hasSubject) { + $templateFileManager->resetTemplate($data->name, 'subject', $scope); + } + + $templateFileManager->resetTemplate($data->name, 'body', $scope); + + $returnData = (object) []; + + $returnData->body = $templateFileManager->getTemplate($data->name, 'body', $scope); + + if ($hasSubject) { + $returnData->subject = $templateFileManager->getTemplate($data->name, 'subject', $scope); + } + + return $returnData; + } +} diff --git a/application/Espo/Controllers/TwoFactorEmail.php b/application/Espo/Controllers/TwoFactorEmail.php new file mode 100644 index 0000000..0f0428d --- /dev/null +++ b/application/Espo/Controllers/TwoFactorEmail.php @@ -0,0 +1,94 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; + +use Espo\Core\Api\Request; + +use Espo\Core\Exceptions\NotFound; +use Espo\Tools\UserSecurity\TwoFactor\EmailService as Service; + +use Espo\Entities\User; + +class TwoFactorEmail +{ + private Service $service; + private User $user; + + /** + * @throws Forbidden + */ + public function __construct(Service $service, User $user) + { + $this->service = $service; + $this->user = $user; + + if ( + !$this->user->isAdmin() && + !$this->user->isRegular() && + !$this->user->isPortal() + ) { + throw new Forbidden(); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + */ + public function postActionSendCode(Request $request): bool + { + $data = $request->getParsedBody(); + + $id = $data->id ?? null; + $emailAddress = $data->emailAddress ?? null; + + if (!$id) { + throw new BadRequest("No 'id'."); + } + + if (!$emailAddress) { + throw new BadRequest("No 'emailAddress'."); + } + + if (!$this->user->isEffectiveAdmin() && $id !== $this->user->getId()) { + throw new Forbidden(); + } + + $this->service->sendCode($id, $emailAddress); + + return true; + } +} diff --git a/application/Espo/Controllers/TwoFactorSms.php b/application/Espo/Controllers/TwoFactorSms.php new file mode 100644 index 0000000..4e11ef3 --- /dev/null +++ b/application/Espo/Controllers/TwoFactorSms.php @@ -0,0 +1,94 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; + +use Espo\Core\Api\Request; + +use Espo\Core\Exceptions\NotFound; +use Espo\Tools\UserSecurity\TwoFactor\SmsService as Service; + +use Espo\Entities\User; + +class TwoFactorSms +{ + private Service $service; + private User $user; + + /** + * @throws Forbidden + */ + public function __construct(Service $service, User $user) + { + $this->service = $service; + $this->user = $user; + + if ( + !$this->user->isAdmin() && + !$this->user->isRegular() && + !$this->user->isPortal() + ) { + throw new Forbidden(); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + */ + public function postActionSendCode(Request $request): bool + { + $data = $request->getParsedBody(); + + $id = $data->id ?? null; + $phoneNumber = $data->phoneNumber ?? null; + + if (!$id) { + throw new BadRequest("No 'id'."); + } + + if (!$phoneNumber) { + throw new BadRequest("No 'phoneNumber'."); + } + + if (!$this->user->isEffectiveAdmin() && $id !== $this->user->getId()) { + throw new Forbidden(); + } + + $this->service->sendCode($id, $phoneNumber); + + return true; + } +} diff --git a/application/Espo/Controllers/User.php b/application/Espo/Controllers/User.php new file mode 100644 index 0000000..4cd9f04 --- /dev/null +++ b/application/Espo/Controllers/User.php @@ -0,0 +1,89 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Response; +use Espo\Core\Api\Route\ContentType; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Api\Request; +use Espo\Core\Controllers\Record; +use Espo\Core\Select\SearchParams; +use Espo\Core\Select\Where\Item as WhereItem; +use stdClass; + +class User extends Record +{ + public function postActionCreate(Request $request, Response $response): stdClass + { + if ($request->getContentType() !== ContentType::APPLICATION_JSON) { + throw new BadRequest("Not supported content type."); + } + + return parent::postActionCreate($request, $response); + } + + public function postActionCreateLink(Request $request): bool + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + return parent::postActionCreateLink($request); + } + + public function deleteActionRemoveLink(Request $request): bool + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + return parent::deleteActionRemoveLink($request); + } + + protected function fetchSearchParamsFromRequest(Request $request): SearchParams + { + $searchParams = parent::fetchSearchParamsFromRequest($request); + + $userType = $request->getQueryParam('userType'); + + if (!$userType) { + return $searchParams; + } + + return $searchParams->withWhereAdded( + WhereItem::fromRaw([ + 'type' => 'isOfType', + 'attribute' => 'id', + 'value' => $userType, + ]) + ); + } +} diff --git a/application/Espo/Controllers/UserSecurity.php b/application/Espo/Controllers/UserSecurity.php new file mode 100644 index 0000000..929c0cb --- /dev/null +++ b/application/Espo/Controllers/UserSecurity.php @@ -0,0 +1,131 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; + +use Espo\Core\Api\Request; + +use Espo\Core\Exceptions\NotFound; +use Espo\Tools\UserSecurity\Service as Service; + +use Espo\Entities\User; + +use stdClass; + +class UserSecurity +{ + private Service $service; + private User $user; + + /** + * @throws Forbidden + */ + public function __construct(Service $service, User $user) + { + $this->service = $service; + $this->user = $user; + + if ( + !$this->user->isAdmin() && + !$this->user->isRegular() && + !$this->user->isPortal() + ) { + throw new Forbidden(); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function getActionRead(Request $request): stdClass + { + $id = $request->getRouteParam('id'); + + if (!$id) { + throw new BadRequest(); + } + + if (!$this->user->isEffectiveAdmin() && $id !== $this->user->getId()) { + throw new Forbidden(); + } + + return $this->service->read($id); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + */ + public function postActionGetTwoFactorUserSetupData(Request $request): stdClass + { + $data = $request->getParsedBody(); + + $id = $data->id ?? null; + + if (!$id) { + throw new BadRequest("No 'id'."); + } + + if (!$this->user->isEffectiveAdmin() && $id !== $this->user->getId()) { + throw new Forbidden(); + } + + return $this->service->getTwoFactorUserSetupData($id, $data); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function putActionUpdate(Request $request): stdClass + { + $id = $request->getRouteParam('id'); + + $data = $request->getParsedBody(); + + if (!$id) { + throw new BadRequest(); + } + + if (!$this->user->isEffectiveAdmin() && $id !== $this->user->getId()) { + throw new Forbidden(); + } + + return $this->service->update($id, $data); + } +} diff --git a/application/Espo/Controllers/Webhook.php b/application/Espo/Controllers/Webhook.php new file mode 100644 index 0000000..c51f6cc --- /dev/null +++ b/application/Espo/Controllers/Webhook.php @@ -0,0 +1,56 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\Record; +use stdClass; + +class Webhook extends Record +{ + protected function checkAccess(): bool + { + if (!$this->user->isEffectiveAdmin() && !$this->user->isApi()) { + return false; + } + + return true; + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + $result = parent::postActionCreate($request, $response); + + $response->setStatus(201); + + return $result; + } +} diff --git a/application/Espo/Controllers/WebhookEventQueueItem.php b/application/Espo/Controllers/WebhookEventQueueItem.php new file mode 100644 index 0000000..146033d --- /dev/null +++ b/application/Espo/Controllers/WebhookEventQueueItem.php @@ -0,0 +1,61 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Exceptions\Forbidden; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class WebhookEventQueueItem extends RecordBase +{ + protected function checkAccess(): bool + { + if (!$this->user->isEffectiveAdmin()) { + return false; + } + + return true; + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/WebhookQueueItem.php b/application/Espo/Controllers/WebhookQueueItem.php new file mode 100644 index 0000000..e42f997 --- /dev/null +++ b/application/Espo/Controllers/WebhookQueueItem.php @@ -0,0 +1,61 @@ +. + * + * 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\Controllers; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Controllers\RecordBase; +use Espo\Core\Exceptions\Forbidden; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class WebhookQueueItem extends RecordBase +{ + protected function checkAccess(): bool + { + if (!$this->user->isEffectiveAdmin()) { + return false; + } + + return true; + } + + public function postActionCreate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } + + public function putActionUpdate(Request $request, Response $response): stdClass + { + throw new Forbidden(); + } +} diff --git a/application/Espo/Controllers/WorkingTimeCalendar.php b/application/Espo/Controllers/WorkingTimeCalendar.php new file mode 100644 index 0000000..4d1dd0e --- /dev/null +++ b/application/Espo/Controllers/WorkingTimeCalendar.php @@ -0,0 +1,32 @@ +. + * + * 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\Controllers; + +class WorkingTimeCalendar extends \Espo\Core\Controllers\Record {} diff --git a/application/Espo/Controllers/WorkingTimeRange.php b/application/Espo/Controllers/WorkingTimeRange.php new file mode 100644 index 0000000..44ec931 --- /dev/null +++ b/application/Espo/Controllers/WorkingTimeRange.php @@ -0,0 +1,32 @@ +. + * + * 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\Controllers; + +class WorkingTimeRange extends \Espo\Core\Controllers\Record {} diff --git a/application/Espo/Core/Acl.php b/application/Espo/Core/Acl.php new file mode 100644 index 0000000..4c1c00a --- /dev/null +++ b/application/Espo/Core/Acl.php @@ -0,0 +1,406 @@ +. + * + * 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; + +use Espo\Core\Acl\Exceptions\NotImplemented; +use Espo\Core\Acl\GlobalRestriction; +use Espo\Core\Acl\Permission; +use Espo\Core\Acl\Table; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +use stdClass; + +/** + * A wrapper for `AclManager` for a current user. A central access point for access checking. + */ +class Acl +{ + public function __construct( + protected AclManager $aclManager, + protected User $user + ) {} + + /** + * Get a full access data map. + */ + public function getMapData(): stdClass + { + return $this->aclManager->getMapData($this->user); + } + + /** + * Get an access level for a specific scope and action. + * + * @param Table::ACTION_* $action + * @noinspection PhpDocSignatureInspection + */ + public function getLevel(string $scope, string $action): string + { + return $this->aclManager->getLevel($this->user, $scope, $action); + } + + /** + * Get a permission. E.g. 'assignment' permission. + */ + public function getPermissionLevel(string $permission): string + { + return $this->aclManager->getPermissionLevel($this->user, $permission); + } + + /** + * Whether there's no 'read' access for a specific scope. + */ + public function checkReadNo(string $scope): bool + { + return $this->aclManager->checkReadNo($this->user, $scope); + } + + /** + * Whether 'read' access is set to 'team' for a specific scope. + */ + public function checkReadOnlyTeam(string $scope): bool + { + return $this->aclManager->checkReadOnlyTeam($this->user, $scope); + } + + /** + * Whether 'read' access is set to 'own' for a specific scope. + */ + public function checkReadOnlyOwn(string $scope): bool + { + return $this->aclManager->checkReadOnlyOwn($this->user, $scope); + } + + /** + * Whether 'read' access is set to 'all' for a specific scope. + */ + public function checkReadAll(string $scope): bool + { + return $this->aclManager->checkReadAll($this->user, $scope); + } + + /** + * Check a scope or entity. If $action is omitted, it will check + * whether a scope level is set to 'enabled'. + * + * @param string|Entity $subject An entity type or entity. + * @param Table::ACTION_*|null $action Action to check. Constants are available in the `Table` class. + * @throws NotImplemented + * @noinspection PhpDocSignatureInspection + */ + public function check($subject, ?string $action = null): bool + { + return $this->aclManager->check($this->user, $subject, $action); + } + + /** + * The same as `check` but does not throw NotImplemented exception. + * + * @param string|Entity $subject An entity type or entity. + * @param Table::ACTION_*|null $action Action to check. Constants are available in the `Table` class. + * @noinspection PhpDocSignatureInspection + */ + public function tryCheck($subject, ?string $action = null): bool + { + return $this->aclManager->tryCheck($this->user, $subject, $action); + } + + /** + * Check access to scope. If $action is omitted, it will check + * whether a scope level is set to 'enabled'. + * + * @throws NotImplemented + */ + public function checkScope(string $scope, ?string $action = null): bool + { + return $this->aclManager->checkScope($this->user, $scope, $action); + } + + /** + * Check access to a specific entity. + * + * @param Entity $entity An entity to check. + * @param Table::ACTION_* $action Action to check. Constants are available in the `Table` class. + * @noinspection PhpDocSignatureInspection + */ + public function checkEntity(Entity $entity, string $action = Table::ACTION_READ): bool + { + return $this->aclManager->checkEntity($this->user, $entity, $action); + } + + /** + * Check 'read' access to a specific entity. + */ + public function checkEntityRead(Entity $entity): bool + { + /** @noinspection PhpRedundantOptionalArgumentInspection */ + return $this->checkEntity($entity, Table::ACTION_READ); + } + + /** + * Check 'create' access to a specific entity. + */ + public function checkEntityCreate(Entity $entity): bool + { + return $this->checkEntity($entity, Table::ACTION_CREATE); + } + + /** + * Check 'edit' access to a specific entity. + */ + public function checkEntityEdit(Entity $entity): bool + { + return $this->checkEntity($entity, Table::ACTION_EDIT); + } + + /** + * Check 'delete' access to a specific entity. + */ + public function checkEntityDelete(Entity $entity): bool + { + return $this->checkEntity($entity, Table::ACTION_DELETE); + } + + /** + * Check 'stream' access to a specific entity. + */ + public function checkEntityStream(Entity $entity): bool + { + return $this->checkEntity($entity, Table::ACTION_STREAM); + } + + /** + * Check whether a user is an owner of an entity. + */ + public function checkOwnershipOwn(Entity $entity): bool + { + return $this->aclManager->checkOwnershipOwn($this->user, $entity); + } + + /** + * Check whether an entity belongs to a user team. + */ + public function checkOwnershipTeam(Entity $entity): bool + { + return $this->aclManager->checkOwnershipTeam($this->user, $entity); + } + + /** + * Check whether an entity is shared with a user. + * + * @param Table::ACTION_* $action + * @since 9.0.0 + * @noinspection PhpDocSignatureInspection + */ + public function checkOwnershipShared(Entity $entity, string $action): bool + { + return $this->aclManager->checkOwnershipShared($this->user, $entity, $action); + } + + /** + * Get attributes forbidden for a user. + * + * @param Table::ACTION_READ|Table::ACTION_EDIT $action An action. + * @param string $thresholdLevel Should not be used. Stands for possible future enhancements. + * @return string[] + * @noinspection PhpDocSignatureInspection + */ + public function getScopeForbiddenAttributeList( + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + return $this->aclManager + ->getScopeForbiddenAttributeList($this->user, $scope, $action, $thresholdLevel); + } + + /** + * Get fields forbidden for a user. + * + * @param Table::ACTION_READ|Table::ACTION_EDIT $action An action. + * @param string $thresholdLevel Should not be used. Stands for possible future enhancements. + * @return string[] + * @noinspection PhpDocSignatureInspection + */ + public function getScopeForbiddenFieldList( + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + return $this->aclManager + ->getScopeForbiddenFieldList($this->user, $scope, $action, $thresholdLevel); + } + + /** + * Check access to a field. + * + * @param string $scope A scope (entity type). + * @param string $field A field to check. + * @param Table::ACTION_READ|Table::ACTION_EDIT $action An action. + * @return bool + * @noinspection PhpDocSignatureInspection + */ + public function checkField(string $scope, string $field, string $action = Table::ACTION_READ): bool + { + 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 10.0.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. + * + * @param Table::ACTION_READ|Table::ACTION_EDIT $action An action. + * @param string $thresholdLevel Should not be used. Stands for possible future enhancements. + * @return string[] + * @noinspection PhpDocSignatureInspection + */ + public function getScopeForbiddenLinkList( + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + return $this->aclManager->getScopeForbiddenLinkList($this->user, $scope, $action, $thresholdLevel); + } + + /** + * Whether a user has access to another user over a specific permission. + * + * @param User|string $target User entity or user ID. + */ + public function checkUserPermission($target, string $permissionType = Permission::USER): bool + { + return $this->aclManager->checkUserPermission($this->user, $target, $permissionType); + } + + /** + * Whether a user can assign to another user. + * + * @param User|string $target User entity or user ID. + */ + public function checkAssignmentPermission($target): bool + { + return $this->aclManager->checkAssignmentPermission($this->user, $target); + } + + /** + * Get a restricted field list for a specific scope by a restriction type. + * + * @param GlobalRestriction::TYPE_*|array $type + * @return string[] + */ + public function getScopeRestrictedFieldList(string $scope, $type): array + { + return $this->aclManager->getScopeRestrictedFieldList($scope, $type); + } + + /** + * Get a restricted attribute list for a specific scope by a restriction type. + * + * @param GlobalRestriction::TYPE_*|array $type + * @return string[] + */ + public function getScopeRestrictedAttributeList(string $scope, $type): array + { + return $this->aclManager->getScopeRestrictedAttributeList($scope, $type); + } + + /** + * Get a restricted link list for a specific scope by a restriction type. + * + * @param GlobalRestriction::TYPE_*|array $type + * @return string[] + */ + public function getScopeRestrictedLinkList(string $scope, $type): array + { + return $this->aclManager->getScopeRestrictedLinkList($scope, $type); + } + + /** + * @deprecated Use `getPermissionLevel` instead. + */ + public function get(string $permission): string + { + return $this->getPermissionLevel($permission); + } + + /** + * @deprecated Use `checkOwnershipOwn` instead. + */ + public function checkIsOwner(Entity $entity): bool + { + return $this->aclManager->checkOwnershipOwn($this->user, $entity); + } + + /** + * @deprecated Use `checkOwnershipTeam` instead. + */ + public function checkInTeam(Entity $entity): bool + { + return $this->aclManager->checkOwnershipTeam($this->user, $entity); + } + + /** + * @deprecated Use `checkUserPermission` instead. + */ + public function checkUser(string $permission, User $entity): bool + { + /** @noinspection PhpDeprecationInspection */ + return $this->aclManager->checkUser($this->user, $permission, $entity); + } + + /** + * Whether the user is admin and has admin capabilities. + * + * @since 10.1.0 + */ + public function checkAdmin(): bool + { + return $this->aclManager->checkAdmin($this->user); + } +} diff --git a/application/Espo/Core/Acl/AccessChecker.php b/application/Espo/Core/Acl/AccessChecker.php new file mode 100644 index 0000000..2829a1e --- /dev/null +++ b/application/Espo/Core/Acl/AccessChecker.php @@ -0,0 +1,45 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Entities\User; + +/** + * Bindings: + * - `$entityType` – as of v9.1.0. + * - `Espo\Core\AclManager` + */ +interface AccessChecker +{ + /** + * Check access to a scope. + */ + public function check(User $user, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessChecker/AccessCheckerFactory.php b/application/Espo/Core/Acl/AccessChecker/AccessCheckerFactory.php new file mode 100644 index 0000000..a9c63dd --- /dev/null +++ b/application/Espo/Core/Acl/AccessChecker/AccessCheckerFactory.php @@ -0,0 +1,103 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\AccessChecker; + +use Espo\Core\Acl\AccessChecker; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\Exceptions\NotImplemented; +use Espo\Core\AclManager; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +class AccessCheckerFactory +{ + /** @var class-string */ + private string $defaultClassName = DefaultAccessChecker::class; + + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + /** + * Create an access checker. + * + * @throws NotImplemented + */ + public function create(string $scope, AclManager $aclManager): AccessChecker + { + $className = $this->getClassName($scope); + + $bindingContainer = $this->createBindingContainer($className, $aclManager, $scope); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + /** + * @return class-string + * @throws NotImplemented + */ + private function getClassName(string $scope): string + { + /** @var ?class-string $className1 */ + $className1 = $this->metadata->get(['aclDefs', $scope, 'accessCheckerClassName']); + + if ($className1) { + return $className1; + } + + if (!$this->metadata->get(['scopes', $scope])) { + throw new NotImplemented("Access checker is not implemented for '$scope'."); + } + + return $this->defaultClassName; + } + + /** + * @param class-string $className + */ + private function createBindingContainer(string $className, AclManager $aclManager, string $scope): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder->bindInstance(AclManager::class, $aclManager); + + $binder + ->for($className) + ->bindValue('$entityType', $scope); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Acl/AccessChecker/AccessCheckers/Foreign.php b/application/Espo/Core/Acl/AccessChecker/AccessCheckers/Foreign.php new file mode 100644 index 0000000..645a726 --- /dev/null +++ b/application/Espo/Core/Acl/AccessChecker/AccessCheckers/Foreign.php @@ -0,0 +1,170 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\AccessChecker\AccessCheckers; + +use Espo\Core\AclManager; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Utils\Metadata; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency; +use Espo\Core\Acl\AccessEntityCreateChecker; +use Espo\Core\Acl\AccessEntityReadChecker; +use Espo\Core\Acl\AccessEntityEditChecker; +use Espo\Core\Acl\AccessEntityDeleteChecker; +use Espo\Core\Acl\AccessEntityStreamChecker; +use Espo\Core\Acl\ScopeData; + +use Espo\ORM\EntityManager; + +use LogicException; + +/** + * Access is determined by access to a foreign entity. + * + * @implements AccessEntityCreateChecker + * @implements AccessEntityReadChecker + * @implements AccessEntityEditChecker + * @implements AccessEntityDeleteChecker + * @implements AccessEntityStreamChecker + */ +class Foreign implements + + AccessEntityCreateChecker, + AccessEntityReadChecker, + AccessEntityEditChecker, + AccessEntityDeleteChecker, + AccessEntityStreamChecker +{ + use DefaultAccessCheckerDependency; + + public function __construct( + private Metadata $metadata, + DefaultAccessChecker $defaultAccessChecker, + private EntityManager $entityManager, + private AclManager $aclManager, + ) { + $this->defaultAccessChecker = $defaultAccessChecker; + } + + private function getForeignEntity(Entity $entity): ?Entity + { + $entityType = $entity->getEntityType(); + + $link = $this->metadata->get(['aclDefs', $entityType, 'link']); + + if (!$link) { + throw new LogicException("No `link` in aclDefs for {$entityType}."); + } + + if ($entity->isNew()) { + $foreignEntityType = $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->getRelation($link) + ->getForeignEntityType(); + + /** @var ?string $id */ + $id = $entity->get($link . 'Id'); + + if (!$id) { + return null; + } + + return $this->entityManager->getEntityById($foreignEntityType, $id); + } + + return $this->entityManager + ->getRDBRepository($entityType) + ->getRelation($entity, $link) + ->findOne(); + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + $foreign = $this->getForeignEntity($entity); + + if (!$foreign) { + return false; + } + + // @todo Check parent 'edit' access. + + return $this->defaultAccessChecker->checkEntityCreate($user, $foreign, $data); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + $foreign = $this->getForeignEntity($entity); + + if (!$foreign) { + return false; + } + + return $this->defaultAccessChecker->checkEntityRead($user, $foreign, $data); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + $foreign = $this->getForeignEntity($entity); + + if (!$foreign) { + return false; + } + + return $this->defaultAccessChecker->checkEntityEdit($user, $foreign, $data); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + $foreign = $this->getForeignEntity($entity); + + if (!$foreign) { + if ($this->aclManager->checkAdmin($user)) { + return true; + } + + return false; + } + + return $this->defaultAccessChecker->checkEntityDelete($user, $foreign, $data); + } + + public function checkEntityStream(User $user, Entity $entity, ScopeData $data): bool + { + $foreign = $this->getForeignEntity($entity); + + if (!$foreign) { + return false; + } + + return $this->defaultAccessChecker->checkEntityStream($user, $foreign, $data); + } +} diff --git a/application/Espo/Core/Acl/AccessChecker/ScopeChecker.php b/application/Espo/Core/Acl/AccessChecker/ScopeChecker.php new file mode 100644 index 0000000..237294b --- /dev/null +++ b/application/Espo/Core/Acl/AccessChecker/ScopeChecker.php @@ -0,0 +1,91 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\AccessChecker; + +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Table; + +/** + * Checks scope access. + */ +class ScopeChecker +{ + public function __construct() + {} + + public function check(ScopeData $data, ?string $action = null, ?ScopeCheckerData $checkerData = null): bool + { + if ($data->isFalse()) { + return false; + } + + if ($data->isTrue()) { + return true; + } + + if ($action === null) { + return true; + } + + $level = $data->get($action); + + if ($level === Table::LEVEL_ALL || $level === Table::LEVEL_YES) { + return true; + } + + if ($level === Table::LEVEL_NO) { + return false; + } + + if (!$checkerData) { + return false; + } + + if ($level === Table::LEVEL_OWN || $level === Table::LEVEL_TEAM) { + if ($checkerData->isOwn()) { + return true; + } + } + + if ($level === Table::LEVEL_OWN || $level === Table::LEVEL_TEAM) { + if ($checkerData->isShared()) { + return true; + } + } + + if ($level === Table::LEVEL_TEAM) { + if ($checkerData->inTeam()) { + return true; + } + } + + return false; + } +} diff --git a/application/Espo/Core/Acl/AccessChecker/ScopeCheckerData.php b/application/Espo/Core/Acl/AccessChecker/ScopeCheckerData.php new file mode 100644 index 0000000..05de194 --- /dev/null +++ b/application/Espo/Core/Acl/AccessChecker/ScopeCheckerData.php @@ -0,0 +1,64 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\AccessChecker; + +use Closure; + +/** + * Scope checker data. + */ +class ScopeCheckerData +{ + public function __construct( + private Closure $isOwnChecker, + private Closure $inTeamChecker, + private Closure $isSharedChecker, + ) {} + + public function isOwn(): bool + { + return ($this->isOwnChecker)(); + } + + public function inTeam(): bool + { + return ($this->inTeamChecker)(); + } + + public function isShared(): bool + { + return ($this->isSharedChecker)(); + } + + public static function createBuilder(): ScopeCheckerDataBuilder + { + return new ScopeCheckerDataBuilder(); + } +} diff --git a/application/Espo/Core/Acl/AccessChecker/ScopeCheckerDataBuilder.php b/application/Espo/Core/Acl/AccessChecker/ScopeCheckerDataBuilder.php new file mode 100644 index 0000000..e2e47aa --- /dev/null +++ b/application/Espo/Core/Acl/AccessChecker/ScopeCheckerDataBuilder.php @@ -0,0 +1,131 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\AccessChecker; + +use Closure; + +/** + * Builds scope checker data. + */ +class ScopeCheckerDataBuilder +{ + private Closure $isOwnChecker; + private Closure $inTeamChecker; + private Closure $isSharedChecker; + + public function __construct() + { + $this->isOwnChecker = fn(): bool => false; + $this->inTeamChecker = fn(): bool => false; + $this->isSharedChecker = fn(): bool => false; + } + + public function setIsOwn(bool $value): self + { + if ($value) { + $this->isOwnChecker = function (): bool { + return true; + }; + + return $this; + } + + $this->isOwnChecker = function (): bool { + return false; + }; + + return $this; + } + + public function setInTeam(bool $value): self + { + if ($value) { + $this->inTeamChecker = function (): bool { + return true; + }; + + return $this; + } + + $this->inTeamChecker = function (): bool { + return false; + }; + + return $this; + } + + public function setIsShared(bool $value): self + { + if ($value) { + $this->isSharedChecker = fn(): bool => true; + + return $this; + } + + $this->isSharedChecker = fn(): bool => false; + + return $this; + } + + /** + * @param Closure(): bool $checker + */ + public function setIsOwnChecker(Closure $checker): self + { + $this->isOwnChecker = $checker; + + return $this; + } + + /** + * @param Closure(): bool $checker + */ + public function setInTeamChecker(Closure $checker): self + { + $this->inTeamChecker = $checker; + + return $this; + } + + /** + * @param Closure(): bool $checker + */ + public function setIsSharedChecker(Closure $checker): self + { + $this->isSharedChecker = $checker; + + return $this; + } + + public function build(): ScopeCheckerData + { + return new ScopeCheckerData($this->isOwnChecker, $this->inTeamChecker, $this->isSharedChecker); + } +} diff --git a/application/Espo/Core/Acl/AccessCreateChecker.php b/application/Espo/Core/Acl/AccessCreateChecker.php new file mode 100644 index 0000000..4316391 --- /dev/null +++ b/application/Espo/Core/Acl/AccessCreateChecker.php @@ -0,0 +1,40 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Entities\User; + +interface AccessCreateChecker extends AccessChecker +{ + /** + * Check 'create' access. + */ + public function checkCreate(User $user, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessDeleteChecker.php b/application/Espo/Core/Acl/AccessDeleteChecker.php new file mode 100644 index 0000000..d7aa432 --- /dev/null +++ b/application/Espo/Core/Acl/AccessDeleteChecker.php @@ -0,0 +1,40 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Entities\User; + +interface AccessDeleteChecker extends AccessChecker +{ + /** + * Check 'delete' access. + */ + public function checkDelete(User $user, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessEditChecker.php b/application/Espo/Core/Acl/AccessEditChecker.php new file mode 100644 index 0000000..94f42d0 --- /dev/null +++ b/application/Espo/Core/Acl/AccessEditChecker.php @@ -0,0 +1,40 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Entities\User; + +interface AccessEditChecker extends AccessChecker +{ + /** + * Check 'edit' access. + */ + public function checkEdit(User $user, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessEntityCREDChecker.php b/application/Espo/Core/Acl/AccessEntityCREDChecker.php new file mode 100644 index 0000000..c01b72f --- /dev/null +++ b/application/Espo/Core/Acl/AccessEntityCREDChecker.php @@ -0,0 +1,48 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +/** + * @template TEntity of \Espo\ORM\Entity + * @extends AccessEntityCreateChecker + * @extends AccessEntityCreateChecker + * @extends AccessEntityReadChecker + * @extends AccessEntityEditChecker + * @extends AccessEntityDeleteChecker + */ +interface AccessEntityCREDChecker extends + + AccessEntityCreateChecker, + AccessEntityReadChecker, + AccessEntityEditChecker, + AccessEntityDeleteChecker +{ + +} diff --git a/application/Espo/Core/Acl/AccessEntityCREDSChecker.php b/application/Espo/Core/Acl/AccessEntityCREDSChecker.php new file mode 100644 index 0000000..23649b5 --- /dev/null +++ b/application/Espo/Core/Acl/AccessEntityCREDSChecker.php @@ -0,0 +1,47 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +/** + * @template TEntity of \Espo\ORM\Entity + * @extends AccessEntityCreateChecker + * @extends AccessEntityCreateChecker + * @extends AccessEntityReadChecker + * @extends AccessEntityEditChecker + * @extends AccessEntityDeleteChecker + * @extends AccessEntityStreamChecker + */ +interface AccessEntityCREDSChecker extends + AccessEntityCreateChecker, + AccessEntityReadChecker, + AccessEntityEditChecker, + AccessEntityDeleteChecker, + AccessEntityStreamChecker +{} diff --git a/application/Espo/Core/Acl/AccessEntityCreateChecker.php b/application/Espo/Core/Acl/AccessEntityCreateChecker.php new file mode 100644 index 0000000..a337c55 --- /dev/null +++ b/application/Espo/Core/Acl/AccessEntityCreateChecker.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface AccessEntityCreateChecker extends AccessCreateChecker +{ + /** + * Check 'create' access for an entity. + * + * @param TEntity $entity + */ + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessEntityDeleteChecker.php b/application/Espo/Core/Acl/AccessEntityDeleteChecker.php new file mode 100644 index 0000000..339b099 --- /dev/null +++ b/application/Espo/Core/Acl/AccessEntityDeleteChecker.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface AccessEntityDeleteChecker extends AccessDeleteChecker +{ + /** + * Check 'delete' access for an entity. + * + * @param TEntity $entity + */ + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessEntityEditChecker.php b/application/Espo/Core/Acl/AccessEntityEditChecker.php new file mode 100644 index 0000000..a3bf7f5 --- /dev/null +++ b/application/Espo/Core/Acl/AccessEntityEditChecker.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface AccessEntityEditChecker extends AccessEditChecker +{ + /** + * Check 'edit' access for an entity. + * + * @param TEntity $entity + */ + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessEntityReadChecker.php b/application/Espo/Core/Acl/AccessEntityReadChecker.php new file mode 100644 index 0000000..40b8d5f --- /dev/null +++ b/application/Espo/Core/Acl/AccessEntityReadChecker.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface AccessEntityReadChecker extends AccessReadChecker +{ + /** + * Check 'read' access for entity. + * + * @param TEntity $entity + */ + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessEntityStreamChecker.php b/application/Espo/Core/Acl/AccessEntityStreamChecker.php new file mode 100644 index 0000000..5460f54 --- /dev/null +++ b/application/Espo/Core/Acl/AccessEntityStreamChecker.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface AccessEntityStreamChecker extends AccessStreamChecker +{ + /** + * Check 'stream' access for an entity. + * + * @param TEntity $entity + */ + public function checkEntityStream(User $user, Entity $entity, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessReadChecker.php b/application/Espo/Core/Acl/AccessReadChecker.php new file mode 100644 index 0000000..9816c05 --- /dev/null +++ b/application/Espo/Core/Acl/AccessReadChecker.php @@ -0,0 +1,40 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Entities\User; + +interface AccessReadChecker extends AccessChecker +{ + /** + * Check 'read' access. + */ + public function checkRead(User $user, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AccessStreamChecker.php b/application/Espo/Core/Acl/AccessStreamChecker.php new file mode 100644 index 0000000..26e2aeb --- /dev/null +++ b/application/Espo/Core/Acl/AccessStreamChecker.php @@ -0,0 +1,40 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Entities\User; + +interface AccessStreamChecker extends AccessChecker +{ + /** + * Check 'stream' access. + */ + public function checkStream(User $user, ScopeData $data): bool; +} diff --git a/application/Espo/Core/Acl/AssignmentChecker.php b/application/Espo/Core/Acl/AssignmentChecker.php new file mode 100644 index 0000000..e6f32c1 --- /dev/null +++ b/application/Espo/Core/Acl/AssignmentChecker.php @@ -0,0 +1,47 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\ORM\Entity; + +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface AssignmentChecker +{ + /** + * Check assignment. + * + * @param TEntity $entity + */ + public function check(User $user, Entity $entity): bool; +} diff --git a/application/Espo/Core/Acl/AssignmentChecker/AssignmentCheckerFactory.php b/application/Espo/Core/Acl/AssignmentChecker/AssignmentCheckerFactory.php new file mode 100644 index 0000000..09d1991 --- /dev/null +++ b/application/Espo/Core/Acl/AssignmentChecker/AssignmentCheckerFactory.php @@ -0,0 +1,83 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\AssignmentChecker; + +use Espo\Core\Acl\AssignmentChecker; +use Espo\Core\Acl\DefaultAssignmentChecker; +use Espo\Core\Acl\Exceptions\NotImplemented; +use Espo\Core\InjectableFactory; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; + +class AssignmentCheckerFactory +{ + /** @var class-string> */ + private string $defaultClassName = DefaultAssignmentChecker::class; + + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + /** + * Create an access checker. + * + * @return AssignmentChecker + * @throws NotImplemented + */ + public function create(string $scope): AssignmentChecker + { + $className = $this->getClassName($scope); + + return $this->injectableFactory->create($className); + } + + /** + * @return class-string> + * @throws NotImplemented + */ + private function getClassName(string $scope): string + { + /** @var ?class-string> $className */ + $className = $this->metadata->get(['aclDefs', $scope, 'assignmentCheckerClassName']); + + if ($className) { + return $className; + } + + if (!$this->metadata->get(['scopes', $scope])) { + throw new NotImplemented(); + } + + /** @var class-string> */ + return $this->defaultClassName; + } +} diff --git a/application/Espo/Core/Acl/AssignmentChecker/AssignmentCheckerManager.php b/application/Espo/Core/Acl/AssignmentChecker/AssignmentCheckerManager.php new file mode 100644 index 0000000..864dee8 --- /dev/null +++ b/application/Espo/Core/Acl/AssignmentChecker/AssignmentCheckerManager.php @@ -0,0 +1,69 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\AssignmentChecker; + +use Espo\ORM\Entity; +use Espo\Entities\User; +use Espo\Core\Acl\AssignmentChecker; + +class AssignmentCheckerManager +{ + /** @var array> */ + private $checkerCache = []; + + public function __construct(private AssignmentCheckerFactory $factory) + {} + + public function check(User $user, Entity $entity): bool + { + $entityType = $entity->getEntityType(); + + $checker = $this->getChecker($entityType); + + return $checker->check($user, $entity); + } + + /** + * @return AssignmentChecker + */ + private function getChecker(string $entityType): AssignmentChecker + { + if (!array_key_exists($entityType, $this->checkerCache)) { + $this->loadChecker($entityType); + } + + return $this->checkerCache[$entityType]; + } + + private function loadChecker(string $entityType): void + { + $this->checkerCache[$entityType] = $this->factory->create($entityType); + } +} diff --git a/application/Espo/Core/Acl/AssignmentChecker/Helper.php b/application/Espo/Core/Acl/AssignmentChecker/Helper.php new file mode 100644 index 0000000..7d729d0 --- /dev/null +++ b/application/Espo/Core/Acl/AssignmentChecker/Helper.php @@ -0,0 +1,360 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\AssignmentChecker; + +use Espo\Core\Acl\Permission; +use Espo\Core\Acl\Table; +use Espo\Core\AclManager; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Entity as CoreEntity; +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; +use Espo\Repositories\User as UserRepository; + +class Helper +{ + private const ATTR_ASSIGNED_USER_ID = Field::ASSIGNED_USER . 'Id'; + private const FIELD_TEAMS = Field::TEAMS; + private const FIELD_ASSIGNED_USERS = Field::ASSIGNED_USERS; + private const FIELD_COLLABORATORS = Field::COLLABORATORS; + + public function __construct( + private EntityManager $entityManager, + private AclManager $aclManager, + private Defs $ormDefs, + private Metadata $metadata, + ) {} + + public function checkAssignedUser(User $user, Entity $entity): bool + { + if (!$entity->hasAttribute(self::ATTR_ASSIGNED_USER_ID)) { + return true; + } + + if ($user->isPortal()) { + return !$entity->isAttributeChanged(self::ATTR_ASSIGNED_USER_ID); + } + + $assignmentPermission = $this->aclManager->getPermissionLevel($user, Permission::ASSIGNMENT); + + if ($assignmentPermission === Table::LEVEL_ALL) { + return true; + } + + $toProcess = false; + + if (!$entity->isNew()) { + if ($entity->isAttributeChanged(self::ATTR_ASSIGNED_USER_ID)) { + $toProcess = true; + } + } else { + $toProcess = true; + } + + if (!$toProcess) { + return true; + } + + $assignedUserId = $entity->get(self::ATTR_ASSIGNED_USER_ID); + + if (!$assignedUserId) { + if ($assignmentPermission === Table::LEVEL_NO && !$user->isApi()) { + return false; + } + + return true; + } + + if ($assignmentPermission === Table::LEVEL_NO) { + return $user->getId() === $assignedUserId; + } + + if ($assignmentPermission === Table::LEVEL_TEAM) { + $teamIdList = $user->getTeamIdList(); + + return $this->getUserRepository()->checkBelongsToAnyOfTeams($assignedUserId, $teamIdList); + } + + return false; + } + + public function checkTeams(User $user, Entity $entity): bool + { + $assignmentPermission = $this->aclManager->getPermissionLevel($user, Permission::ASSIGNMENT); + + if (!in_array($assignmentPermission, [Table::LEVEL_TEAM, Table::LEVEL_NO])) { + return true; + } + + if (!$entity instanceof CoreEntity) { + return true; + } + + if (!$entity->hasLinkMultipleField(self::FIELD_TEAMS)) { + return true; + } + + $teamIdList = $entity->getLinkMultipleIdList(self::FIELD_TEAMS); + + if ($teamIdList === []) { + return $this->isPermittedTeamsEmpty($user, $entity); + } + + $newIdList = []; + + if (!$entity->isNew()) { + $existingIdList = []; + + $teamCollection = $this->entityManager + ->getRelation($entity, self::FIELD_TEAMS) + ->select(Attribute::ID) + ->find(); + + foreach ($teamCollection as $team) { + $existingIdList[] = $team->getId(); + } + + foreach ($teamIdList as $id) { + if (!in_array($id, $existingIdList)) { + $newIdList[] = $id; + } + } + } else { + $newIdList = $teamIdList; + } + + if ($newIdList === []) { + return true; + } + + $userTeamIdList = $user->getLinkMultipleIdList(self::FIELD_TEAMS); + + foreach ($newIdList as $id) { + if (!in_array($id, $userTeamIdList)) { + return false; + } + } + + return true; + } + + public function checkUsers(User $user, Entity $entity, string $field): bool + { + if (!$entity instanceof CoreEntity) { + return true; + } + + $idsAttr = $field . 'Ids'; + + if (!$entity->hasLinkMultipleField($field)) { + return true; + } + + if ($user->isPortal()) { + if (!$entity->isAttributeChanged($idsAttr)) { + return true; + } + + return false; + } + + $assignmentPermission = $this->aclManager->getPermissionLevel($user, Permission::ASSIGNMENT); + + if ($assignmentPermission === Table::LEVEL_ALL) { + if (!$this->hasOnlyInternalUsers($user, $entity, $field)) { + return false; + } + + return true; + } + + $toProcess = $entity->isNew() || $entity->isAttributeChanged($idsAttr); + + if (!$toProcess) { + return true; + } + + $userIds = $entity->getLinkMultipleIdList($field); + + if ($userIds === []) { + if ( + $assignmentPermission === Table::LEVEL_NO && + !$user->isApi() && + $field !== Field::COLLABORATORS + ) { + return false; + } + + return true; + } + + if ($assignmentPermission === Table::LEVEL_NO) { + return $this->isPermittedUsersLevelNo($user, $entity, $field); + } + + if ($assignmentPermission === Table::LEVEL_TEAM) { + return $this->isPermittedUsersLevelTeam($user, $entity, $field); + } + + return false; + } + + private function isPermittedUsersLevelNo(User $user, CoreEntity $entity, string $field): bool + { + $userIds = $this->getAddedLinkMultipleIds($entity, $field); + + foreach ($userIds as $userId) { + if ($user->getId() !== $userId) { + return false; + } + } + + return true; + } + + private function isPermittedUsersLevelTeam(User $user, CoreEntity $entity, string $field): bool + { + $teamIds = $user->getLinkMultipleIdList(self::FIELD_TEAMS); + $userIds = $this->getAddedLinkMultipleIds($entity, $field); + + foreach ($userIds as $userId) { + if (!$this->getUserRepository()->checkBelongsToAnyOfTeams($userId, $teamIds)) { + return false; + } + } + + return true; + } + + private function hasOnlyInternalUsers(User $user, CoreEntity $entity, string $field): bool + { + $ids = array_diff($this->getAddedLinkMultipleIds($entity, $field), [$user->getId()]); + $ids = array_values($ids); + + $count = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + 'type!=' => [ + User::TYPE_REGULAR, + User::TYPE_ADMIN, + ], + Attribute::ID => $ids, + ]) + ->count(); + + return $count === 0; + } + + /** + * @return string[] + */ + private function getAddedLinkMultipleIds(CoreEntity $entity, string $field): array + { + /** @var string[] $previousIds */ + $previousIds = $entity->getFetched($field . 'Ids') ?? []; + + return array_values(array_diff($entity->getLinkMultipleIdList($field), $previousIds)); + } + + private function isPermittedTeamsEmpty(User $user, CoreEntity $entity): bool + { + $assignmentPermission = $this->aclManager->getPermissionLevel($user, Permission::ASSIGNMENT); + + if ($assignmentPermission !== Table::LEVEL_TEAM) { + return true; + } + + if ($entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS)) { + $assignedUserIdList = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS); + + if ($assignedUserIdList === []) { + return false; + } + } else if ($entity->hasAttribute(self::ATTR_ASSIGNED_USER_ID)) { + if (!$entity->get(self::ATTR_ASSIGNED_USER_ID)) { + return false; + } + } + + return true; + } + + public function hasAssignedUsersField(string $entityType): bool + { + $entityDefs = $this->ormDefs->getEntity($entityType); + + return + $entityDefs->hasField(self::FIELD_ASSIGNED_USERS) && + $entityDefs->getField(self::FIELD_ASSIGNED_USERS)->getType() === FieldType::LINK_MULTIPLE && + $entityDefs->hasRelation(self::FIELD_ASSIGNED_USERS) && + $entityDefs->getRelation(self::FIELD_ASSIGNED_USERS)->getForeignEntityType() === User::ENTITY_TYPE; + } + + public function hasAssignedUserField(string $entityType): bool + { + $entityDefs = $this->ormDefs->getEntity($entityType); + + $fieldDefs = $entityDefs->tryGetField(Field::ASSIGNED_USER); + + if (!$fieldDefs) { + return false; + } + + return $fieldDefs->getType() === FieldType::LINK && + !$fieldDefs->getParam(FieldParam::DISABLED) && + $entityDefs->hasRelation(Field::ASSIGNED_USER) && + $entityDefs->getRelation(Field::ASSIGNED_USER)->getForeignEntityType() === User::ENTITY_TYPE; + } + + public function hasCollaboratorsField(string $entityType): bool + { + if (!$this->metadata->get("scopes.$entityType.collaborators")) { + return false; + } + + $entityDefs = $this->ormDefs->getEntity($entityType); + + return + $entityDefs->tryGetField(self::FIELD_COLLABORATORS)?->getType() === FieldType::LINK_MULTIPLE && + $entityDefs->tryGetRelation(self::FIELD_COLLABORATORS)?->tryGetForeignEntityType() === User::ENTITY_TYPE; + } + + private function getUserRepository(): UserRepository + { + /** @var UserRepository */ + return $this->entityManager->getRepository(User::ENTITY_TYPE); + } +} diff --git a/application/Espo/Core/Acl/Cache/Clearer.php b/application/Espo/Core/Acl/Cache/Clearer.php new file mode 100644 index 0000000..4f68e52 --- /dev/null +++ b/application/Espo/Core/Acl/Cache/Clearer.php @@ -0,0 +1,112 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Cache; + +use Espo\Core\Portal\Acl\Events\PortalUserRoleUpdate; +use Espo\Core\Acl\Events\UserRoleUpdate; +use Espo\Core\Utils\Event\EventDispatcher; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\System\SystemState; +use Espo\Entities\Portal; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +class Clearer +{ + public function __construct( + private FileManager $fileManager, + private EntityManager $entityManager, + private SystemState $systemState, + private EventDispatcher $eventDispatcher, + ) {} + + public function clearForAllInternalUsers(): void + { + $this->fileManager->removeInDir('data/cache/application/acl'); + $this->fileManager->removeInDir('data/cache/application/aclMap'); + + $this->bumpSystemStateVersionNumber(); + } + + public function clearForAllPortalUsers(): void + { + $this->fileManager->removeInDir('data/cache/application/aclPortal'); + $this->fileManager->removeInDir('data/cache/application/aclPortalMap'); + + $this->bumpSystemStateVersionNumber(); + } + + public function clearForUser(User $user): void + { + if ($user->isPortal()) { + $this->clearForPortalUser($user); + + return; + } + + $file = basename($user->getId() . '.php'); + $dir = basename($user->getId()); + + $this->fileManager->remove('data/cache/application/acl/' . $file); + $this->fileManager->remove('data/cache/application/aclMap/' . $file); + $this->fileManager->removeInDir('data/cache/application/acl/' . $dir, true); + $this->fileManager->removeInDir('data/cache/application/aclMap/' . $dir, true); + + $this->eventDispatcher->dispatch(new UserRoleUpdate($user->getId())); + } + + private function clearForPortalUser(User $user): void + { + $portals = $this->entityManager + ->getRDBRepositoryByClass(Portal::class) + ->select(Attribute::ID) + ->find(); + + foreach ($portals as $portal) { + $part = basename($portal->getId()) . '/' . basename($user->getId() . '.php'); + + $this->fileManager->remove('data/cache/application/aclPortal/' . $part); + $this->fileManager->remove('data/cache/application/aclPortalMap/' . $part); + + $event = new PortalUserRoleUpdate( + userId: $user->getId(), + portalId: $portal->getId(), + ); + + $this->eventDispatcher->dispatch($event); + } + } + + private function bumpSystemStateVersionNumber(): void + { + $this->systemState->bumpVersionNumber(); + } +} diff --git a/application/Espo/Core/Acl/DefaultAccessChecker.php b/application/Espo/Core/Acl/DefaultAccessChecker.php new file mode 100644 index 0000000..c94a2fb --- /dev/null +++ b/application/Espo/Core/Acl/DefaultAccessChecker.php @@ -0,0 +1,236 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Core\Name\Field; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Core\Acl\AccessChecker\ScopeChecker; +use Espo\Core\Acl\AccessChecker\ScopeCheckerData; +use Espo\Core\AclManager; +use Espo\Core\Utils\Config; + +use DateTime; +use Exception; + +/** + * A default implementation for access checking. + * + * @implements AccessEntityCreateChecker + * @implements AccessEntityReadChecker + * @implements AccessEntityEditChecker + * @implements AccessEntityDeleteChecker + * @implements AccessEntityStreamChecker + */ +class DefaultAccessChecker implements + + AccessEntityCreateChecker, + AccessEntityReadChecker, + AccessEntityEditChecker, + AccessEntityDeleteChecker, + AccessEntityStreamChecker +{ + private const ATTR_CREATED_BY_ID = Field::CREATED_BY . 'Id'; + private const ATTR_CREATED_AT = Field::CREATED_AT; + private const ATTR_ASSIGNED_USER_ID = Field::ASSIGNED_USER . 'Id'; + private const ALLOW_DELETE_OWN_CREATED_PERIOD = '24 hours'; + + public function __construct( + private AclManager $aclManager, + private Config $config, + private ScopeChecker $scopeChecker + ) {} + + /** + * @param Table::ACTION_* $action + * @noinspection PhpDocSignatureInspection + */ + private function checkEntity(User $user, Entity $entity, ScopeData $data, string $action): bool + { + $checkerData = ScopeCheckerData + ::createBuilder() + ->setIsOwnChecker( + fn(): bool => $this->aclManager->checkOwnershipOwn($user, $entity) + ) + ->setInTeamChecker( + fn(): bool => $this->aclManager->checkOwnershipTeam($user, $entity) + ) + ->setIsSharedChecker( + fn(): bool => $this->aclManager->checkOwnershipShared($user, $entity, $action) + ) + ->build(); + + return $this->scopeChecker->check($data, $action, $checkerData); + } + + private function checkScope(ScopeData $data, ?string $action = null): bool + { + $checkerData = ScopeCheckerData + ::createBuilder() + ->setIsOwn(true) + ->setInTeam(true) + ->setIsShared(true) + ->build(); + + return $this->scopeChecker->check($data, $action, $checkerData); + } + + public function check(User $user, ScopeData $data): bool + { + return $this->checkScope($data); + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return $this->checkScope($data, Table::ACTION_CREATE); + } + + public function checkRead(User $user, ScopeData $data): bool + { + return $this->checkScope($data, Table::ACTION_READ); + } + + public function checkEdit(User $user, ScopeData $data): bool + { + return $this->checkScope($data, Table::ACTION_EDIT); + } + + public function checkDelete(User $user, ScopeData $data): bool + { + if ($this->checkScope($data, Table::ACTION_DELETE)) { + return true; + } + + if ($data->getCreate() === Table::LEVEL_NO) { + return false; + } + + if ($this->config->get('aclAllowDeleteCreated')) { + return true; + } + + return false; + } + + public function checkStream(User $user, ScopeData $data): bool + { + return $this->checkScope($data, Table::ACTION_STREAM); + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntity($user, $entity, $data, Table::ACTION_CREATE); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntity($user, $entity, $data, Table::ACTION_READ); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntity($user, $entity, $data, Table::ACTION_EDIT); + } + + public function checkEntityStream(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntity($user, $entity, $data, Table::ACTION_STREAM); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + if ($this->checkEntity($user, $entity, $data, Table::ACTION_DELETE)) { + return true; + } + + if ($data->getCreate() === Table::LEVEL_NO) { + return false; + } + + if ( + !$this->config->get('aclAllowDeleteCreated') || + !$entity->has(self::ATTR_CREATED_BY_ID) || + $entity->get(self::ATTR_CREATED_BY_ID) !== $user->getId() + ) { + return false; + } + + $isDeletedAllowed = false; + + if (!$entity->has(self::ATTR_ASSIGNED_USER_ID)) { + $isDeletedAllowed = true; + } else { + if (!$entity->get(self::ATTR_ASSIGNED_USER_ID)) { + $isDeletedAllowed = true; + } else if ($entity->get(self::ATTR_ASSIGNED_USER_ID) === $entity->get(self::ATTR_CREATED_BY_ID)) { + $isDeletedAllowed = true; + } + } + + if (!$isDeletedAllowed) { + return false; + } + + $createdAt = $entity->get(self::ATTR_CREATED_AT); + + if (!$createdAt) { + return true; + } + + $deleteThresholdPeriod = + $this->config->get('aclAllowDeleteCreatedThresholdPeriod') ?? + self::ALLOW_DELETE_OWN_CREATED_PERIOD; + + if (self::isDateTimeAfterPeriod($createdAt, $deleteThresholdPeriod)) { + return false; + } + + return true; + } + + private static function isDateTimeAfterPeriod(string $value, string $period): bool + { + try { + $dt = new DateTime($value); + } catch (Exception) { + return false; + } + + $dt->modify($period); + + $dtNow = new DateTime(); + + if ($dtNow->format('U') > $dt->format('U')) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Core/Acl/DefaultAssignmentChecker.php b/application/Espo/Core/Acl/DefaultAssignmentChecker.php new file mode 100644 index 0000000..c32b280 --- /dev/null +++ b/application/Espo/Core/Acl/DefaultAssignmentChecker.php @@ -0,0 +1,92 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Core\Acl\AssignmentChecker\Helper; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @implements AssignmentChecker + */ +class DefaultAssignmentChecker implements AssignmentChecker +{ + protected const FIELD_ASSIGNED_USERS = Field::ASSIGNED_USERS; + private const FIELD_COLLABORATORS = Field::COLLABORATORS; + + public function __construct( + private Helper $helper, + ) {} + + public function check(User $user, Entity $entity): bool + { + if (!$this->isPermittedAssignedUser($user, $entity)) { + return false; + } + + if (!$this->isPermittedTeams($user, $entity)) { + return false; + } + + if ($this->helper->hasAssignedUsersField($entity->getEntityType())) { + if (!$this->isPermittedAssignedUsers($user, $entity)) { + return false; + } + } + + if ($this->helper->hasCollaboratorsField($entity->getEntityType())) { + if (!$this->helper->checkUsers($user, $entity, self::FIELD_COLLABORATORS)) { + return false; + } + } + + return true; + } + + protected function isPermittedAssignedUser(User $user, Entity $entity): bool + { + return $this->helper->checkAssignedUser($user, $entity); + } + + protected function isPermittedTeams(User $user, Entity $entity): bool + { + return $this->helper->checkTeams($user, $entity); + } + + /** + * Left for backward compatibility. + */ + protected function isPermittedAssignedUsers(User $user, Entity $entity): bool + { + return $this->helper->checkUsers($user, $entity, self::FIELD_ASSIGNED_USERS); + } +} diff --git a/application/Espo/Core/Acl/DefaultOwnershipChecker.php b/application/Espo/Core/Acl/DefaultOwnershipChecker.php new file mode 100644 index 0000000..b73b091 --- /dev/null +++ b/application/Espo/Core/Acl/DefaultOwnershipChecker.php @@ -0,0 +1,135 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Core\Name\Field; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Entities\User; + +/** + * A default implementation for ownership checking. + * + * @implements OwnershipOwnChecker + * @implements OwnershipTeamChecker + * @implements OwnershipSharedChecker + */ +class DefaultOwnershipChecker implements OwnershipOwnChecker, OwnershipTeamChecker, OwnershipSharedChecker +{ + private const ATTR_CREATED_BY_ID = Field::CREATED_BY . 'Id'; + private const ATTR_ASSIGNED_USER_ID = Field::ASSIGNED_USER . 'Id'; + private const ATTR_ASSIGNED_TEAMS_IDS = Field::TEAMS . 'Ids'; + private const FIELD_TEAMS = Field::TEAMS; + private const FIELD_ASSIGNED_USERS = Field::ASSIGNED_USERS; + private const FIELD_COLLABORATORS = Field::COLLABORATORS; + + public function checkOwn(User $user, Entity $entity): bool + { + if ($entity instanceof CoreEntity && $entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS)) { + if ($entity->hasLinkMultipleId(self::FIELD_ASSIGNED_USERS, $user->getId())) { + return true; + } + + return false; + } + + if ($entity->hasAttribute(self::ATTR_ASSIGNED_USER_ID)) { + if ( + $entity->has(self::ATTR_ASSIGNED_USER_ID) && + $user->getId() === $entity->get(self::ATTR_ASSIGNED_USER_ID) + ) { + return true; + } + + return false; + } + + if ($entity->hasAttribute(self::ATTR_CREATED_BY_ID)) { + if ( + $entity->has(self::ATTR_CREATED_BY_ID) && + $user->getId() === $entity->get(self::ATTR_CREATED_BY_ID) + ) { + return true; + } + } + + return false; + } + + public function checkTeam(User $user, Entity $entity): bool + { + if (!$entity instanceof CoreEntity) { + return false; + } + + $userTeamIdList = $user->getLinkMultipleIdList(self::FIELD_TEAMS); + + if ( + !$entity->hasRelation(self::FIELD_TEAMS) || + !$entity->hasAttribute(self::ATTR_ASSIGNED_TEAMS_IDS) + ) { + return false; + } + + $entityTeamIdList = $entity->getLinkMultipleIdList(self::FIELD_TEAMS); + + if (empty($entityTeamIdList)) { + return false; + } + + foreach ($userTeamIdList as $id) { + if (in_array($id, $entityTeamIdList)) { + return true; + } + } + + return false; + } + + public function checkShared(User $user, Entity $entity, string $action): bool + { + if (!$entity instanceof CoreEntity) { + return false; + } + + if ($action !== Table::ACTION_READ && $action !== Table::ACTION_STREAM) { + return false; + } + + if ( + !$entity->hasRelation(self::FIELD_COLLABORATORS) || + !$entity->hasLinkMultipleField(self::FIELD_COLLABORATORS) + ) { + return false; + } + + return in_array($user->getId(), $entity->getLinkMultipleIdList(self::FIELD_COLLABORATORS)); + } +} diff --git a/application/Espo/Core/Acl/Events/UserRoleUpdate.php b/application/Espo/Core/Acl/Events/UserRoleUpdate.php new file mode 100644 index 0000000..a0e62a8 --- /dev/null +++ b/application/Espo/Core/Acl/Events/UserRoleUpdate.php @@ -0,0 +1,64 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; +use UnexpectedValueException; + +/** + * @since 10.1.0 + */ +readonly class UserRoleUpdate implements CrossInstanceEvent +{ + public function __construct( + public string $userId, + ) {} + + public static function fromPayload(stdClass $payload): self + { + $userId = $payload->userId ?? null; + + if (!is_string($userId)) { + throw new UnexpectedValueException(); + } + + return new self( + userId: $userId, + ); + } + + public function toPayload(): stdClass + { + return (object) [ + 'userId' => $this->userId, + ]; + } +} diff --git a/application/Espo/Core/Acl/Exceptions/NotAvailable.php b/application/Espo/Core/Acl/Exceptions/NotAvailable.php new file mode 100644 index 0000000..e06f585 --- /dev/null +++ b/application/Espo/Core/Acl/Exceptions/NotAvailable.php @@ -0,0 +1,34 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Exceptions; + +use RuntimeException; + +class NotAvailable extends RuntimeException {} diff --git a/application/Espo/Core/Acl/Exceptions/NotImplemented.php b/application/Espo/Core/Acl/Exceptions/NotImplemented.php new file mode 100644 index 0000000..a5f4ede --- /dev/null +++ b/application/Espo/Core/Acl/Exceptions/NotImplemented.php @@ -0,0 +1,34 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Exceptions; + +use RuntimeException; + +class NotImplemented extends RuntimeException {} diff --git a/application/Espo/Core/Acl/Exceptions/Restricted.php b/application/Espo/Core/Acl/Exceptions/Restricted.php new file mode 100644 index 0000000..51c6a74 --- /dev/null +++ b/application/Espo/Core/Acl/Exceptions/Restricted.php @@ -0,0 +1,38 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Exceptions; + +use Exception; + +/** + * @since 10.0.0 + */ +class Restricted extends Exception +{} diff --git a/application/Espo/Core/Acl/FieldData.php b/application/Espo/Core/Acl/FieldData.php new file mode 100644 index 0000000..f4cdd98 --- /dev/null +++ b/application/Espo/Core/Acl/FieldData.php @@ -0,0 +1,96 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use stdClass; +use RuntimeException; + +/** + * Field data. + */ +class FieldData +{ + /** + * @var array + */ + private $actionData = []; + + private function __construct() {} + + /** + * @return never + */ + public function __get(string $name) + { + throw new RuntimeException("Accessing ScopeData properties is not allowed."); + } + + /** + * Get a level for an action. + */ + public function get(string $action): string + { + return $this->actionData[$action] ?? Table::LEVEL_NO; + } + + /** + * Get a 'read' level. + */ + public function getRead(): string + { + return $this->get(Table::ACTION_READ); + } + + /** + * Get an 'edit' level. + */ + public function getEdit(): string + { + return $this->get(Table::ACTION_EDIT); + } + + /** + * Create from a raw table value. + */ + public static function fromRaw(stdClass $raw): self + { + $obj = new self(); + + $obj->actionData = get_object_vars($raw); + + foreach ($obj->actionData as $item) { + if (!is_string($item)) { + throw new RuntimeException("Bad raw scope data."); + } + } + + return $obj; + } +} diff --git a/application/Espo/Core/Acl/GlobalRestriction.php b/application/Espo/Core/Acl/GlobalRestriction.php new file mode 100644 index 0000000..8249028 --- /dev/null +++ b/application/Espo/Core/Acl/GlobalRestriction.php @@ -0,0 +1,224 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Core\Utils\Cache\DataCacheAccess; +use Espo\Core\Utils\FieldUtil; +use Espo\Core\Utils\Metadata; + +use stdClass; + +/** + * Lists of restricted fields can be obtained from here. Restricted fields + * are specified in metadata > entityAcl. + */ +class GlobalRestriction +{ + /** Totally forbidden. */ + public const string TYPE_FORBIDDEN = 'forbidden'; + /** Reading forbidden, writing allowed. */ + public const string TYPE_INTERNAL = 'internal'; + /** Forbidden for non-admin users. */ + public const string TYPE_ONLY_ADMIN = 'onlyAdmin'; + /** Read-only for all users. */ + public const string TYPE_READ_ONLY = 'readOnly'; + /** Read-only for non-admin users. */ + public const string TYPE_NON_ADMIN_READ_ONLY = 'nonAdminReadOnly'; + + /** + * @var array + */ + private $fieldTypeList = [ + self::TYPE_FORBIDDEN, + self::TYPE_INTERNAL, + self::TYPE_ONLY_ADMIN, + self::TYPE_READ_ONLY, + self::TYPE_NON_ADMIN_READ_ONLY, + ]; + + /** + * @var array + */ + private $linkTypeList = [ + self::TYPE_FORBIDDEN, + self::TYPE_INTERNAL, + self::TYPE_ONLY_ADMIN, + self::TYPE_READ_ONLY, + self::TYPE_NON_ADMIN_READ_ONLY, + ]; + + /** + * Types that should also be taken from entityDefs. + * @var array + */ + private array $entityDefsTypeList = [ + self::TYPE_READ_ONLY, + ]; + + private string $cacheKey = 'entityAcl'; + + public function __construct( + private Metadata $metadata, + private FieldUtil $fieldUtil, + private DataCacheAccess $dataCacheAccess, + ) { + + $this->dataCacheAccess->init( + key: $this->cacheKey, + loader: fn () => $this->buildData(), + ); + } + + private function buildData(): stdClass + { + /** @var string[] $scopeList */ + $scopeList = array_keys($this->metadata->get(['entityDefs']) ?? []); + + $data = (object) []; + + foreach ($scopeList as $scope) { + /** @var string[] $fieldList */ + $fieldList = array_keys($this->metadata->get(['entityDefs', $scope, 'fields']) ?? []); + /** @var string[] $linkList */ + $linkList = array_keys($this->metadata->get(['entityDefs', $scope, 'links']) ?? []); + + $isNotEmpty = false; + + $scopeData = (object) [ + 'fields' => (object) [], + 'attributes' => (object) [], + 'links' => (object) [], + ]; + + foreach ($this->fieldTypeList as $type) { + $resultFieldList = []; + $resultAttributeList = []; + + foreach ($fieldList as $field) { + $value = $this->metadata->get(['entityAcl', $scope, 'fields', $field, $type]); + + if (!$value && in_array($type, $this->entityDefsTypeList)) { + $value = $this->metadata->get(['entityDefs', $scope, 'fields', $field, $type]); + } + + if ( + $type === self::TYPE_FORBIDDEN && + $this->metadata->get("entityDefs.$scope.fields.$field.disabled") + ) { + $value = true; + } + + if (!$value) { + continue; + } + + $isNotEmpty = true; + + $resultFieldList[] = $field; + + foreach ($this->fieldUtil->getAttributeList($scope, $field) as $attribute) { + $resultAttributeList[] = $attribute; + } + } + + $scopeData->fields->$type = $resultFieldList; + $scopeData->attributes->$type = $resultAttributeList; + } + + foreach ($this->linkTypeList as $type) { + $resultLinkList = []; + + foreach ($linkList as $link) { + $value = $this->metadata->get(['entityAcl', $scope, 'links', $link, $type]); + + if (!$value && in_array($type, $this->entityDefsTypeList)) { + $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; + } + + $isNotEmpty = true; + + $resultLinkList[] = $link; + } + + $scopeData->links->$type = $resultLinkList; + } + + if ($isNotEmpty) { + $data->$scope = $scopeData; + } + } + + return $data; + } + + /** + * @param self::TYPE_* $type + * @return string[] + */ + public function getScopeRestrictedFieldList(string $scope, string $type): array + { + $data = $this->dataCacheAccess->get(); + + return $data->$scope->fields->$type ?? []; + } + + /** + * @param self::TYPE_* $type + * @return string[] + */ + public function getScopeRestrictedAttributeList(string $scope, string $type): array + { + $data = $this->dataCacheAccess->get(); + + return $data->$scope->attributes->$type ?? []; + } + + /** + * @param self::TYPE_* $type + * @return string[] + */ + public function getScopeRestrictedLinkList(string $scope, string $type): array + { + $data = $this->dataCacheAccess->get(); + + return $data->$scope->links->$type ?? []; + } +} diff --git a/application/Espo/Core/Acl/LinkChecker.php b/application/Espo/Core/Acl/LinkChecker.php new file mode 100644 index 0000000..7488535 --- /dev/null +++ b/application/Espo/Core/Acl/LinkChecker.php @@ -0,0 +1,48 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * Checks access for linking/unlinking two records. + * + * @template TEntity of Entity + * @template TForeignEntity of Entity + */ +interface LinkChecker +{ + /** + * @param TEntity $entity + * @param TForeignEntity $foreignEntity + */ + public function check(User $user, Entity $entity, Entity $foreignEntity): bool; +} diff --git a/application/Espo/Core/Acl/LinkChecker/LinkCheckerFactory.php b/application/Espo/Core/Acl/LinkChecker/LinkCheckerFactory.php new file mode 100644 index 0000000..2d848d1 --- /dev/null +++ b/application/Espo/Core/Acl/LinkChecker/LinkCheckerFactory.php @@ -0,0 +1,74 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\LinkChecker; + +use Espo\Core\Acl\LinkChecker; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; +use RuntimeException; + +class LinkCheckerFactory +{ + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + /** + * Create a link checker. + * + * @return LinkChecker + */ + public function create(string $scope, string $link): LinkChecker + { + $className = $this->getClassName($scope, $link); + + if (!$className) { + throw new RuntimeException("Link checker is not implemented for {$scope}.{$link}."); + } + + return $this->injectableFactory->create($className); + } + + public function isCreatable(string $scope, string $link): bool + { + return (bool) $this->getClassName($scope, $link); + } + + /** + * @return ?class-string> + */ + private function getClassName(string $scope, string $link): ?string + { + /** @var ?class-string> */ + return $this->metadata->get(['aclDefs', $scope, 'linkCheckerClassNameMap', $link]); + } +} diff --git a/application/Espo/Core/Acl/Map/CacheKeyProvider.php b/application/Espo/Core/Acl/Map/CacheKeyProvider.php new file mode 100644 index 0000000..a5fbd0f --- /dev/null +++ b/application/Espo/Core/Acl/Map/CacheKeyProvider.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Map; + +interface CacheKeyProvider +{ + public function get(): string; +} diff --git a/application/Espo/Core/Acl/Map/DataBuilder.php b/application/Espo/Core/Acl/Map/DataBuilder.php new file mode 100644 index 0000000..f1c188f --- /dev/null +++ b/application/Espo/Core/Acl/Map/DataBuilder.php @@ -0,0 +1,189 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Map; + +use Espo\Core\Acl\Table; +use Espo\Core\Utils\FieldUtil; + +use stdClass; + +class DataBuilder +{ + /** @var string[] */ + private $actionList = [ + Table::ACTION_READ, + Table::ACTION_STREAM, + Table::ACTION_EDIT, + Table::ACTION_DELETE, + Table::ACTION_CREATE, + ]; + /** @var string[] */ + private $fieldActionList = [ + Table::ACTION_READ, + Table::ACTION_EDIT, + ]; + /** @var string[] */ + private $fieldLevelList = [ + Table::LEVEL_YES, + Table::LEVEL_NO, + ]; + + public function __construct(private MetadataProvider $metadataProvider, private FieldUtil $fieldUtil) + {} + + /** + * @return stdClass&object{table: stdClass, fieldTable: stdClass} + */ + public function build(Table $table): stdClass + { + $data = (object) [ + 'table' => (object) [], + 'fieldTable' => (object) [], + ]; + + foreach ($this->metadataProvider->getScopeList() as $scope) { + $data->table->$scope = $this->getScopeRawData($table, $scope); + + $fieldData = $this->getScopeFieldData($table, $scope); + + if ($fieldData !== null) { + $data->fieldTable->$scope = $fieldData; + } + } + + foreach ($this->metadataProvider->getPermissionList() as $permission) { + $data->{$permission . 'Permission'} = $table->getPermissionLevel($permission); + } + + $data->fieldTableQuickAccess = $this->buildFieldTableQuickAccess($data->fieldTable); + + return $data; + } + + /** + * @return bool|stdClass + */ + private function getScopeRawData(Table $table, string $scope) + { + $data = $table->getScopeData($scope); + + if ($data->isBoolean()) { + return $data->isTrue(); + } + + $rawData = (object) []; + + foreach ($this->actionList as $action) { + $rawData->$action = $data->get($action); + } + + return $rawData; + } + + private function getScopeFieldData(Table $table, string $scope): ?stdClass + { + if (!$this->metadataProvider->isScopeEntity($scope)) { + return null; + } + + $fieldList = $this->metadataProvider->getScopeFieldList($scope); + + $rawData = (object) []; + + foreach ($fieldList as $field) { + $data = $table->getFieldData($scope, $field); + + if ( + $data->getRead() === Table::LEVEL_YES && + $data->getEdit() === Table::LEVEL_YES + ) { + continue; + } + + $rawData->$field = (object) [ + Table::ACTION_READ => $data->getRead(), + Table::ACTION_EDIT => $data->getEdit(), + ]; + } + + return $rawData; + } + + protected function buildFieldTableQuickAccess(stdClass $fieldTable): stdClass + { + $quickAccess = (object) []; + + foreach (get_object_vars($fieldTable) as $scope => $scopeData) { + $quickAccess->$scope = $this->buildFieldTableQuickAccessScope($scope, $scopeData); + } + + return $quickAccess; + } + + private function buildFieldTableQuickAccessScope(string $scope, stdClass $data): stdClass + { + $quickAccess = (object) [ + 'attributes' => (object) [], + 'fields' => (object) [], + ]; + + foreach ($this->fieldActionList as $action) { + $quickAccess->attributes->$action = (object) []; + $quickAccess->fields->$action = (object) []; + + foreach ($this->fieldLevelList as $level) { + $quickAccess->attributes->$action->$level = []; + $quickAccess->fields->$action->$level = []; + } + } + + foreach (get_object_vars($data) as $field => $fieldData) { + $attributeList = $this->fieldUtil->getAttributeList($scope, $field); + + foreach ($this->fieldActionList as $action) { + if (!isset($fieldData->$action)) { + continue; + } + + foreach ($this->fieldLevelList as $level) { + if ($fieldData->$action === $level) { + $quickAccess->fields->$action->{$level}[] = $field; + + foreach ($attributeList as $attribute) { + $quickAccess->attributes->$action->{$level}[] = $attribute; + } + } + } + } + } + + return $quickAccess; + } +} diff --git a/application/Espo/Core/Acl/Map/DefaultCacheKeyProvider.php b/application/Espo/Core/Acl/Map/DefaultCacheKeyProvider.php new file mode 100644 index 0000000..e59ca7f --- /dev/null +++ b/application/Espo/Core/Acl/Map/DefaultCacheKeyProvider.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Map; + +use Espo\Entities\User; + +final class DefaultCacheKeyProvider implements CacheKeyProvider +{ + public function __construct(private User $user) + {} + + public function get(): string + { + $key = 'aclMap/' . $this->user->getId(); + + $scopes = $this->user->getScopes(); + + if ($scopes === null) { + return $key; + } + + $hash = hash('xxh128', implode(' ', $scopes)); + + $key .= '/' . $hash; + + return $key; + } +} diff --git a/application/Espo/Core/Acl/Map/Map.php b/application/Espo/Core/Acl/Map/Map.php new file mode 100644 index 0000000..49f2ab8 --- /dev/null +++ b/application/Espo/Core/Acl/Map/Map.php @@ -0,0 +1,224 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Map; + +use Espo\Core\Acl\Table; +use Espo\Core\Utils\Cache\DataCacheAccess; +use Espo\Core\Utils\ObjectUtil; +use stdClass; +use RuntimeException; + +/** + * Provides quick access to ACL data. + */ +class Map +{ + /** @var array */ + private $forbiddenFieldsCache = []; + /** @var array */ + private $forbiddenAttributesCache; + /** @var string[] */ + private $fieldLevelList = [ + Table::LEVEL_YES, + Table::LEVEL_NO, + ]; + + /** + * @param DataCacheAccess $dataCacheAccess + */ + public function __construct( + Table $table, + private DataBuilder $dataBuilder, + CacheKeyProvider $cacheKeyProvider, + private DataCacheAccess $dataCacheAccess, + ) { + + $this->dataCacheAccess->init( + key: $cacheKeyProvider->get(), + loader: fn () => $this->dataBuilder->build($table), + ); + } + + /** + * Get raw data (for front-end). + */ + public function getData(): stdClass + { + return ObjectUtil::clone($this->dataCacheAccess->get()); + } + + /** + * Get a list of forbidden attributes for a scope and action. + * + * @param string $scope A scope. + * @param string $action An action. + * @param string $thresholdLevel An attribute will be treated as forbidden if the level is + * equal to or lower than the threshold. + * @return string[] + */ + public function getScopeForbiddenAttributeList( + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + if ( + !in_array($thresholdLevel, $this->fieldLevelList) || + $thresholdLevel === Table::LEVEL_YES + ) { + throw new RuntimeException("Bad threshold level."); + } + + $key = $scope . '_'. $action . '_' . $thresholdLevel; + + if (isset($this->forbiddenAttributesCache[$key])) { + return $this->forbiddenAttributesCache[$key]; + } + + $data = $this->dataCacheAccess->get(); + + $fieldTableQuickAccess = $data->fieldTableQuickAccess; + + if ( + !isset($fieldTableQuickAccess->$scope) || + !isset($fieldTableQuickAccess->$scope->attributes) || + !isset($fieldTableQuickAccess->$scope->attributes->$action) + ) { + $this->forbiddenAttributesCache[$key] = []; + + return []; + } + + $levelList = []; + + foreach ($this->fieldLevelList as $level) { + if ( + array_search($level, $this->fieldLevelList) >= + array_search($thresholdLevel, $this->fieldLevelList) + ) { + $levelList[] = $level; + } + } + + $attributeList = []; + + foreach ($levelList as $level) { + if (!isset($fieldTableQuickAccess->$scope->attributes->$action->$level)) { + continue; + } + + foreach ($fieldTableQuickAccess->$scope->attributes->$action->$level as $attribute) { + if (in_array($attribute, $attributeList)) { + continue; + } + + $attributeList[] = $attribute; + } + } + + $this->forbiddenAttributesCache[$key] = $attributeList; + + return $attributeList; + } + + /** + * Get a list of forbidden fields for a scope and action. + * + * @param string $scope A scope. + * @param string $action An action. + * @param string $thresholdLevel An attribute will be treated as forbidden if the level is + * equal to or lower than the threshold. + * @return string[] + */ + public function getScopeForbiddenFieldList( + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + if ( + !in_array($thresholdLevel, $this->fieldLevelList) || + $thresholdLevel === Table::LEVEL_YES + ) { + throw new RuntimeException("Bad threshold level."); + } + + $key = $scope . '_'. $action . '_' . $thresholdLevel; + + if (isset($this->forbiddenFieldsCache[$key])) { + return $this->forbiddenFieldsCache[$key]; + } + + $data = $this->dataCacheAccess->get(); + + $fieldTableQuickAccess = $data->fieldTableQuickAccess; + + if ( + !isset($fieldTableQuickAccess->$scope) || + !isset($fieldTableQuickAccess->$scope->fields) || + !isset($fieldTableQuickAccess->$scope->fields->$action) + ) { + $this->forbiddenFieldsCache[$key] = []; + + return []; + } + + $levelList = []; + + foreach ($this->fieldLevelList as $level) { + if ( + array_search($level, $this->fieldLevelList) >= + array_search($thresholdLevel, $this->fieldLevelList) + ) { + $levelList[] = $level; + } + } + + $fieldList = []; + + foreach ($levelList as $level) { + if (!isset($fieldTableQuickAccess->$scope->fields->$action->$level)) { + continue; + } + + foreach ($fieldTableQuickAccess->$scope->fields->$action->$level as $field) { + if (in_array($field, $fieldList)) { + continue; + } + + $fieldList[] = $field; + } + } + + $this->forbiddenFieldsCache[$key] = $fieldList; + + return $fieldList; + } +} diff --git a/application/Espo/Core/Acl/Map/MapFactory.php b/application/Espo/Core/Acl/Map/MapFactory.php new file mode 100644 index 0000000..4eb4825 --- /dev/null +++ b/application/Espo/Core/Acl/Map/MapFactory.php @@ -0,0 +1,64 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Map; + +use Espo\Entities\User; + +use Espo\Core\Acl\Table; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; + +class MapFactory +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function create(User $user, Table $table): Map + { + $bindingContainer = $this->createBindingContainer($user, $table); + + return $this->injectableFactory->createWithBinding(Map::class, $bindingContainer); + } + + private function createBindingContainer(User $user, Table $table): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + $binder + ->bindInstance(User::class, $user) + ->bindInstance(Table::class, $table) + ->bindImplementation(CacheKeyProvider::class, DefaultCacheKeyProvider::class); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Acl/Map/MetadataProvider.php b/application/Espo/Core/Acl/Map/MetadataProvider.php new file mode 100644 index 0000000..5273bb5 --- /dev/null +++ b/application/Espo/Core/Acl/Map/MetadataProvider.php @@ -0,0 +1,82 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Map; + +use Espo\Core\Utils\Metadata; + +class MetadataProvider +{ + protected string $type = 'acl'; + + public function __construct(private Metadata $metadata) + {} + + /** + * @return string[] + */ + public function getScopeList(): array + { + /** @var string[] */ + return array_keys($this->metadata->get('scopes') ?? []); + } + + public function isScopeEntity(string $scope): bool + { + return (bool) $this->metadata->get(['scopes', $scope, 'entity']); + } + + /** + * @return string[] + */ + public function getScopeFieldList(string $scope): array + { + /** @var string[] */ + return array_keys($this->metadata->get(['entityDefs', $scope, 'fields']) ?? []); + } + + /** + * @return array + */ + public function getPermissionList(): array + { + $itemList = $this->metadata->get(['app', $this->type, 'valuePermissionList']) ?? []; + + return array_map( + function (string $item): string { + if (str_ends_with($item, 'Permission')) { + return substr($item, 0, -10); + } + + return $item; + }, + $itemList + ); + } +} diff --git a/application/Espo/Core/Acl/OwnerUserFieldProvider.php b/application/Espo/Core/Acl/OwnerUserFieldProvider.php new file mode 100644 index 0000000..15cae1d --- /dev/null +++ b/application/Espo/Core/Acl/OwnerUserFieldProvider.php @@ -0,0 +1,90 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Core\Name\Field; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\ORM\Defs; + +class OwnerUserFieldProvider +{ + protected const FIELD_ASSIGNED_USERS = Field::ASSIGNED_USERS; + protected const FIELD_ASSIGNED_USER = Field::ASSIGNED_USER; + protected const FIELD_CREATED_BY = Field::CREATED_BY; + + public function __construct(private Defs $ormDefs, private Metadata $metadata) + {} + + /** + * Get an entity field that stores an owner-user (or multiple users). + * Must be a link or linkMultiple field. NULL means no owner. + */ + public function get(string $entityType): ?string + { + $value = $this->metadata->get(['aclDefs', $entityType, 'readOwnerUserField']); + + if ($value) { + return $value; + } + + $defs = $this->ormDefs->getEntity($entityType); + + if ( + $defs->hasField(self::FIELD_ASSIGNED_USERS) && + $defs->getField(self::FIELD_ASSIGNED_USERS)->getType() === FieldType::LINK_MULTIPLE && + $defs->hasRelation(self::FIELD_ASSIGNED_USERS) && + $defs->getRelation(self::FIELD_ASSIGNED_USERS)->getForeignEntityType() === User::ENTITY_TYPE + ) { + return self::FIELD_ASSIGNED_USERS; + } + + if ( + $defs->hasField(self::FIELD_ASSIGNED_USER) && + $defs->getField(self::FIELD_ASSIGNED_USER)->getType() === FieldType::LINK && + $defs->hasRelation(self::FIELD_ASSIGNED_USER) && + $defs->getRelation(self::FIELD_ASSIGNED_USER)->getForeignEntityType() === User::ENTITY_TYPE + ) { + return self::FIELD_ASSIGNED_USER; + } + + if ( + $defs->hasField(self::FIELD_CREATED_BY) && + $defs->getField(self::FIELD_CREATED_BY)->getType() === FieldType::LINK && + $defs->hasRelation(self::FIELD_CREATED_BY) && + $defs->getRelation(self::FIELD_CREATED_BY)->getForeignEntityType() === User::ENTITY_TYPE + ) { + return self::FIELD_CREATED_BY; + } + + return null; + } +} diff --git a/application/Espo/Core/Acl/OwnershipChecker.php b/application/Espo/Core/Acl/OwnershipChecker.php new file mode 100644 index 0000000..103b5f6 --- /dev/null +++ b/application/Espo/Core/Acl/OwnershipChecker.php @@ -0,0 +1,37 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +/** + * Bindings: + * - `$entityType` – as of v9.1.0. + * - `Espo\Core\AclManager` + */ +interface OwnershipChecker {} diff --git a/application/Espo/Core/Acl/OwnershipChecker/OwnershipCheckerFactory.php b/application/Espo/Core/Acl/OwnershipChecker/OwnershipCheckerFactory.php new file mode 100644 index 0000000..516b038 --- /dev/null +++ b/application/Espo/Core/Acl/OwnershipChecker/OwnershipCheckerFactory.php @@ -0,0 +1,103 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\OwnershipChecker; + +use Espo\Core\Acl\DefaultOwnershipChecker; +use Espo\Core\Acl\Exceptions\NotImplemented; +use Espo\Core\Acl\OwnershipChecker; +use Espo\Core\AclManager; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +class OwnershipCheckerFactory +{ + /** @var class-string */ + private string $defaultClassName = DefaultOwnershipChecker::class; + + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + /** + * Create an ownership checker. + * + * @throws NotImplemented + */ + public function create(string $scope, AclManager $aclManager): OwnershipChecker + { + $className = $this->getClassName($scope); + + $bindingContainer = $this->createBindingContainer($className, $aclManager, $scope); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + /** + * @return class-string + * @throws NotImplemented + */ + private function getClassName(string $scope): string + { + /** @var ?class-string $className */ + $className = $this->metadata->get(['aclDefs', $scope, 'ownershipCheckerClassName']); + + if ($className) { + return $className; + } + + if (!$this->metadata->get(['scopes', $scope])) { + throw new NotImplemented(); + } + + return $this->defaultClassName; + } + + /** + * @param class-string $className + */ + private function createBindingContainer(string $className, AclManager $aclManager, string $scope): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder->bindInstance(AclManager::class, $aclManager); + + $binder + ->for($className) + ->bindValue('$entityType', $scope); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Acl/OwnershipOwnChecker.php b/application/Espo/Core/Acl/OwnershipOwnChecker.php new file mode 100644 index 0000000..d9c8187 --- /dev/null +++ b/application/Espo/Core/Acl/OwnershipOwnChecker.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface OwnershipOwnChecker extends OwnershipChecker +{ + /** + * Check whether a user is an owner of an entity. + * + * @param TEntity $entity + */ + public function checkOwn(User $user, Entity $entity): bool; +} diff --git a/application/Espo/Core/Acl/OwnershipSharedChecker.php b/application/Espo/Core/Acl/OwnershipSharedChecker.php new file mode 100644 index 0000000..2bee358 --- /dev/null +++ b/application/Espo/Core/Acl/OwnershipSharedChecker.php @@ -0,0 +1,48 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface OwnershipSharedChecker extends OwnershipChecker +{ + /** + * Check whether an entity is shared with a user. + * + * @param TEntity $entity + * @param Table::ACTION_* $action + * @noinspection PhpDocSignatureInspection + */ + public function checkShared(User $user, Entity $entity, string $action): bool; +} diff --git a/application/Espo/Core/Acl/OwnershipTeamChecker.php b/application/Espo/Core/Acl/OwnershipTeamChecker.php new file mode 100644 index 0000000..361eea0 --- /dev/null +++ b/application/Espo/Core/Acl/OwnershipTeamChecker.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface OwnershipTeamChecker extends OwnershipChecker +{ + /** + * Check whether an entity belongs to a user team. + * + * @param TEntity $entity + */ + public function checkTeam(User $user, Entity $entity): bool; +} diff --git a/application/Espo/Core/Acl/Permission.php b/application/Espo/Core/Acl/Permission.php new file mode 100644 index 0000000..e49fd04 --- /dev/null +++ b/application/Espo/Core/Acl/Permission.php @@ -0,0 +1,47 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +class Permission +{ + public const ASSIGNMENT = 'assignment'; + public const USER = 'user'; + public const PORTAL = 'portal'; + public const MASS_UPDATE = 'massUpdate'; + public const EXPORT = 'export'; + public const AUDIT = 'audit'; + public const DATA_PRIVACY = 'dataPrivacy'; + public const MESSAGE = 'message'; + public const MENTION = 'mention'; + public const USER_CALENDAR = 'userCalendar'; + public const FOLLOWER_MANAGEMENT = 'followerManagement'; + public const GROUP_EMAIL_ACCOUNT = 'groupEmailAccount'; + public const LOCK = 'lock'; +} diff --git a/application/Espo/Core/Acl/Scope.php b/application/Espo/Core/Acl/Scope.php new file mode 100644 index 0000000..b49c918 --- /dev/null +++ b/application/Espo/Core/Acl/Scope.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +/** + * @since 10.1.0 + */ +class Scope +{ + /** + * Access to admin capabilities. + */ + public const string ADMIN = 'Admin'; + + /** + * Reserved scope used for OAuth. If Global is present, the current user's scopes are not stripped. + */ + public const string GLOBAL = 'Global'; +} diff --git a/application/Espo/Core/Acl/ScopeData.php b/application/Espo/Core/Acl/ScopeData.php new file mode 100644 index 0000000..9f8093e --- /dev/null +++ b/application/Espo/Core/Acl/ScopeData.php @@ -0,0 +1,183 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use stdClass; +use InvalidArgumentException; +use RuntimeException; + +/** + * Scope data. + */ +class ScopeData +{ + /** @var stdClass|bool */ + private $raw; + /** @var array */ + private $actionData = []; + private bool $isBoolean = false; + + private function __construct() {} + + /** + * @return never + */ + public function __get(string $name) + { + throw new RuntimeException("Accessing ScopeData properties is not allowed."); + } + + /** + * Is of boolean type. + */ + public function isBoolean(): bool + { + return $this->isBoolean; + } + + /** + * Is true. + */ + public function isTrue(): bool + { + if (!$this->isBoolean) { + return false; + } + + return $this->raw === true; + } + + /** + * Is false. + */ + public function isFalse(): bool + { + if (!$this->isBoolean) { + return false; + } + + return $this->raw === false; + } + + /** + * Has any level other than 'no'. + */ + public function hasNotNo(): bool + { + foreach ($this->actionData as $level) { + if ($level !== Table::LEVEL_NO) { + return true; + } + } + + return false; + } + + /** + * Get a level for an action. + */ + public function get(string $action): string + { + return $this->actionData[$action] ?? Table::LEVEL_NO; + } + + /** + * Get a 'read' level. + */ + public function getRead(): string + { + return $this->get(Table::ACTION_READ); + } + + /** + * Get a 'stream' level. + */ + public function getStream(): string + { + return $this->get(Table::ACTION_STREAM); + } + + /** + * Get a 'create' level. + */ + public function getCreate(): string + { + return $this->get(Table::ACTION_CREATE); + } + + /** + * Get an 'edit' level. + */ + public function getEdit(): string + { + return $this->get(Table::ACTION_EDIT); + } + + /** + * Get a 'delete' level. + */ + public function getDelete(): string + { + return $this->get(Table::ACTION_DELETE); + } + + /** + * Create from a raw table value. + * + * @param stdClass|bool $raw + * @return self + */ + public static function fromRaw($raw): self + { + /** @var mixed $raw */ + + $obj = new self(); + + if ($raw instanceof stdClass) { + $obj->isBoolean = false; + + $obj->actionData = get_object_vars($raw); + + foreach ($obj->actionData as $item) { + if (!is_string($item)) { + throw new RuntimeException("Bad raw scope data."); + } + } + } else if (is_bool($raw)) { + $obj->isBoolean = true; + } else { + throw new InvalidArgumentException(); + } + + $obj->raw = $raw; + + return $obj; + } +} diff --git a/application/Espo/Core/Acl/SystemRestriction.php b/application/Espo/Core/Acl/SystemRestriction.php new file mode 100644 index 0000000..bc58f8a --- /dev/null +++ b/application/Espo/Core/Acl/SystemRestriction.php @@ -0,0 +1,231 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +use Espo\Core\Acl\Exceptions\Restricted; +use Espo\Core\Utils\Metadata; +use Espo\Entities\AppLogRecord; +use Espo\Entities\ArrayValue; +use Espo\Entities\Extension; +use Espo\Entities\MassAction; +use Espo\Entities\PasswordChangeRequest; +use Espo\Entities\SystemData; +use Espo\Entities\TwoFactorCode; +use Espo\Entities\User; +use Espo\ORM\Entity; + +/** + * @since 10.0.0 + */ +class SystemRestriction +{ + /** @var string[] */ + private array $writeForbiddenEntityTypeList = [ + Extension::ENTITY_TYPE, + AppLogRecord::ENTITY_TYPE, + PasswordChangeRequest::ENTITY_TYPE, + TwoFactorCode::ENTITY_TYPE, + SystemData::ENTITY_TYPE, + MassAction::ENTITY_TYPE, + ArrayValue::ENTITY_TYPE, + ]; + + public function __construct( + private Metadata $metadata, + private GlobalRestriction $globalRestriction, + ) {} + + /** + * @throws Restricted + */ + public function assertUpdate(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + if (!$this->checkEntityTypeWrite($entity->getEntityType())) { + throw new Restricted("Cannot write '$entityType' entity."); + } + + if ($entity instanceof User) { + $this->assertUserUpdate($entity); + } + } + + /** + * @throws Restricted + */ + public function assertRemoval(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + if (!$this->checkEntityTypeWrite($entity->getEntityType())) { + throw new Restricted("Cannot remove '$entityType' entity."); + } + + if ($entity instanceof User) { + $this->assertRemovalUser($entity); + } + } + + public function checkEntityTypeWrite(string $entityType): bool + { + if (in_array($entityType, $this->writeForbiddenEntityTypeList)) { + return false; + } + + if ($this->metadata->get("entityAcl.$entityType.systemWriteForbidden")) { + return false; + } + + return true; + } + + /** + * @return string[] + */ + private static function getUserRestrictedTypeList(): array + { + return [ + User::TYPE_SUPER_ADMIN, + User::TYPE_SYSTEM, + ]; + } + + /** + * @throws Restricted + */ + private function assertUserUpdate(User $entity): void + { + $restrictedTypeList = self::getUserRestrictedTypeList(); + + if ( + $entity->isAttributeChanged(User::ATTR_TYPE) && + ( + in_array($entity->getFetched(User::ATTR_TYPE), $restrictedTypeList) || + in_array($entity->getType(), $restrictedTypeList) + ) + ) { + throw new Restricted("Cannot change user type."); + } + } + + /** + * @throws Restricted + */ + private function assertRemovalUser(User $entity): void + { + if (in_array($entity->getType(), self::getUserRestrictedTypeList())) { + throw new Restricted("Cannot remove {$entity->getId()} user."); + } + } + + /** + * @return string[] + */ + public function getReadRestrictedAttributeList(string $entityType): array + { + $list1 = $this->globalRestriction + ->getScopeRestrictedAttributeList($entityType, GlobalRestriction::TYPE_FORBIDDEN); + + $list2 = $this->globalRestriction + ->getScopeRestrictedAttributeList($entityType, GlobalRestriction::TYPE_INTERNAL); + + $list = array_merge($list1, $list2); + $list = array_unique($list); + + return array_values($list); + } + + /** + * @return string[] + */ + public function getWriteRestrictedAttributeList(string $entityType): array + { + return $this->globalRestriction + ->getScopeRestrictedAttributeList($entityType, GlobalRestriction::TYPE_FORBIDDEN); + } + + public function checkLinkWrite(string $entityType, string $link): bool + { + $forbiddenList = $this->globalRestriction + ->getScopeRestrictedLinkList($entityType, GlobalRestriction::TYPE_FORBIDDEN); + + return !in_array($link, $forbiddenList); + } + + public function checkLinkRead(string $entityType, string $link): bool + { + $internalList = $this->globalRestriction + ->getScopeRestrictedLinkList($entityType, GlobalRestriction::TYPE_INTERNAL); + + $forbiddenList = $this->globalRestriction + ->getScopeRestrictedLinkList($entityType, GlobalRestriction::TYPE_FORBIDDEN); + + return !in_array($link, $internalList) && !in_array($link, $forbiddenList); + } + + public function checkAttributeRead(string $entityType, string $attribute): bool + { + $internalList = $this->globalRestriction + ->getScopeRestrictedAttributeList($entityType, GlobalRestriction::TYPE_INTERNAL); + + $forbiddenList = $this->globalRestriction + ->getScopeRestrictedAttributeList($entityType, GlobalRestriction::TYPE_FORBIDDEN); + + return !in_array($attribute, $internalList) && !in_array($attribute, $forbiddenList); + } + + public function checkFieldRead(string $entityType, string $field): bool + { + $internalList = $this->globalRestriction + ->getScopeRestrictedFieldList($entityType, GlobalRestriction::TYPE_INTERNAL); + + $forbiddenList = $this->globalRestriction + ->getScopeRestrictedFieldList($entityType, GlobalRestriction::TYPE_FORBIDDEN); + + return !in_array($field, $internalList) && !in_array($field, $forbiddenList); + } + + public function checkFieldWrite(string $entityType, string $field): bool + { + $forbiddenList = $this->globalRestriction + ->getScopeRestrictedFieldList($entityType, GlobalRestriction::TYPE_FORBIDDEN); + + return !in_array($field, $forbiddenList); + } + + public function checkAttributeWrite(string $entityType, string $attribute): bool + { + $forbiddenList = $this->globalRestriction + ->getScopeRestrictedAttributeList($entityType, GlobalRestriction::TYPE_FORBIDDEN); + + return !in_array($attribute, $forbiddenList); + } +} diff --git a/application/Espo/Core/Acl/Table.php b/application/Espo/Core/Acl/Table.php new file mode 100644 index 0000000..4e0cf23 --- /dev/null +++ b/application/Espo/Core/Acl/Table.php @@ -0,0 +1,65 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl; + +/** + * Access levels for a user. + */ +interface Table +{ + public const LEVEL_YES = 'yes'; + public const LEVEL_NO = 'no'; + public const LEVEL_ALL = 'all'; + public const LEVEL_TEAM = 'team'; + public const LEVEL_OWN = 'own'; + + public const ACTION_READ = 'read'; + public const ACTION_STREAM = 'stream'; + public const ACTION_EDIT = 'edit'; + public const ACTION_DELETE = 'delete'; + public const ACTION_CREATE = 'create'; + + /** + * Get scope data. + */ + public function getScopeData(string $scope): ScopeData; + + /** + * Get field data. + */ + public function getFieldData(string $scope, string $field): FieldData; + + /** + * Get a permission level. + * + * @return self::ACTION_* + */ + public function getPermissionLevel(string $permission): string; +} diff --git a/application/Espo/Core/Acl/Table/CacheKeyProvider.php b/application/Espo/Core/Acl/Table/CacheKeyProvider.php new file mode 100644 index 0000000..0b6cd18 --- /dev/null +++ b/application/Espo/Core/Acl/Table/CacheKeyProvider.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +interface CacheKeyProvider +{ + public function get(): string; +} diff --git a/application/Espo/Core/Acl/Table/DefaultCacheKeyProvider.php b/application/Espo/Core/Acl/Table/DefaultCacheKeyProvider.php new file mode 100644 index 0000000..65334f9 --- /dev/null +++ b/application/Espo/Core/Acl/Table/DefaultCacheKeyProvider.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +use Espo\Entities\User; + +final class DefaultCacheKeyProvider implements CacheKeyProvider +{ + public function __construct(private User $user) + {} + + public function get(): string + { + $key = 'acl/' . $this->user->getId(); + + $scopes = $this->user->getScopes(); + + if ($scopes === null) { + return $key; + } + + $hash = hash('xxh128', implode(' ', $scopes)); + + $key .= '/' . $hash; + + return $key; + } +} diff --git a/application/Espo/Core/Acl/Table/DefaultRoleListProvider.php b/application/Espo/Core/Acl/Table/DefaultRoleListProvider.php new file mode 100644 index 0000000..aac7592 --- /dev/null +++ b/application/Espo/Core/Acl/Table/DefaultRoleListProvider.php @@ -0,0 +1,103 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +use Espo\Core\Name\Field; +use Espo\Core\Utils\Config; +use Espo\Entities\Team; +use Espo\ORM\EntityManager; +use Espo\Entities\User; +use Espo\Entities\Role as RoleEntity; + +class DefaultRoleListProvider implements RoleListProvider +{ + private const PARAM_BASELINE_ROLE_ID = 'baselineRoleId'; + + public function __construct( + private User $user, + private EntityManager $entityManager, + private Config $config, + ) {} + + /** + * @return Role[] + */ + public function get(): array + { + $roleList = []; + + $baselineRole = $this->getBaselineRole(); + + if ($baselineRole) { + $roleList[] = $baselineRole; + } + + /** @var iterable $userRoleList */ + $userRoleList = $this->entityManager + ->getRelation($this->user, User::LINK_ROLES) + ->find(); + + foreach ($userRoleList as $role) { + $roleList[] = $role; + } + + /** @var iterable $teamList */ + $teamList = $this->entityManager + ->getRelation($this->user, Field::TEAMS) + ->find(); + + foreach ($teamList as $team) { + /** @var iterable $teamRoleList */ + $teamRoleList = $this->entityManager + ->getRelation($team, Team::LINK_ROLES) + ->find(); + + foreach ($teamRoleList as $role) { + $roleList[] = $role; + } + } + + return array_map( + fn (RoleEntity $role) => new RoleEntityWrapper($role), + $roleList + ); + } + + private function getBaselineRole(): ?RoleEntity + { + $roleId = $this->config->get(self::PARAM_BASELINE_ROLE_ID); + + if (!$roleId) { + return null; + } + + return $this->entityManager->getRDBRepositoryByClass(RoleEntity::class)->getById($roleId); + } +} diff --git a/application/Espo/Core/Acl/Table/DefaultTable.php b/application/Espo/Core/Acl/Table/DefaultTable.php new file mode 100644 index 0000000..935d9b7 --- /dev/null +++ b/application/Espo/Core/Acl/Table/DefaultTable.php @@ -0,0 +1,765 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +use Espo\Core\Acl\Scope; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Entities\User; + +use Espo\Core\Acl\FieldData; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Table; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Metadata; +use stdClass; +use RuntimeException; + +/** + * A table is generated for a user. Multiple roles are merged into a single table. + * Stores access levels. + */ +class DefaultTable implements Table +{ + private const LEVEL_NOT_SET = 'not-set'; + + protected string $type = 'acl'; + + /** @var string[] */ + private $actionList = [ + self::ACTION_READ, + self::ACTION_STREAM, + self::ACTION_EDIT, + self::ACTION_DELETE, + self::ACTION_CREATE, + ]; + + /** @var string[] */ + private $booleanActionList = [ + self::ACTION_CREATE, + ]; + + /** @var string[] */ + protected $levelList = [ + self::LEVEL_YES, + self::LEVEL_ALL, + self::LEVEL_TEAM, + self::LEVEL_OWN, + self::LEVEL_NO, + ]; + + /** @var string[] */ + private $fieldActionList = [ + self::ACTION_READ, + self::ACTION_EDIT, + ]; + + /** @var string[] */ + protected $fieldLevelList = [ + self::LEVEL_YES, + self::LEVEL_NO, + ]; + + private stdClass $data; + private string $cacheKey; + /** @var string[] */ + private $valuePermissionList = []; + private ScopeDataResolver $scopeDataResolver; + + public function __construct( + private RoleListProvider $roleListProvider, + CacheKeyProvider $cacheKeyProvider, + protected User $user, + SystemConfig $systemConfig, + protected Metadata $metadata, + DataCache $dataCache, + ) { + + $this->data = (object) [ + 'scopes' => (object) [], + 'fields' => (object) [], + 'permissions' => (object) [], + ]; + + if (!$this->user->isFetched()) { + throw new RuntimeException('User must be fetched before ACL check.'); + } + + $this->valuePermissionList = $this->metadata + ->get(['app', $this->type, 'valuePermissionList'], []); + + $this->cacheKey = $cacheKeyProvider->get(); + + $cachedData = null; + + if ($systemConfig->useCache() && $dataCache->has($this->cacheKey)) { + /** @var ?stdClass $cachedData */ + $cachedData = $dataCache->get($this->cacheKey); + + if ($cachedData !== null) { + $this->data = $cachedData; + } + } + + if ($cachedData === null) { + $this->load(); + + if ($systemConfig->useCache()) { + $dataCache->store($this->cacheKey, $this->data); + } + } + + $this->scopeDataResolver = new ScopeDataResolver($this); + } + + /** + * Get scope data. + */ + public function getScopeData(string $scope): ScopeData + { + if (!isset($this->data->scopes->$scope)) { + return ScopeData::fromRaw(false); + } + + $data = $this->data->scopes->$scope; + + return $this->scopeDataResolver->resolve($data); + } + + /** + * Get field data. + */ + public function getFieldData(string $scope, string $field): FieldData + { + if (!isset($this->data->fields->$scope)) { + return FieldData::fromRaw((object) [ + self::ACTION_READ => self::LEVEL_YES, + self::ACTION_EDIT => self::LEVEL_YES, + ]); + } + + $data = $this->data->fields->$scope->$field ?? (object) [ + self::ACTION_READ => self::LEVEL_YES, + self::ACTION_EDIT => self::LEVEL_YES, + ]; + + return FieldData::fromRaw($data); + } + + /** + * Get a permission level. + */ + public function getPermissionLevel(string $permission): string + { + return $this->data->permissions->$permission ?? self::LEVEL_NO; + } + + private function load(): void + { + $valuePermissionLists = (object) []; + + foreach ($this->valuePermissionList as $permission) { + $valuePermissionLists->$permission = []; + } + + $aclTableList = []; + $fieldTableList = []; + + $aclTable = (object) []; + $fieldTable = (object) []; + + if (!$this->isAdmin()) { + $roleList = $this->roleListProvider->get(); + + foreach ($roleList as $role) { + $aclTableList[] = $role->getScopeTableData(); + $fieldTableList[] = $role->getFieldTableData(); + + foreach ($this->valuePermissionList as $permissionKey) { + $permission = $this->normalizePermissionName($permissionKey); + + $valuePermissionLists->{$permissionKey}[] = $role->getPermissionLevel($permission); + } + } + + $aclTable = $this->mergeTableList($aclTableList); + $fieldTable = $this->mergeFieldTableList($fieldTableList); + + $this->applyDefault($aclTable, $fieldTable); + $this->applyDisabled($aclTable, $fieldTable); + $this->stripScopes($aclTable, $fieldTable); + $this->applyMandatory($aclTable, $fieldTable); + } + + if ($this->isAdmin()) { + $aclTable = (object) []; + $fieldTable = (object) []; + + $this->applyHighest($aclTable); + $this->applyDisabled($aclTable, $fieldTable); + $this->stripScopes($aclTable, $fieldTable); + $this->applyAdminMandatoryGeneral($aclTable, $fieldTable); + } + + foreach (get_object_vars($aclTable) as $scope => $data) { + if (is_string($data) && isset($aclTable->$data)) { + $aclTable->$scope = $aclTable->$data; + } + } + + $this->data->scopes = $aclTable; + $this->data->fields = $fieldTable; + + if (!$this->isAdmin()) { + foreach ($this->valuePermissionList as $permissionKey) { + $permission = $this->normalizePermissionName($permissionKey); + + $defaultLevel = $this->metadata + ->get(['app', $this->type, 'permissionsStrictDefaults', $permissionKey]) ?? + self::LEVEL_NO; + + $this->data->permissions->$permission = $this->mergeValueList( + $valuePermissionLists->$permissionKey, + $defaultLevel + ); + + $mandatoryLevel = $this->metadata->get(['app', $this->type, 'mandatory', $permissionKey]); + + if ($mandatoryLevel !== null) { + $this->data->permissions->$permission = $mandatoryLevel; + } + } + } + + if ($this->isAdmin()) { + foreach ($this->valuePermissionList as $permissionKey) { + $permission = $this->normalizePermissionName($permissionKey); + + $highestLevel = $this->metadata + ->get(['app', $this->type, 'valuePermissionHighestLevels', $permissionKey]); + + if ($highestLevel !== null) { + $this->data->permissions->$permission = $highestLevel; + + continue; + } + + $this->data->permissions->$permission = self::LEVEL_ALL; + } + } + } + + private function normalizePermissionName(string $permissionKey): string + { + $permission = $permissionKey; + + if (str_ends_with($permissionKey, 'Permission')) { + $permission = substr($permissionKey, 0, -10); + } + + return $permission; + } + + private function applyHighest(stdClass $table): void + { + foreach ($this->getScopeList() as $scope) { + if ($this->getAclMetaValue($scope) === ScopeDataType::BOOLEAN) { + $table->$scope = true; + + continue; + } + + if (!$this->metadata->get(['scopes', $scope, 'entity'])) { + continue; + } + + $table->$scope = (object) []; + + $actionList = $this->metadata->get( + ['scopes', $scope, $this->type . 'ActionList'], + $this->actionList + ); + + $highest = $this->metadata->get( + ['scopes', $scope, $this->type . 'HighestLevel'], + self::LEVEL_ALL + ); + + foreach ($actionList as $action) { + $table->$scope->$action = $highest; + + if (in_array($action, $this->booleanActionList)) { + $table->$scope->$action = self::LEVEL_YES; + } + } + } + } + + protected function applyDefault(stdClass &$table, stdClass &$fieldTable): void + { + if ($this->isAdmin()) { + return; + } + + $data = $this->metadata->get(['app', $this->type, 'strictDefault', 'scopeLevel'], []); + + foreach ($data as $scope => $item) { + if (isset($table->$scope)) { + continue; + } + + $value = $item; + + if (is_array($item)) { + $value = (object) $item; + } + + $table->$scope = $value; + } + + $defaultFieldData = $this->metadata + ->get(['app', $this->type, 'strictDefault', 'fieldLevel']) ?? []; + + foreach ($this->getScopeList() as $scope) { + if (isset($table->$scope) && $table->$scope === false) { + continue; + } + + if (!$this->metadata->get(['scopes', $scope, 'entity'])) { + continue; + } + + $fieldList = array_keys($this->metadata->get(['entityDefs', $scope, 'fields']) ?? []); + + $defaultScopeFieldData = $this->metadata + ->get(['app', $this->type, 'strictDefault', 'scopeFieldLevel', $scope]) ?? []; + + foreach (array_merge($defaultFieldData, $defaultScopeFieldData) as $field => $f) { + if (!in_array($field, $fieldList)) { + continue; + } + + if (!isset($fieldTable->$scope)) { + $fieldTable->$scope = (object) []; + } + + if (isset($fieldTable->$scope->$field)) { + continue; + } + + $fieldTable->$scope->$field = (object) []; + + foreach ($this->fieldActionList as $action) { + $level = self::LEVEL_NO; + + if ($f === true) { + $level = self::LEVEL_YES; + } else { + if (is_array($f) && isset($f[$action])) { + $level = $f[$action]; + } + } + + $fieldTable->$scope->$field->$action = $level; + } + } + } + + foreach ($this->getScopeWithAclList() as $scope) { + if (isset($table->$scope)) { + continue; + } + + $aclType = $this->getAclMetaValue($scope); + + if (empty($aclType)) { + continue; + } + + $table->$scope = false; + } + } + + private function applyMandatoryInternal(stdClass $table, stdClass $fieldTable, string $type): void + { + $data = $this->metadata->get(['app', $this->type, $type, 'scopeLevel']) ?? []; + + foreach ($data as $scope => $item) { + $value = $item; + + if (is_array($item)) { + $value = (object) $item; + } + + $table->$scope = $value; + } + + $mandatoryFieldData = $this->metadata->get(['app', $this->type, $type, 'fieldLevel']) ?? []; + + foreach ($this->getScopeList() as $scope) { + if (isset($table->$scope) && $table->$scope === false) { + continue; + } + + if (!$this->metadata->get(['scopes', $scope, 'entity'])) { + continue; + } + + $fieldList = array_keys($this->metadata->get(['entityDefs', $scope, 'fields']) ?? []); + + $mandatoryScopeFieldData = $this->metadata + ->get(['app', $this->type, $type, 'scopeFieldLevel', $scope]) ?? []; + + foreach (array_merge($mandatoryFieldData, $mandatoryScopeFieldData) as $field => $item) { + if (!in_array($field, $fieldList)) { + continue; + } + + if (!isset($fieldTable->$scope)) { + $fieldTable->$scope = (object) []; + } + + $fieldTable->$scope->$field = (object) []; + + foreach ($this->fieldActionList as $action) { + $level = self::LEVEL_NO; + + if ($item === true) { + $level = self::LEVEL_YES; + } else { + if (is_array($item) && isset($item[$action])) { + $level = $item[$action]; + } + } + + $fieldTable->$scope->$field->$action = $level; + } + } + } + } + + private function applyMandatory(stdClass $table, stdClass $fieldTable): void + { + $this->applyMandatoryInternal($table, $fieldTable, 'mandatory'); + } + + private function applyAdminMandatory(stdClass $table, stdClass $fieldTable): void + { + $this->applyMandatoryInternal($table, $fieldTable, 'adminMandatory'); + } + + protected function applyDisabled(stdClass $table, stdClass $fieldTable): void + { + foreach ($this->getScopeList() as $scope) { + if ($this->metadata->get(['scopes', $scope, 'disabled'])) { + $table->$scope = false; + + unset($fieldTable->$scope); + } + } + } + + /** + * @param string[] $list + */ + private function mergeValueList(array $list, string $defaultValue): string + { + $result = null; + + foreach ($list as $level) { + if ($level === self::LEVEL_NOT_SET) { + continue; + } + + if (is_null($result)) { + $result = $level; + + continue; + } + + if ( + array_search($result, $this->levelList) > + array_search($level, $this->levelList) + ) { + $result = $level; + } + } + + if (is_null($result)) { + $result = $defaultValue; + } + + return $result; + } + + /** + * @return string[] + */ + protected function getScopeWithAclList(): array + { + $scopeList = []; + + $scopes = $this->metadata->get('scopes'); + + foreach ($scopes as $scope => $d) { + if (empty($d['acl'])) { + continue; + } + + $scopeList[] = $scope; + } + + return $scopeList; + } + + /** + * @return string[] + */ + protected function getScopeList(): array + { + $scopeList = []; + + $scopes = $this->metadata->get('scopes'); + + foreach ($scopes as $scope => $item) { + $scopeList[] = $scope; + } + + return $scopeList; + } + + /** + * @param stdClass[] $tableList + */ + private function mergeTableList(array $tableList): stdClass + { + $data = (object) []; + + $scopeList = $this->getScopeWithAclList(); + + foreach ($tableList as $table) { + foreach ($scopeList as $scope) { + if (!isset($table->$scope)) { + continue; + } + + $this->mergeTableListItem($data, $scope, $table->$scope); + } + } + + return $data; + } + + /** + * @param stdClass|bool|null $row + */ + private function mergeTableListItem(stdClass $data, string $scope, $row): void + { + if ($row === false || $row === null) { + if (!isset($data->$scope)) { + $data->$scope = false; + } + + return; + } + + if ($row === true) { + $data->$scope = true; + + return; + } + + if (!isset($data->$scope)) { + $data->$scope = (object) []; + } + + if ($data->$scope === false) { + $data->$scope = (object) []; + } + + if (!is_object($row)) { + return; + } + + $actionList = $this->metadata + ->get(['scopes', $scope, $this->type . 'ActionList']) ?? $this->actionList; + + foreach ($actionList as $i => $action) { + if (isset($row->$action)) { + $level = $row->$action; + + if (!isset($data->$scope->$action)) { + $data->$scope->$action = $level; + } else if ( + array_search($data->$scope->$action, $this->levelList) > + array_search($level, $this->levelList) + ) { + $data->$scope->$action = $level; + } + + continue; + } + + if (isset($data->$scope->$action)) { + continue; + } + + $data->$scope->$action = self::LEVEL_NO; + + // @todo Remove everything below. In v10.1. + if ($i === 0) { + continue; + } + + $previousAction = $this->actionList[$i - 1]; + + if ($action === self::ACTION_STREAM && isset($data->$scope->$previousAction)) { + $data->$scope->$action = $data->$scope->$previousAction; + } + } + } + + /** + * @param stdClass[] $tableList + */ + private function mergeFieldTableList(array $tableList): stdClass + { + $data = (object) []; + + $scopeList = $this->getScopeWithAclList(); + + foreach ($tableList as $table) { + foreach ($scopeList as $scope) { + if (!isset($table->$scope)) { + continue; + } + + if (!isset($data->$scope)) { + $data->$scope = (object) []; + } + + if (!is_object($table->$scope)) { + continue; + } + + $fieldList = array_keys($this->metadata->get(['entityDefs', $scope, 'fields']) ?? []); + + foreach (get_object_vars($table->$scope) as $field => $row) { + if (!is_object($row)) { + continue; + } + + if (!in_array($field, $fieldList)) { + continue; + } + + if (!isset($data->$scope->$field)) { + $data->$scope->$field = (object) []; + } + + foreach ($this->fieldActionList as $action) { + if (!isset($row->$action)) { + continue; + } + + $level = $row->$action; + + if (!isset($data->$scope->$field->$action)) { + $data->$scope->$field->$action = $level; + } else { + if ( + array_search( + $data->$scope->$field->$action, + $this->fieldLevelList + ) > array_search($level, $this->fieldLevelList) + ) { + $data->$scope->$field->$action = $level; + } + } + } + } + } + } + + return $data; + } + + private function getAclMetaValue(string $scope): mixed + { + return $this->metadata->get("scopes.$scope.$this->type"); + } + + private function applyAdminMandatoryGeneral(stdClass $aclTable, stdClass $fieldTable): void + { + $isAllowedAdmin = $this->user->getScopes() === null || in_array(Scope::ADMIN, $this->user->getScopes()); + + if ($isAllowedAdmin) { + $this->applyAdminMandatory($aclTable, $fieldTable); + } else { + $this->applyMandatory($aclTable, $fieldTable); + } + } + + protected function stripScopes(stdClass $table, stdClass $fieldTable): void + { + if ($this->user->getScopes() === null) { + return; + } + + $map = array_fill_keys($this->user->getScopes(), true); + + if (array_key_exists(Scope::GLOBAL, $map)) { + return; + } + + if ($this->isAdmin() && array_key_exists(Scope::ADMIN, $map)) { + // Grant an effective admin access to everything. + return; + } + + foreach ($this->getScopeList() as $scope) { + $value = $this->getAclMetaValue($scope); + + if (!$value || array_key_exists($scope, $map)) { + continue; + } + + if (isset($table->$scope)) { + $table->$scope = false; + } + + unset($fieldTable->$scope); + } + } + + private function isAdmin(): bool + { + return $this->user->isAdmin(); + } +} diff --git a/application/Espo/Core/Acl/Table/DefaultTableFactory.php b/application/Espo/Core/Acl/Table/DefaultTableFactory.php new file mode 100644 index 0000000..99614d9 --- /dev/null +++ b/application/Espo/Core/Acl/Table/DefaultTableFactory.php @@ -0,0 +1,67 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +use Espo\Entities\User; +use Espo\Core\Acl\Table; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; + +class DefaultTableFactory implements TableFactory +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + /** + * Create a table. + */ + public function create(User $user): Table + { + $bindingContainer = $this->createBindingContainer($user); + + return $this->injectableFactory->createWithBinding(DefaultTable::class, $bindingContainer); + } + + private function createBindingContainer(User $user): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(User::class, $user) + ->bindImplementation(RoleListProvider::class, DefaultRoleListProvider::class) + ->bindImplementation(CacheKeyProvider::class, DefaultCacheKeyProvider::class); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Acl/Table/Role.php b/application/Espo/Core/Acl/Table/Role.php new file mode 100644 index 0000000..a3caf0b --- /dev/null +++ b/application/Espo/Core/Acl/Table/Role.php @@ -0,0 +1,41 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +use stdClass; + +interface Role +{ + public function getScopeTableData(): stdClass; + + public function getFieldTableData(): stdClass; + + public function getPermissionLevel(string $permission): ?string; +} diff --git a/application/Espo/Core/Acl/Table/RoleEntityWrapper.php b/application/Espo/Core/Acl/Table/RoleEntityWrapper.php new file mode 100644 index 0000000..4291e4f --- /dev/null +++ b/application/Espo/Core/Acl/Table/RoleEntityWrapper.php @@ -0,0 +1,55 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +use Espo\ORM\Entity; + +use stdClass; + +class RoleEntityWrapper implements Role +{ + public function __construct(private Entity $entity) + {} + + public function getScopeTableData(): stdClass + { + return $this->entity->get('data') ?? (object) []; + } + + public function getFieldTableData(): stdClass + { + return $this->entity->get('fieldData') ?? (object) []; + } + + public function getPermissionLevel(string $permission): ?string + { + return $this->entity->get($permission . 'Permission'); + } +} diff --git a/application/Espo/Core/Acl/Table/RoleListProvider.php b/application/Espo/Core/Acl/Table/RoleListProvider.php new file mode 100644 index 0000000..7ef7bee --- /dev/null +++ b/application/Espo/Core/Acl/Table/RoleListProvider.php @@ -0,0 +1,38 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +interface RoleListProvider +{ + /** + * @return array + */ + public function get(): array; +} diff --git a/application/Espo/Core/Acl/Table/ScopeDataResolver.php b/application/Espo/Core/Acl/Table/ScopeDataResolver.php new file mode 100644 index 0000000..196d016 --- /dev/null +++ b/application/Espo/Core/Acl/Table/ScopeDataResolver.php @@ -0,0 +1,66 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +use Espo\Core\Acl\ScopeData; +use Espo\Core\Acl\Table; + +/** + * @internal + */ +class ScopeDataResolver +{ + public function __construct( + private Table $table, + ) {} + + public function resolve(mixed $data): ScopeData + { + if (!is_string($data)) { + return ScopeData::fromRaw($data); + } + + $foreignScope = $data; + $isBoolean = false; + + if (str_starts_with($data, 'boolean:')) { + [, $foreignScope] = explode(':', $data, 2); + $isBoolean = true; + } + + $scopeData = $this->table->getScopeData($foreignScope); + + if ($isBoolean && !$scopeData->isBoolean()) { + return ScopeData::fromRaw(true); + } + + return $scopeData; + } +} diff --git a/application/Espo/Core/Acl/Table/ScopeDataType.php b/application/Espo/Core/Acl/Table/ScopeDataType.php new file mode 100644 index 0000000..0e2466c --- /dev/null +++ b/application/Espo/Core/Acl/Table/ScopeDataType.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +class ScopeDataType +{ + const BOOLEAN = 'boolean'; +} diff --git a/application/Espo/Core/Acl/Table/TableFactory.php b/application/Espo/Core/Acl/Table/TableFactory.php new file mode 100644 index 0000000..c889c9e --- /dev/null +++ b/application/Espo/Core/Acl/Table/TableFactory.php @@ -0,0 +1,42 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Table; + +use Espo\Entities\User; + +use Espo\Core\Acl\Table; + +interface TableFactory +{ + /** + * Create a table. + */ + public function create(User $user): Table; +} diff --git a/application/Espo/Core/Acl/Traits/DefaultAccessCheckerDependency.php b/application/Espo/Core/Acl/Traits/DefaultAccessCheckerDependency.php new file mode 100644 index 0000000..1539eae --- /dev/null +++ b/application/Espo/Core/Acl/Traits/DefaultAccessCheckerDependency.php @@ -0,0 +1,95 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Acl\Traits; + +use Espo\ORM\Entity; +use Espo\Entities\User; +use Espo\Core\Acl\DefaultAccessChecker; +use Espo\Core\Acl\ScopeData; + +trait DefaultAccessCheckerDependency +{ + private DefaultAccessChecker $defaultAccessChecker; + + public function check(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->check($user, $data); + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkCreate($user, $data); + } + + public function checkRead(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkRead($user, $data); + } + + public function checkEdit(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEdit($user, $data); + } + + public function checkDelete(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkDelete($user, $data); + } + + public function checkStream(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkStream($user, $data); + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityRead($user, $entity, $data); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityEdit($user, $entity, $data); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityDelete($user, $entity, $data); + } + + public function checkEntityStream(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityStream($user, $entity, $data); + } +} diff --git a/application/Espo/Core/AclManager.php b/application/Espo/Core/AclManager.php new file mode 100644 index 0000000..bd159cd --- /dev/null +++ b/application/Espo/Core/AclManager.php @@ -0,0 +1,811 @@ +. + * + * 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; + +use Espo\Core\Acl\Events\UserRoleUpdate; +use Espo\Core\Acl\OwnershipSharedChecker; +use Espo\Core\Acl\Permission; +use Espo\Core\Acl\Scope; +use Espo\Core\Name\Field; +use Espo\Core\Utils\Event\EventDispatcher; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Entities\User; +use Espo\Core\Acl\AccessChecker; +use Espo\Core\Acl\AccessChecker\AccessCheckerFactory; +use Espo\Core\Acl\AccessCreateChecker; +use Espo\Core\Acl\AccessDeleteChecker; +use Espo\Core\Acl\AccessEditChecker; +use Espo\Core\Acl\AccessEntityCreateChecker; +use Espo\Core\Acl\AccessEntityDeleteChecker; +use Espo\Core\Acl\AccessEntityEditChecker; +use Espo\Core\Acl\AccessEntityReadChecker; +use Espo\Core\Acl\AccessEntityStreamChecker; +use Espo\Core\Acl\AccessReadChecker; +use Espo\Core\Acl\AccessStreamChecker; +use Espo\Core\Acl\Exceptions\NotImplemented; +use Espo\Core\Acl\GlobalRestriction; +use Espo\Core\Acl\Map\Map; +use Espo\Core\Acl\Map\MapFactory; +use Espo\Core\Acl\OwnershipChecker; +use Espo\Core\Acl\OwnershipChecker\OwnershipCheckerFactory; +use Espo\Core\Acl\OwnershipOwnChecker; +use Espo\Core\Acl\OwnershipTeamChecker; +use Espo\Core\Acl\OwnerUserFieldProvider; +use Espo\Core\Acl\Table; +use Espo\Core\Acl\Table\TableFactory; + +use stdClass; +use InvalidArgumentException; + +/** + * A central access point for access checking. + * + * @todo Refactor. Replace with an interface `Espo\Core\Acl\AclManager`. + * Keep `Espo\Core\AclManager` as an extending interface for bc, bind it to the service. + * Implementation in `Espo\Core\Acl\DefaultAclManager`. + * The same for `Portal\AclManager`. + */ +class AclManager +{ + protected const PERMISSION_ASSIGNMENT = Permission::ASSIGNMENT; + + /** @var array */ + private $accessCheckerHashMap = []; + /** @var array */ + private $ownershipCheckerHashMap = []; + /** @var array */ + protected $tableHashMap = []; + /** @var array */ + protected $mapHashMap = []; + /** @var class-string */ + protected $userAclClassName = Acl::class; + + /** @var array> */ + private $entityActionInterfaceMap = [ + Table::ACTION_CREATE => AccessEntityCreateChecker::class, + Table::ACTION_READ => AccessEntityReadChecker::class, + Table::ACTION_EDIT => AccessEntityEditChecker::class, + Table::ACTION_DELETE => AccessEntityDeleteChecker::class, + Table::ACTION_STREAM => AccessEntityStreamChecker::class, + ]; + /** @var array> */ + private $actionInterfaceMap = [ + Table::ACTION_CREATE => AccessCreateChecker::class, + Table::ACTION_READ => AccessReadChecker::class, + Table::ACTION_EDIT => AccessEditChecker::class, + Table::ACTION_DELETE => AccessDeleteChecker::class, + Table::ACTION_STREAM => AccessStreamChecker::class, + ]; + + /** @var AccessCheckerFactory|Portal\Acl\AccessChecker\AccessCheckerFactory */ + protected $accessCheckerFactory; + /** @var OwnershipCheckerFactory|Portal\Acl\OwnershipChecker\OwnershipCheckerFactory */ + protected $ownershipCheckerFactory; + + /** @var TableFactory */ + private $tableFactory; + /** @var MapFactory */ + private $mapFactory; + + public function __construct( + AccessCheckerFactory $accessCheckerFactory, + OwnershipCheckerFactory $ownershipCheckerFactory, + TableFactory $tableFactory, + MapFactory $mapFactory, + protected GlobalRestriction $globalRestriction, + protected OwnerUserFieldProvider $ownerUserFieldProvider, + protected EntityManager $entityManager, + protected EventDispatcher $eventDispatcher, + ) { + $this->accessCheckerFactory = $accessCheckerFactory; + $this->ownershipCheckerFactory = $ownershipCheckerFactory; + $this->tableFactory = $tableFactory; + $this->mapFactory = $mapFactory; + + $this->initEventHandling(); + } + + /** + * Get an access checker for a specific scope. + */ + protected function getAccessChecker(string $scope): AccessChecker + { + if (!array_key_exists($scope, $this->accessCheckerHashMap)) { + $this->accessCheckerHashMap[$scope] = $this->accessCheckerFactory->create($scope, $this); + } + + return $this->accessCheckerHashMap[$scope]; + } + + /** + * Get an ownership checker for a specific scope. + */ + protected function getOwnershipChecker(string $scope): OwnershipChecker + { + if (!array_key_exists($scope, $this->ownershipCheckerHashMap)) { + $this->ownershipCheckerHashMap[$scope] = $this->ownershipCheckerFactory->create($scope, $this); + } + + return $this->ownershipCheckerHashMap[$scope]; + } + + protected function getTable(User $user): Table + { + $key = $user->hasId() ? $user->getId() : spl_object_hash($user); + + if (!array_key_exists($key, $this->tableHashMap)) { + $this->tableHashMap[$key] = $this->tableFactory->create($user); + } + + return $this->tableHashMap[$key]; + } + + protected function getMap(User $user): Map + { + $key = $user->hasId() ? $user->getId() : spl_object_hash($user); + + if (!array_key_exists($key, $this->mapHashMap)) { + $this->mapHashMap[$key] = $this->mapFactory->create($user, $this->getTable($user)); + } + + return $this->mapHashMap[$key]; + } + + /** + * Get a full access data map (for front-end). + */ + public function getMapData(User $user): stdClass + { + return $this->getMap($user)->getData(); + } + + /** + * Get an access level for a specific scope and action. + * + * @param Table::ACTION_* $action + * @noinspection PhpDocSignatureInspection + */ + public function getLevel(User $user, string $scope, string $action): string + { + if (!$this->checkScope($user, $scope)) { + return Table::LEVEL_NO; + } + + $data = $this->getTable($user)->getScopeData($scope); + + return $data->get($action); + } + + /** + * Get a permission. E.g. 'assignment' permission. + */ + public function getPermissionLevel(User $user, string $permission): string + { + if (str_ends_with($permission, 'Permission')) { + $permission = substr($permission, 0, -10); + } + + return $this->getTable($user)->getPermissionLevel($permission); + } + + /** + * Whether there's no 'read' access for a specific scope. + */ + public function checkReadNo(User $user, string $scope): bool + { + return $this->getLevel($user, $scope, Table::ACTION_READ) === Table::LEVEL_NO; + } + + /** + * Whether 'read' access is set to 'team' for a specific scope. + */ + public function checkReadOnlyTeam(User $user, string $scope): bool + { + return $this->getLevel($user, $scope, Table::ACTION_READ) === Table::LEVEL_TEAM; + } + + /** + * Whether 'read' access is set to 'own' for a specific scope. + */ + public function checkReadOnlyOwn(User $user, string $scope): bool + { + return $this->getLevel($user, $scope, Table::ACTION_READ) === Table::LEVEL_OWN; + } + + /** + * Whether 'read' access is set to 'all' for a specific scope. + */ + public function checkReadAll(User $user, string $scope): bool + { + return $this->getLevel($user, $scope, Table::ACTION_READ) === Table::LEVEL_ALL; + } + + /** + * Check a scope or entity. If $action is omitted, it will check whether + * a scope level is set to 'enabled'. + * + * @param User $user A user to check for. + * @param string|Entity $subject An entity type or entity. + * @param Table::ACTION_*|null $action $action Action to check. Constants are available in the `Table` class. + * @throws NotImplemented + * @noinspection PhpDocSignatureInspection + */ + public function check(User $user, $subject, ?string $action = null): bool + { + if (is_string($subject)) { + return $this->checkScope($user, $subject, $action); + } + + /** @var mixed $entity */ + $entity = $subject; + + if ($entity instanceof Entity) { + $action = $action ?? Table::ACTION_READ; + + return $this->checkEntity($user, $entity, $action); + } + + throw new InvalidArgumentException(); + } + + /** + * The same as `check` but does not throw NotImplemented exception. + * + * @param User $user A user to check for. + * @param string|Entity $subject An entity type or entity. + * @param Table::ACTION_*|null $action Action to check. Constants are available in the `Table` class. + * @noinspection PhpDocSignatureInspection + */ + public function tryCheck(User $user, $subject, ?string $action = null): bool + { + try { + return $this->check($user, $subject, $action); + } catch (NotImplemented) { + return false; + } + } + + /** + * Check access to a specific entity. + * + * @param User $user A user to check for. + * @param Entity $entity An entity to check. + * @param Table::ACTION_* $action Action to check. Constants are available in the `Table` class. + * @throws NotImplemented + * @noinspection PhpDocSignatureInspection + */ + public function checkEntity(User $user, Entity $entity, string $action = Table::ACTION_READ): bool + { + $scope = $entity->getEntityType(); + + if (!$this->checkScope($user, $scope, $action)) { + return false; + } + + $data = $this->getTable($user)->getScopeData($scope); + + $checker = $this->getAccessChecker($scope); + + /** @var non-falsy-string $methodName */ + $methodName = 'checkEntity' . ucfirst($action); + + $interface = $this->entityActionInterfaceMap[$action] ?? null; + + if ($interface && $checker instanceof $interface && method_exists($checker, $methodName)) { + return $checker->$methodName($user, $entity, $data); + } + + throw new NotImplemented("No entity access checker for '$scope' action '$action'."); + } + + /** + * Check 'read' access to a specific entity. + * + * @throws NotImplemented + */ + public function checkEntityRead(User $user, Entity $entity): bool + { + /** @noinspection PhpRedundantOptionalArgumentInspection */ + return $this->checkEntity($user, $entity, Table::ACTION_READ); + } + + /** + * Check 'create' access to a specific entity. + * + * @throws NotImplemented + */ + public function checkEntityCreate(User $user, Entity $entity): bool + { + return $this->checkEntity($user, $entity, Table::ACTION_CREATE); + } + + /** + * Check 'edit' access to a specific entity. + * + * @throws NotImplemented + */ + public function checkEntityEdit(User $user, Entity $entity): bool + { + return $this->checkEntity($user, $entity, Table::ACTION_EDIT); + } + + /** + * Check 'delete' access to a specific entity. + * + * @throws NotImplemented + */ + public function checkEntityDelete(User $user, Entity $entity): bool + { + return $this->checkEntity($user, $entity, Table::ACTION_DELETE); + } + + /** + * Check 'stream' access to a specific entity. + * + * @throws NotImplemented + */ + public function checkEntityStream(User $user, Entity $entity): bool + { + return $this->checkEntity($user, $entity, Table::ACTION_STREAM); + } + + /** + * Check whether a user is an owner of an entity. + */ + public function checkOwnershipOwn(User $user, Entity $entity): bool + { + $checker = $this->getOwnershipChecker($entity->getEntityType()); + + if (!$checker instanceof OwnershipOwnChecker) { + return false; + } + + return $checker->checkOwn($user, $entity); + } + + /** + * Check whether an entity belongs to a user team. + */ + public function checkOwnershipTeam(User $user, Entity $entity): bool + { + $checker = $this->getOwnershipChecker($entity->getEntityType()); + + if (!$checker instanceof OwnershipTeamChecker) { + return false; + } + + return $checker->checkTeam($user, $entity); + } + + /** + * Check whether an entity is shared with a user. + * + * @param Table::ACTION_* $action + * @since 9.0.0 + * @noinspection PhpDocSignatureInspection + */ + public function checkOwnershipShared(User $user, Entity $entity, string $action): bool + { + $checker = $this->getOwnershipChecker($entity->getEntityType()); + + if (!$checker instanceof OwnershipSharedChecker) { + return false; + } + + return $checker->checkShared($user, $entity, $action); + } + + /** + * Check access to scope. If $action is omitted, it will check whether a scope level is set to 'enabled'. + * + * @throws NotImplemented If not implemented by an access checker class. + */ + public function checkScope(User $user, string $scope, ?string $action = null): bool + { + if ($action && !$this->checkScope($user, $scope)) { + return false; + } + + $data = $this->getTable($user)->getScopeData($scope); + + $checker = $this->getAccessChecker($scope); + + if (!$action) { + return $checker->check($user, $data); + } + + $methodName = 'check' . ucfirst($action); + + $interface = $this->actionInterfaceMap[$action] ?? null; + + if ($interface && $checker instanceof $interface) { + return $checker->$methodName($user, $data); + } + + // For backward compatibility. + $methodName = 'checkScope'; + + if (!method_exists($checker, $methodName)) { + throw new NotImplemented("No access checker for '$scope' action '$action'."); + } + + return $checker->$methodName($user, $data, $action); + } + + /** + * @return array + */ + protected function getGlobalRestrictionTypeList(User $user, string $action = Table::ACTION_READ): array + { + $typeList = [ + GlobalRestriction::TYPE_FORBIDDEN, + ]; + + if ($action === Table::ACTION_READ) { + $typeList[] = GlobalRestriction::TYPE_INTERNAL; + } + + if (!$this->checkAdmin($user)) { + $typeList[] = GlobalRestriction::TYPE_ONLY_ADMIN; + } + + if ($action === Table::ACTION_EDIT) { + $typeList[] = GlobalRestriction::TYPE_READ_ONLY; + + if (!$this->checkAdmin($user)) { + $typeList[] = GlobalRestriction::TYPE_NON_ADMIN_READ_ONLY; + } + } + + return $typeList; + } + + /** + * Get attributes forbidden for a user. + * + * @param Table::ACTION_READ|Table::ACTION_EDIT $action An action. + * @param string $thresholdLevel Should not be used. Stands for possible future enhancements. + * @return string[] + * @noinspection PhpDocSignatureInspection + */ + public function getScopeForbiddenAttributeList( + User $user, + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + $list = array_merge( + $this->getMap($user)->getScopeForbiddenAttributeList( + $scope, + $action, + $thresholdLevel + ), + $this->getScopeRestrictedAttributeList( + $scope, + $this->getGlobalRestrictionTypeList($user, $action) + ) + ); + + return array_unique($list); + } + + /** + * Get fields forbidden for a user. + * + * @param Table::ACTION_READ|Table::ACTION_EDIT $action An action. + * @param string $thresholdLevel Should not be used. Stands for possible future enhancements. + * @return string[] + * @noinspection PhpDocSignatureInspection + */ + public function getScopeForbiddenFieldList( + User $user, + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + $list = array_merge( + $this->getMap($user)->getScopeForbiddenFieldList( + $scope, + $action, + $thresholdLevel + ), + $this->getScopeRestrictedFieldList( + $scope, + $this->getGlobalRestrictionTypeList($user, $action) + ) + ); + + return array_unique($list); + } + + /** + * Get links forbidden for a user. + * + * @param Table::ACTION_READ|Table::ACTION_EDIT $action An action. + * @param string $thresholdLevel Should not be used. Stands for possible future enhancements. + * @return string[] + * @noinspection PhpUnusedParameterInspection + * @noinspection PhpDocSignatureInspection + */ + public function getScopeForbiddenLinkList( + User $user, + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + return $this->getScopeRestrictedLinkList( + $scope, + $this->getGlobalRestrictionTypeList($user, $action) + ); + } + + /** + * Check access to a field. + * + * @param User $user A user. + * @param string $scope A scope (entity type). + * @param string $field A field to check. + * @param Table::ACTION_READ|Table::ACTION_EDIT $action An action. + * @return bool + * @noinspection PhpDocSignatureInspection + */ + public function checkField(User $user, string $scope, string $field, string $action = Table::ACTION_READ): bool + { + 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 10.0.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. + * + * @param User|string $target User entity or user ID. + */ + public function checkUserPermission(User $user, $target, string $permissionType = Permission::USER): bool + { + $permission = $this->getPermissionLevel($user, $permissionType); + + if (is_object($target)) { + $userId = $target->getId(); + } else { + $userId = $target; + } + + if ($user->getId() === $userId) { + return true; + } + + if ($permission === Table::LEVEL_NO) { + return false; + } + + if ($permission === Table::LEVEL_YES) { + return true; + } + + if ($permission === Table::LEVEL_TEAM) { + $teamIdList = $user->getLinkMultipleIdList(Field::TEAMS); + + /** @var \Espo\Repositories\User $userRepository */ + $userRepository = $this->entityManager->getRepository(User::ENTITY_TYPE); + + if (!$userRepository->checkBelongsToAnyOfTeams($userId, $teamIdList)) { + return false; + } + } + + return true; + } + + /** + * Whether a user can assign to another user. + * + * @param User|string $target User entity or user ID. + */ + public function checkAssignmentPermission(User $user, $target): bool + { + return $this->checkUserPermission($user, $target, self::PERMISSION_ASSIGNMENT); + } + + /** + * Create a wrapper for a specific user. + */ + public function createUserAcl(User $user): Acl + { + $className = $this->userAclClassName; + + /** @var Acl */ + return new $className($this, $user); + } + + /** + * Get a restricted field list for a specific scope by a restriction type. + * + * @param GlobalRestriction::TYPE_*|array $type + * @return string[] + */ + public function getScopeRestrictedFieldList(string $scope, $type): array + { + $typeList = !is_array($type) ? [$type] : $type; + + $list = []; + + foreach ($typeList as $type) { + $list = array_merge( + $list, + $this->globalRestriction->getScopeRestrictedFieldList($scope, $type) + ); + } + + return array_unique($list); + } + + /** + * Get a restricted attribute list for a specific scope by a restriction type. + * + * @param GlobalRestriction::TYPE_*|array $type + * @return string[] + */ + public function getScopeRestrictedAttributeList(string $scope, $type): array + { + $typeList = !is_array($type) ? [$type] : $type; + + $list = []; + + foreach ($typeList as $type) { + $list = array_merge( + $list, + $this->globalRestriction->getScopeRestrictedAttributeList($scope, $type) + ); + } + + return array_unique($list); + } + + /** + * Get a restricted link list for a specific scope by a restriction type. + * + * @param GlobalRestriction::TYPE_*|array $type + * @return string[] + */ + public function getScopeRestrictedLinkList(string $scope, $type): array + { + $typeList = !is_array($type) ? [$type] : $type; + + $list = []; + + foreach ($typeList as $type) { + $list = array_merge( + $list, + $this->globalRestriction->getScopeRestrictedLinkList($scope, $type) + ); + } + + return array_unique($list); + } + + /** + * Get an entity field that stores an owner-user (or multiple users). + * Must be a link or linkMultiple field. NULL means no owner. + */ + public function getReadOwnerUserField(string $entityType): ?string + { + return $this->ownerUserFieldProvider->get($entityType); + } + + /** + * @deprecated Use `getPermissionLevel` instead. + */ + public function get(User $user, string $permission): string + { + return $this->getPermissionLevel($user, $permission); + } + + /** + * @deprecated Use `checkUserPermission` instead. + */ + public function checkUser(User $user, string $permission, User $target): bool + { + if ($this->getPermissionLevel($user, $permission) === Table::LEVEL_ALL) { + return true; + } + + if ($this->getPermissionLevel($user, $permission) === Table::LEVEL_NO) { + if ($target->getId() === $user->getId()) { + return true; + } + + return false; + } + + if ($this->getPermissionLevel($user, $permission) === Table::LEVEL_TEAM) { + if ($target->getId() === $user->getId()) { + return true; + } + + $targetTeamIdList = $target->getTeamIdList(); + + $inTeam = false; + + foreach ($user->getTeamIdList() as $id) { + if (in_array($id, $targetTeamIdList)) { + $inTeam = true; + + break; + } + } + + if ($inTeam) { + return true; + } + + return false; + } + + return false; + } + + protected function initEventHandling(): void + { + $this->eventDispatcher->subscribe(UserRoleUpdate::class, function (UserRoleUpdate $event) { + $userId = $event->userId; + + unset($this->tableHashMap[$userId]); + unset($this->mapHashMap[$userId]); + }); + } + + /** + * Whether the user is admin and has admin capabilities. + * + * @since 10.1.0 + */ + public function checkAdmin(User $user): bool + { + return $user->isEffectiveAdmin() && $this->checkScope($user, Scope::ADMIN); + } +} diff --git a/application/Espo/Core/Action/Action.php b/application/Espo/Core/Action/Action.php new file mode 100644 index 0000000..0367b6c --- /dev/null +++ b/application/Espo/Core/Action/Action.php @@ -0,0 +1,46 @@ +. + * + * 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; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; + +interface Action +{ + /** + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + * @throws Conflict + */ + public function process(Params $params, Data $data): void; +} diff --git a/application/Espo/Core/Action/ActionFactory.php b/application/Espo/Core/Action/ActionFactory.php new file mode 100644 index 0000000..94400eb --- /dev/null +++ b/application/Espo/Core/Action/ActionFactory.php @@ -0,0 +1,149 @@ +. + * + * 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; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +class ActionFactory +{ + public function __construct(private Metadata $metadata, private InjectableFactory $injectableFactory) + {} + + /** + * @throws Forbidden + * @throws NotFound + */ + public function create(string $action, ?string $entityType = null): Action + { + $className = $this->getClassName($action, $entityType); + + if (!$className) { + throw new NotFound("Action '{$action}' not found."); + } + + if ($entityType && $this->isDisabled($action, $entityType)) { + throw new Forbidden("Action '{$action}' is disabled for '{$entityType}'."); + } + + return $this->injectableFactory->create($className); + } + + /** + * @param array $with + * @throws NotFound + * @throws Forbidden + * @todo Remove. + * @deprecated + */ + public function createWith(string $action, ?string $entityType, array $with): Action + { + $className = $this->getClassName($action, $entityType); + + if (!$className) { + throw new NotFound("Action '{$action}' not found."); + } + + if ($entityType && $this->isDisabled($action, $entityType)) { + throw new Forbidden("Action '{$action}' is disabled for '{$entityType}'."); + } + + return $this->injectableFactory->createWith($className, $with); + } + + /** + * @return ?class-string + */ + private function getClassName(string $action, ?string $entityType): ?string + { + if ($entityType) { + $className = $this->getEntityTypeClassName($action, $entityType); + + if ($className) { + return $className; + } + } + + /** @var ?class-string */ + return $this->metadata->get( + ['app', 'actions', $action, 'implementationClassName'] + ); + } + + /** + * @return ?class-string + */ + private function getEntityTypeClassName(string $action, string $entityType): ?string + { + /** @var ?class-string */ + return $this->metadata->get( + ['recordDefs', $entityType, 'actions', $action, 'implementationClassName'] + ); + } + + private function isDisabled(string $action, string $entityType): bool + { + $actionsDisabled = $this->metadata + ->get(['recordDefs', $entityType, 'actionsDisabled']) ?? false; + + if ($actionsDisabled) { + return true; + } + + if ($this->needsToBeAllowed($entityType)) { + if (!$this->isAllowed($action, $entityType)) { + return true; + } + } + + return $this->metadata + ->get(['recordDefs', $entityType, 'actions', $action, 'disabled']) ?? false; + } + + private function needsToBeAllowed(string $entityType): bool + { + $isObject = $this->metadata->get(['scopes', $entityType, 'object']) ?? false; + + if (!$isObject) { + return true; + } + + return $this->metadata + ->get(['recordDefs', $entityType, 'notAllowedActionsDisabled']) ?? false; + } + + private function isAllowed(string $action, string $entityType): bool + { + return $this->metadata + ->get(['recordDefs', $entityType, 'actions', $action, 'allowed']) ?? false; + } +} diff --git a/application/Espo/Core/Action/Actions/ConvertCurrency.php b/application/Espo/Core/Action/Actions/ConvertCurrency.php new file mode 100644 index 0000000..0c23449 --- /dev/null +++ b/application/Espo/Core/Action/Actions/ConvertCurrency.php @@ -0,0 +1,142 @@ +. + * + * 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\Acl; +use Espo\Core\Acl\Table; +use Espo\Core\Action\Action; +use Espo\Core\Action\Data; +use Espo\Core\Action\Params; +use Espo\Core\Currency\ConfigDataProvider as CurrencyConfigDataProvider; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Currency\Rates as CurrencyRates; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\ORM\EntityManager; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\Tools\Currency\Conversion\EntityConverterFactory; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class ConvertCurrency implements Action +{ + public function __construct( + private EntityConverterFactory $converterFactory, + private Acl $acl, + private EntityManager $entityManager, + private Metadata $metadata, + private CurrencyConfigDataProvider $configDataProvider, + private User $user + ) {} + + public function process(Params $params, Data $data): void + { + $entityType = $params->getEntityType(); + $id = $params->getId(); + + if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_EDIT)) { + throw new Forbidden(); + } + + $this->checkFieldAccess($entityType); + + $baseCurrency = $this->configDataProvider->getBaseCurrency(); + $targetCurrency = $data->get('targetCurrency'); + + if (!$targetCurrency) { + throw new BadRequest("No target currency."); + } + + $rates = $this->getRatesFromData($data) ?? + $this->configDataProvider->getCurrencyRates(); + + if ($targetCurrency !== $baseCurrency && !$rates->hasRate($targetCurrency)) { + throw new BadRequest("Target currency rate is not specified."); + } + + $entity = $this->entityManager->getEntityById($entityType, $id); + + if (!$entity) { + throw new NotFound(); + } + + if (!$this->acl->checkEntityEdit($entity)) { + throw new Forbidden("No 'edit' access."); + } + + if (!$entity instanceof CoreEntity) { + throw new RuntimeException("Only Core-Entity allowed."); + } + + $converter = $this->converterFactory->create($entityType); + + $converter->convert($entity, $targetCurrency, $rates); + + $this->entityManager->saveEntity($entity, [SaveOption::MODIFIED_BY_ID => $this->user->getId()]); + } + + private function getRatesFromData(Data $data): ?CurrencyRates + { + if ($data->get('rates') === null) { + return null; + } + + $baseCurrency = $this->configDataProvider->getBaseCurrency(); + + $ratesArray = get_object_vars($data->get('rates')); + $ratesArray[$baseCurrency] = 1.0; + + return CurrencyRates::fromAssoc($ratesArray, $baseCurrency); + } + + /** + * @throws Forbidden + */ + private function checkFieldAccess(string $entityType): void + { + /** @var string[] $requiredFieldList */ + $requiredFieldList = $this->metadata->get(['scopes', $entityType, 'currencyConversionAccessRequiredFieldList']); + + if ($requiredFieldList === null) { + return; + } + + foreach ($requiredFieldList as $field) { + if (!$this->acl->checkField($entityType, $field, Table::ACTION_EDIT)) { + throw new Forbidden("No edit access to field `$field`."); + } + } + } +} diff --git a/application/Espo/Core/Action/Actions/Lock.php b/application/Espo/Core/Action/Actions/Lock.php new file mode 100644 index 0000000..ec7b3a5 --- /dev/null +++ b/application/Espo/Core/Action/Actions/Lock.php @@ -0,0 +1,72 @@ +. + * + * 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); + } + } +} diff --git a/application/Espo/Core/Action/Actions/Merge.php b/application/Espo/Core/Action/Actions/Merge.php new file mode 100644 index 0000000..afb8adb --- /dev/null +++ b/application/Espo/Core/Action/Actions/Merge.php @@ -0,0 +1,69 @@ +. + * + * 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\Acl; +use Espo\Core\Acl\Table; +use Espo\Core\Action\Action; +use Espo\Core\Action\Actions\Merge\Merger; +use Espo\Core\Action\Data; +use Espo\Core\Action\Params; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; + +use stdClass; + +class Merge implements Action +{ + public function __construct(private Acl $acl, private Merger $merger) + {} + + public function process(Params $params, Data $data): void + { + $entityType = $params->getEntityType(); + + if (!$this->acl->checkScope($entityType, Table::ACTION_EDIT)) { + throw new Forbidden(); + } + + $sourceIdList = $data->get('sourceIdList'); + $attributes = $data->get('attributes'); + + if (!is_array($sourceIdList)) { + throw new BadRequest("No 'sourceIdList'."); + } + + if (!$attributes instanceof stdClass) { + throw new BadRequest("No 'attributes'."); + } + + $this->merger->process($params, $sourceIdList, $attributes); + } +} diff --git a/application/Espo/Core/Action/Actions/Merge/Merger.php b/application/Espo/Core/Action/Actions/Merge/Merger.php new file mode 100644 index 0000000..36208fe --- /dev/null +++ b/application/Espo/Core/Action/Actions/Merge/Merger.php @@ -0,0 +1,460 @@ +. + * + * 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\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\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; +use Espo\Entities\Note; +use Espo\ORM\Entity; +use Espo\Entities\EmailAddress; +use Espo\Entities\PhoneNumber; +use Espo\ORM\Type\RelationType; +use stdClass; + +class Merger +{ + public function __construct( + private Acl $acl, + private Metadata $metadata, + private EntityManager $entityManager, + private ServiceContainer $serviceContainer + ) {} + + /** + * @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); + + $entityType = $params->getEntityType(); + $id = $params->getId(); + + $entity = $this->entityManager->getEntityById($entityType, $id); + + if (!$entity) { + throw new NotFound("Record not found."); + } + + if (!$this->acl->check($entity, Table::ACTION_EDIT)) { + throw new Forbidden("No edit access."); + } + + $service = $this->serviceContainer->get($entityType); + + $service->filterUpdateInput($clonedData); + + $entity->setMultiple($clonedData); + + $this->unsetNotActualAttributes($entity); + + if (!$service->checkAssignment($entity)) { + throw new Forbidden("Assignment permission failure."); + } + + $sourceEntityList = $this->fetchSourceEntityList($entityType, $sourceIdList); + + $entityDefs = $this->entityManager->getDefs()->getEntity($entityType); + + $hasPhoneNumber = + $entityDefs->hasField(Field::PHONE_NUMBER) && + $entityDefs->getField(Field::PHONE_NUMBER)->getType() === FieldType::PHONE; + + $hasEmailAddress = + $entityDefs->hasField(Field::EMAIL_ADDRESS) && + $entityDefs->getField(Field::EMAIL_ADDRESS)->getType() === FieldType::EMAIL; + + if ($hasPhoneNumber) { + $phoneNumberToRelateList = $this->fetchEntityPhoneNumberList($entity); + } + + if ($hasEmailAddress) { + $emailAddressToRelateList = $this->fetchEntityEmailAddressList($entity); + } + + foreach ($sourceEntityList as $sourceEntity) { + if ($hasPhoneNumber) { + $phoneNumberToRelateList = array_merge( + $phoneNumberToRelateList, + $this->fetchEntityPhoneNumberList($sourceEntity) + ); + } + + if ($hasEmailAddress) { + $emailAddressToRelateList = array_merge( + $emailAddressToRelateList, + $this->fetchEntityEmailAddressList($sourceEntity) + ); + } + + $this->updateNotes($sourceEntity, $entity); + } + + $this->mergeLinks($entity, $sourceEntityList); + + foreach ($sourceEntityList as $sourceEntity) { + $service->delete($sourceEntity->getId(), DeleteParams::create()); + } + + if ($hasPhoneNumber) { + $this->preparePhoneNumberData($phoneNumberToRelateList, $clonedData); + } + + if ($hasEmailAddress) { + $this->prepareEmailAddressData($emailAddressToRelateList, $clonedData); + } + + $service->update($id, $clonedData, UpdateParams::create()->withSkipDuplicateCheck()); + } + + /** + * @param string[] $sourceIdList + * @return Entity[] + * @throws Forbidden + * @throws NotFound + */ + private function fetchSourceEntityList(string $entityType, array $sourceIdList): array + { + $list = []; + + foreach ($sourceIdList as $sourceId) { + $sourceEntity = $this->entityManager->getEntityById($entityType, $sourceId); + + if (!$sourceEntity) { + throw new NotFound("Source record not found."); + } + + $list[] = $sourceEntity; + + if ( + !$this->acl->check($sourceEntity, Table::ACTION_READ) || + !$this->acl->check($sourceEntity, Table::ACTION_EDIT) || + !$this->acl->check($sourceEntity, Table::ACTION_DELETE) + ) { + throw new Forbidden("No read, edit or delete access for one of source entities."); + } + } + + return $list; + } + + /** + * @return PhoneNumber[] + */ + private function fetchEntityPhoneNumberList(Entity $entity): array + { + $list = []; + + /** @var iterable $collection */ + $collection = $this->entityManager + ->getRelation($entity, 'phoneNumbers') + ->find(); + + foreach ($collection as $entity) { + $list[] = $entity; + } + + return $list; + } + + /** + * @return EmailAddress[] + */ + private function fetchEntityEmailAddressList(Entity $entity): array + { + $list = []; + + /** @var iterable $collection */ + $collection = $this->entityManager + ->getRelation($entity, Link::EMAIL_ADDRESSES) + ->find(); + + foreach ($collection as $entity) { + $list[] = $entity; + } + + return $list; + } + + private function updateNotes(Entity $sourceEntity, Entity $targetEntity): void + { + $updateQuery = $this->entityManager + ->getQueryBuilder() + ->update() + ->in(Note::ENTITY_TYPE) + ->set([ + 'parentId' => $targetEntity->getId(), + 'parentType' => $targetEntity->getEntityType(), + ]) + ->where([ + 'type' => [ + Note::TYPE_POST, + Note::TYPE_EMAIL_SENT, + Note::TYPE_EMAIL_RECEIVED, + ], + 'parentId' => $sourceEntity->getId(), + 'parentType' => $sourceEntity->getEntityType(), + ]) + ->build(); + + $this->entityManager + ->getQueryExecutor() + ->execute($updateQuery); + } + + private function updateRelations(Entity $sourceEntity, Entity $targetEntity, string $link): void + { + $entityType = $sourceEntity->getEntityType(); + + $columnAttributeMap = $this->getLinkColumnAttributeMap($entityType, $link); + + $collection = $this->entityManager + ->getRelation($sourceEntity, $link) + ->find(); + + foreach ($collection as $relatedEntity) { + $map = null; + + if ($columnAttributeMap) { + $map = array_map(fn ($attribute) => $relatedEntity->get($attribute), $columnAttributeMap); + } + + $this->entityManager + ->getRelation($targetEntity, $link) + ->relate($relatedEntity, $map); + } + } + + /** + * @return string[] + */ + private function getMergeLinkList(string $entityType): array + { + $list = []; + + $entityDefs = $this->entityManager->getDefs()->getEntity($entityType); + + $ignoreList = [ + Link::EMAIL_ADDRESSES, + 'phoneNumbers', + ]; + + foreach ($entityDefs->getRelationList() as $relationDefs) { + $name = $relationDefs->getName(); + $type = $relationDefs->getType(); + + if (in_array($name, $ignoreList)) { + continue; + } + + $notMergeable = $this->metadata + ->get(['entityDefs', $entityType, 'links', $name, 'notMergeable']); + + if ($notMergeable) { + continue; + } + + if ( + $relationDefs->tryGetForeignEntityType() === Attachment::ENTITY_TYPE && + $type === RelationType::HAS_CHILDREN + ) { + continue; + } + + if ( + $type !== Entity::HAS_MANY && + $type !== Entity::HAS_CHILDREN && + $type !== Entity::MANY_MANY + ) { + continue; + } + + $list[] = $name; + } + + return $list; + } + + /** + * @param PhoneNumber[] $phoneNumberList + */ + private function preparePhoneNumberData(array $phoneNumberList, stdClass $data): void + { + $phoneNumberData = []; + + foreach ($phoneNumberList as $i => $phoneNumber) { + $o = (object) []; + + $o->phoneNumber = $phoneNumber->getNumber(); + $o->primary = false; + + if (empty($data->phoneNumber) && $i === 0) { + $o->primary = true; + } + + if (!empty($data->phoneNumber)) { + $o->primary = $o->phoneNumber === $data->phoneNumber; + } + + $o->optOut = $phoneNumber->isOptedOut(); + $o->invalid = $phoneNumber->isInvalid(); + $o->type = $phoneNumber->getType(); + + $phoneNumberData[] = $o; + } + + $data->phoneNumberData = $phoneNumberData; + } + + /** + * @param EmailAddress[] $emailAddressList + */ + private function prepareEmailAddressData(array $emailAddressList, stdClass $data): void + { + $emailAddressData = []; + + foreach ($emailAddressList as $i => $emailAddress) { + $o = (object) []; + + $o->emailAddress = $emailAddress->getAddress(); + $o->primary = false; + + if (empty($data->emailAddress) && $i === 0) { + $o->primary = true; + } + + if (!empty($data->emailAddress)) { + $o->primary = $o->emailAddress === $data->emailAddress; + } + + $o->optOut = $emailAddress->isOptedOut(); + $o->invalid = $emailAddress->isInvalid(); + + $emailAddressData[] = $o; + } + + $data->emailAddressData = $emailAddressData; + } + + private function unsetNotActualAttributes(Entity $entity): void + { + $fieldDefsList = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()) + ->getFieldList(); + + foreach ($fieldDefsList as $fieldDefs) { + $field = $fieldDefs->getName(); + + if ($fieldDefs->getType() === FieldType::LINK && $entity->isAttributeChanged($field . 'Id')) { + $entity->clear($field . 'Name'); + } + } + } + + /** + * @return ?array + */ + private function getLinkColumnAttributeMap(string $entityType, string $link): ?array + { + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType); + + $columnAttributeMap = null; + + $relationDefs = $entityDefs->tryGetRelation($link); + + if ( + $relationDefs && + $relationDefs->getType() === RelationType::MANY_MANY && + $relationDefs->hasForeignEntityType() && + $relationDefs->hasForeignRelationName() + ) { + $foreignRelationDefs = $this->entityManager + ->getDefs() + ->getEntity($relationDefs->getForeignEntityType()) + ->getRelation($relationDefs->getForeignRelationName()); + + /** ?@var array $columnAttributeMap */ + $columnAttributeMap = $foreignRelationDefs->getParam('columnAttributeMap'); + } + + 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); + } + } + } +} diff --git a/application/Espo/Core/Action/Actions/Unlock.php b/application/Espo/Core/Action/Actions/Unlock.php new file mode 100644 index 0000000..f84a886 --- /dev/null +++ b/application/Espo/Core/Action/Actions/Unlock.php @@ -0,0 +1,72 @@ +. + * + * 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); + } + } +} diff --git a/application/Espo/Core/Action/Api/PostProcess.php b/application/Espo/Core/Action/Api/PostProcess.php new file mode 100644 index 0000000..f64a283 --- /dev/null +++ b/application/Espo/Core/Action/Api/PostProcess.php @@ -0,0 +1,64 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Action; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Action\Service; +use Espo\Core\Api\ResponseComposer; +use Espo\Core\Exceptions\BadRequest; + +/** + * Processes actions. + */ +class PostProcess implements Action +{ + public function __construct(private Service $service) + {} + + public function process(Request $request): Response + { + $body = $request->getParsedBody(); + + $entityType = $body->entityType ?? null; + $id = $body->id ?? null; + $action = $body->action ?? null; + $data = $body->data ?? (object) []; + + if (!$entityType || !$action || !$id) { + throw new BadRequest(); + } + + $entity = $this->service->process($entityType, $action, $id, $data); + + return ResponseComposer::json($entity->getValueMap()); + } +} diff --git a/application/Espo/Core/Action/Data.php b/application/Espo/Core/Action/Data.php new file mode 100644 index 0000000..e4793fd --- /dev/null +++ b/application/Espo/Core/Action/Data.php @@ -0,0 +1,95 @@ +. + * + * 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; + +use Espo\Core\Utils\ObjectUtil; + +use stdClass; + +class Data +{ + private stdClass $data; + + private function __construct() + { + $this->data = (object) []; + } + + public function getRaw(): stdClass + { + return ObjectUtil::clone($this->data); + } + + /** + * Get an item value. + * + * @return mixed + */ + public function get(string $name) + { + return $this->getRaw()->$name ?? null; + } + + /** + * Has an item. + */ + public function has(string $name): bool + { + return property_exists($this->data, $name); + } + + public static function fromRaw(stdClass $data): self + { + $obj = new self(); + + $obj->data = $data; + + return $obj; + } + + /** + * Clone with an item value. + * + * @param mixed $value + */ + public function with(string $name, $value): self + { + $obj = clone $this; + + $obj->data->$name = $value; + + return $obj; + } + + public function __clone() + { + $this->data = ObjectUtil::clone($this->data); + } +} diff --git a/application/Espo/Core/Action/Params.php b/application/Espo/Core/Action/Params.php new file mode 100644 index 0000000..5940232 --- /dev/null +++ b/application/Espo/Core/Action/Params.php @@ -0,0 +1,56 @@ +. + * + * 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; + +use RuntimeException; + +/** + * Immutable. + */ +class Params +{ + public function __construct(private string $entityType, private string $id) + { + + if (!$entityType || !$id) { + throw new RuntimeException(); + } + } + + public function getEntityType(): string + { + return $this->entityType; + } + + public function getId(): string + { + return $this->id; + } +} diff --git a/application/Espo/Core/Action/Service.php b/application/Espo/Core/Action/Service.php new file mode 100644 index 0000000..30421f8 --- /dev/null +++ b/application/Espo/Core/Action/Service.php @@ -0,0 +1,85 @@ +. + * + * 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; + +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; +use Espo\Core\Record\ReadParams; +use Espo\Core\Record\ReadResult; +use Espo\Core\Record\ServiceContainer as RecordServiceContainer; + +use Espo\ORM\Entity; + +use stdClass; + +class Service +{ + public function __construct( + private ActionFactory $factory, + private Acl $acl, + private RecordServiceContainer $recordServiceContainer + ) {} + + /** + * Perform an action. + * + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + * @throws Conflict + */ + public function process(string $entityType, string $action, string $id, stdClass $data): ReadResult + { + if (!$this->acl->checkScope($entityType)) { + throw new ForbiddenSilent(); + } + + if (!$action || !$id) { + throw new BadRequest(); + } + + $actionParams = new Params($entityType, $id); + + $actionProcessor = $this->factory->create($action, $entityType); + + $actionProcessor->process( + $actionParams, + Data::fromRaw($data) + ); + + $service = $this->recordServiceContainer->get($entityType); + + return $service->read($id, ReadParams::create()); + } +} diff --git a/application/Espo/Core/Api/Action.php b/application/Espo/Core/Api/Action.php new file mode 100644 index 0000000..3078377 --- /dev/null +++ b/application/Espo/Core/Api/Action.php @@ -0,0 +1,55 @@ +. + * + * 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\Api; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; + +/** + * A route action. + */ +interface Action +{ + /** + * Process. + * + * @param Request $request A request. + * @return Response A response. Use ResponseComposer for building. + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + * @throws Conflict + * @throws Error + */ + public function process(Request $request): Response; +} diff --git a/application/Espo/Core/Api/ActionHandler.php b/application/Espo/Core/Api/ActionHandler.php new file mode 100644 index 0000000..42a1d05 --- /dev/null +++ b/application/Espo/Core/Api/ActionHandler.php @@ -0,0 +1,123 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Route\ContentType; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; + +use Espo\Core\Utils\Config; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ResponseInterface as Psr7Response; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Slim\Psr7\Factory\ResponseFactory; + +/** + * @internal + */ +class ActionHandler implements RequestHandlerInterface +{ + private const string DEFAULT_CONTENT_TYPE = ContentType::APPLICATION_JSON; + + public function __construct( + private Action $action, + private ProcessData $processData, + private Config $config + ) {} + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Error + * @throws NotFound + * @throws Conflict + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $requestWrapped = new RequestWrapper( + $request, + $this->processData->getBasePath(), + $this->processData->getRouteParams() + ); + + $response = $this->action->process($requestWrapped); + + return $this->prepareResponse($response); + } + + private function prepareResponse(Response $response): Psr7Response + { + if (!$response->hasHeader('Content-Type')) { + $response->setHeader('Content-Type', self::DEFAULT_CONTENT_TYPE); + } + + if (!$response->hasHeader('Cache-Control')) { + $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0'); + } + + if (!$response->hasHeader('Expires')) { + $response->setHeader('Expires', '0'); + } + + if (!$response->hasHeader('Last-Modified')) { + $response->setHeader('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT'); + } + + $response->setHeader('X-App-Timestamp', (string) ($this->config->get('appTimestamp') ?? '0')); + + /** @noinspection PhpConditionAlreadyCheckedInspection */ + return $response instanceof ResponseWrapper ? + $response->toPsr7() : + self::responseToPsr7($response); + } + + private static function responseToPsr7(Response $response): Psr7Response + { + $psr7Response = (new ResponseFactory())->createResponse(); + + $statusCode = $response->getStatusCode(); + $reason = $response->getReasonPhrase(); + $body = $response->getBody(); + + $psr7Response = $psr7Response + ->withStatus($statusCode, $reason) + ->withBody($body); + + foreach ($response->getHeaderNames() as $name) { + $psr7Response = $psr7Response->withHeader($name, $response->getHeaderAsArray($name)); + } + + return $psr7Response; + } +} diff --git a/application/Espo/Core/Api/Auth.php b/application/Espo/Core/Api/Auth.php new file mode 100644 index 0000000..642ea11 --- /dev/null +++ b/application/Espo/Core/Api/Auth.php @@ -0,0 +1,371 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Route\ContentType; +use Espo\Core\Authentication\HeaderKey; +use Espo\Core\Authentication\Login\MethodResolver; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\ServiceUnavailable; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Authentication\Authentication; +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Authentication\Result; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\Json; + +use Exception; + +/** + * Determines which auth method to use. Fetches a username and password from headers and server parameters. + * Then tries to log in. + */ +class Auth +{ + private const string SCHEME_BASIC = 'Basic'; + + public function __construct( + private Log $log, + private Authentication $authentication, + private MethodResolver $methodResolver, + private bool $authRequired = true, + private bool $isEntryPoint = false, + ) {} + + /** + * @throws BadRequest + * @throws Exception + */ + public function process(Request $request, Response $response): AuthResult + { + $username = null; + $password = null; + + $authenticationMethod = $this->methodResolver->resolve($request); + + if (!$authenticationMethod) { + [$username, $password] = $this->obtainUsernamePasswordFromRequest($request); + } + + $authenticationData = AuthenticationData::create() + ->withUsername($username) + ->withPassword($password) + ->withMethod($authenticationMethod); + + $hasAuthData = $username || $authenticationMethod; + + if (!$hasAuthData && !$this->toIgnoreNonHeaderAuthorizationData($request)) { + $password = self::obtainTokenFromCookies($request); + + if ($password) { + $authenticationData = AuthenticationData::create() + ->withPassword($password) + ->withByTokenOnly(true); + + $hasAuthData = true; + } + } + + if (!$this->authRequired && !$this->isEntryPoint && $hasAuthData) { + $authResult = $this->processAuthNotRequired( + data: $authenticationData, + request: $request, + response: $response, + ); + + if ($authResult) { + return $authResult; + } + } + + if (!$this->authRequired) { + return AuthResult::createResolvedUseNoAuth(); + } + + if ($hasAuthData) { + return $this->processWithAuthData($authenticationData, $request, $response); + } + + $this->handleUnauthorized( + response: $response, + showDialog: self::toShowDialog($request), + ); + + return AuthResult::createNotResolved(); + } + + /** + * @throws Exception + */ + private function processAuthNotRequired( + AuthenticationData $data, + Request $request, + Response $response, + ): ?AuthResult { + + try { + $result = $this->authentication->login($data, $request, $response); + } catch (Exception $e) { + $this->handleException($response, $e); + + return AuthResult::createNotResolved(); + } + + if ($result->isSuccess()) { + return AuthResult::createResolved(); + } + + return null; + } + + /** + * @throws Exception + */ + private function processWithAuthData( + AuthenticationData $data, + Request $request, + Response $response, + ): AuthResult { + + try { + $result = $this->authentication->login($data, $request, $response); + } catch (Exception $e) { + $this->handleException($response, $e); + + return AuthResult::createNotResolved(); + } + + if ($result->isSuccess()) { + return AuthResult::createResolved(); + } + + if ($result->isFail()) { + $this->handleUnauthorized( + response: $response, + result: $result, + showDialog: $this->toShowDialogOnFail($request), + ); + } + + if ($result->isSecondStepRequired()) { + self::handleSecondStepRequired($response, $result); + } + + return AuthResult::createNotResolved(); + } + + /** + * @return array{string, string} + * @throws BadRequest + */ + private static function decodeAuthorizationString(string $string): array + { + /** @var string $stringDecoded */ + $stringDecoded = base64_decode($string); + + if (!str_contains($stringDecoded, ':')) { + throw new BadRequest("Auth: Bad authorization string provided."); + } + + [$username, $password] = explode(':', $stringDecoded, 2); + + $username = trim($username); + $password = trim($password); + + return [$username, $password]; + } + + private static function handleSecondStepRequired(Response $response, Result $result): void + { + $response->setStatus(401); + $response->setHeader('X-Status-Reason', 'second-step-required'); + + $bodyData = [ + 'status' => $result->getStatus(), + 'message' => $result->getMessage(), + 'view' => $result->getView(), + 'data' => $result->getData(), + ]; + + $response->writeBody(Json::encode($bodyData)); + } + + /** + * @throws Exception + */ + private function handleException(Response $response, Exception $e): void + { + if ( + $e instanceof BadRequest || + $e instanceof ServiceUnavailable || + $e instanceof Forbidden + ) { + $reason = $e->getMessage(); + + if ($reason) { + $response->setHeader('X-Status-Reason', $e->getMessage()); + } + + $response->setStatus($e->getCode()); + + if ($e->getBody()) { + $response->writeBody($e->getBody()); + } + + if ($e->getMessage()) { + $this->log->notice("Auth exception.", ['exception' => $e]); + } + + return; + } + + throw $e; + } + + private function handleUnauthorized(Response $response, ?Result $result = null, bool $showDialog = false): void + { + if ($showDialog) { + $response->setHeader('WWW-Authenticate', 'Basic realm="api"'); + } + + if ($result && $result->getFailReason() === Result\FailReason::ERROR) { + $response = $response->setHeader(ErrorOutput::HEADER_STATUS_REASON, 'error'); + } + + $response->setStatus(401); + } + + /** + * @return array{?string, ?string} + * @throws BadRequest + */ + private function obtainUsernamePasswordFromRequest(Request $request): array + { + if ($request->hasHeader(HeaderKey::AUTHORIZATION)) { + $headerValue = $request->getHeader(HeaderKey::AUTHORIZATION) ?? ''; + + return self::decodeAuthorizationString($headerValue); + } + + // Bypass as the Authorization header may be used for OAuth. + if (!$this->authRequired) { + return [null, null]; + } + + if ($this->toIgnoreNonHeaderAuthorizationData($request)) { + return [null, null]; + } + + if ( + $request->getServerParam('PHP_AUTH_USER') && + $request->getServerParam('PHP_AUTH_PW') + ) { + $username = $request->getServerParam('PHP_AUTH_USER'); + $password = $request->getServerParam('PHP_AUTH_PW'); + + if (!is_string($username) || !is_string($password)) { + return [null, null]; + } + + $username = trim($username); + $password = trim($password); + + return [$username, $password]; + } + + $cgiAuthString = self::getCgiAuthString($request); + + if ($cgiAuthString) { + [$username, $password] = self::decodeAuthorizationString($cgiAuthString); + + return [$username, $password]; + } + + return [null, null]; + } + + private static function obtainTokenFromCookies(Request $request): ?string + { + return $request->getCookieParam('auth-token'); + } + + /** + * Header might be written by a web server's rewrite rule. + */ + private static function getCgiAuthString(Request $request): ?string + { + $value = $request->getHeader('Http-Espo-Cgi-Auth') ?? + $request->getHeader('Redirect-Http-Espo-Cgi-Auth'); + + if (!$value) { + return null; + } + + $basicPrefix = self::SCHEME_BASIC . ' '; + + if (!str_starts_with($value, $basicPrefix)) { + return null; + } + + return substr($value, strlen($basicPrefix)); + } + + private static function toShowDialog(Request $request): bool + { + return + $request->getMethod() === Method::GET && + !$request->getHeader('Referer') && + self::isDocumentNavigate($request); + } + + private function toShowDialogOnFail(Request $request): bool + { + return $this->isEntryPoint && $this->toShowDialog($request); + } + + private static function isDocumentNavigate(Request $request): bool + { + return + $request->getHeader('Sec-Fetch-Mode') === 'navigate' && + $request->getHeader('Sec-Fetch-Dest') === 'document'; + } + + private function toIgnoreNonHeaderAuthorizationData(Request $request): bool + { + return + !$this->isEntryPoint && + $request->getHeader('Sec-Fetch-Mode') === 'navigate' && + in_array($request->getContentType(), [ + ContentType::MULTIPART_FORM_DATA, + ContentType::APPLICATION_X_WWW_FORM_URLENCODED, + ContentType::TEXT_PLAIN, + ]); + } +} diff --git a/application/Espo/Core/Api/AuthBuilder.php b/application/Espo/Core/Api/AuthBuilder.php new file mode 100644 index 0000000..8c04ad0 --- /dev/null +++ b/application/Espo/Core/Api/AuthBuilder.php @@ -0,0 +1,91 @@ +. + * + * 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\Api; + +use Espo\Core\Authentication\Authentication; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Binding\ContextualBinder; +use Espo\Core\InjectableFactory; + +use RuntimeException; + +/** + * Builds Auth instance. + */ +class AuthBuilder +{ + private bool $authRequired = false; + private bool $isEntryPoint = false; + private ?Authentication $authentication = null; + + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function setAuthentication(Authentication $authentication): self + { + $this->authentication = $authentication; + + return $this; + } + + public function setAuthRequired(bool $authRequired): self + { + $this->authRequired = $authRequired; + + return $this; + } + + public function forEntryPoint(): self + { + $this->isEntryPoint = true; + + return $this; + } + + public function build(): Auth + { + if (!$this->authentication) { + throw new RuntimeException("Authentication is not set."); + } + + return $this->injectableFactory->createWithBinding( + Auth::class, + BindingContainerBuilder + ::create() + ->bindInstance(Authentication::class, $this->authentication) + ->inContext(Auth::class, function (ContextualBinder $binder) { + $binder + ->bindValue('$authRequired', $this->authRequired) + ->bindValue('$isEntryPoint', $this->isEntryPoint); + }) + ->build() + ); + } +} diff --git a/application/Espo/Core/Api/AuthBuilderFactory.php b/application/Espo/Core/Api/AuthBuilderFactory.php new file mode 100644 index 0000000..7cf3fae --- /dev/null +++ b/application/Espo/Core/Api/AuthBuilderFactory.php @@ -0,0 +1,43 @@ +. + * + * 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\Api; + +use Espo\Core\InjectableFactory; + +class AuthBuilderFactory +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function create(): AuthBuilder + { + return $this->injectableFactory->create(AuthBuilder::class); + } +} diff --git a/application/Espo/Core/Api/AuthResult.php b/application/Espo/Core/Api/AuthResult.php new file mode 100644 index 0000000..0d05e2e --- /dev/null +++ b/application/Espo/Core/Api/AuthResult.php @@ -0,0 +1,79 @@ +. + * + * 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\Api; + +/** + * An authentication result. + */ +class AuthResult +{ + private bool $isResolved = false; + private bool $isResolvedUseNoAuth = false; + + public static function createResolved(): self + { + $obj = new self(); + + $obj->isResolved = true; + + return $obj; + } + + public static function createResolvedUseNoAuth(): self + { + $obj = new self(); + + $obj->isResolved = true; + $obj->isResolvedUseNoAuth = true; + + return $obj; + } + + public static function createNotResolved(): self + { + return new self(); + } + + /** + * Logged in successfully. + */ + public function isResolved(): bool + { + return $this->isResolved; + } + + /** + * No need to log in. + */ + public function isResolvedUseNoAuth(): bool + { + return $this->isResolvedUseNoAuth; + } +} diff --git a/application/Espo/Core/Api/ConfigDataProvider.php b/application/Espo/Core/Api/ConfigDataProvider.php new file mode 100644 index 0000000..490297c --- /dev/null +++ b/application/Espo/Core/Api/ConfigDataProvider.php @@ -0,0 +1,50 @@ +. + * + * 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\Api; + +use Espo\Core\Utils\Config; + +/** + * @since 10.1.0 + */ +class ConfigDataProvider +{ + public function __construct( + private Config $config, + ) {} + + /** + * Important. Should never be enabled on production. + */ + public function exposeExceptions(): bool + { + return (bool) $this->config->get('apiExposeExceptions'); + } +} diff --git a/application/Espo/Core/Api/ControllerActionHandler.php b/application/Espo/Core/Api/ControllerActionHandler.php new file mode 100644 index 0000000..3f1e8c5 --- /dev/null +++ b/application/Espo/Core/Api/ControllerActionHandler.php @@ -0,0 +1,91 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Route\ContentType; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Utils\Config; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +/** + * @internal + */ +class ControllerActionHandler implements RequestHandlerInterface +{ + public function __construct( + private string $controllerName, + private string $actionName, + private ProcessData $processData, + private ResponseWrapper $responseWrapped, + private ControllerActionProcessor $controllerActionProcessor, + private Config $config + ) {} + + /** + * @throws NotFound + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $requestWrapped = new RequestWrapper( + $request, + $this->processData->getBasePath(), + $this->processData->getRouteParams() + ); + + $this->beforeProceed(); + + $responseWrapped = $this->controllerActionProcessor->process( + $this->controllerName, + $this->actionName, + $requestWrapped, + $this->responseWrapped + ); + + $this->afterProceed($responseWrapped); + + return $responseWrapped->toPsr7(); + } + + private function beforeProceed(): void + { + $this->responseWrapped->setHeader('Content-Type', ContentType::APPLICATION_JSON); + } + + private function afterProceed(Response $responseWrapped): void + { + $responseWrapped + ->setHeader('X-App-Timestamp', (string) ($this->config->get('appTimestamp') ?? '0')) + ->setHeader('Expires', '0') + ->setHeader('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT') + ->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0'); + } +} diff --git a/application/Espo/Core/Api/ControllerActionProcessor.php b/application/Espo/Core/Api/ControllerActionProcessor.php new file mode 100644 index 0000000..00ff1f7 --- /dev/null +++ b/application/Espo/Core/Api/ControllerActionProcessor.php @@ -0,0 +1,218 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Route\ContentType; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Exceptions\NotFoundSilent; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\ClassFinder; +use Espo\Core\Utils\Json; + +use ReflectionClass; +use ReflectionNamedType; +use stdClass; + +/** + * Creates controller instances and processes actions. + */ +class ControllerActionProcessor +{ + public function __construct( + private InjectableFactory $injectableFactory, + private ClassFinder $classFinder + ) {} + + /** + * @throws NotFound + */ + public function process( + string $controllerName, + string $actionName, + Request $request, + ResponseWrapper $response + ): ResponseWrapper { + + $controller = $this->createController($controllerName); + + $requestMethod = $request->getMethod(); + + if ( + $actionName == 'index' && + property_exists($controller, 'defaultAction') + ) { + $actionName = $controller::$defaultAction ?? 'index'; + } + + $actionMethodName = 'action' . ucfirst($actionName); + + $fullActionMethodName = strtolower($requestMethod) . ucfirst($actionMethodName); + + $primaryActionMethodName = method_exists($controller, $fullActionMethodName) ? + $fullActionMethodName : + $actionMethodName; + + if (!method_exists($controller, $primaryActionMethodName)) { + throw new NotFoundSilent( + "Action $requestMethod '$actionName' does not exist in controller '$controllerName'."); + } + + if ($this->useShortParamList($controller, $primaryActionMethodName)) { + $result = $controller->$primaryActionMethodName($request, $response) ?? null; + + $this->handleResult($response, $result); + + return $response; + } + + // Below is a legacy way. + + $data = $request->getBodyContents(); + + if ($data && $this->getRequestContentType($request) === ContentType::APPLICATION_JSON) { + $data = json_decode($data); + } + + $params = $request->getRouteParams(); + + $beforeMethodName = 'before' . ucfirst($actionName); + + if (method_exists($controller, $beforeMethodName)) { + $controller->$beforeMethodName($params, $data, $request, $response); + } + + $result = $controller->$primaryActionMethodName($params, $data, $request, $response) ?? null; + + $afterMethodName = 'after' . ucfirst($actionName); + + if (method_exists($controller, $afterMethodName)) { + $controller->$afterMethodName($params, $data, $request, $response); + } + + $this->handleResult($response, $result); + + return $response; + } + + /** + * @param mixed $result + */ + private function handleResult(Response $response, $result): void + { + $responseContents = $result; + + if ( + is_int($result) || + is_float($result) || + is_array($result) || + is_bool($result) || + $result instanceof stdClass + ) { + $responseContents = Json::encode($result); + } + + if (is_string($responseContents)) { + $response->writeBody($responseContents); + } + } + + private function useShortParamList(object $controller, string $methodName): bool + { + $class = new ReflectionClass($controller); + + $method = $class->getMethod($methodName); + $params = $method->getParameters(); + + if (count($params) === 0) { + return false; + } + + $type = $params[0]->getType(); + + if ( + !$type instanceof ReflectionNamedType || + $type->isBuiltin() + ) { + return false; + } + + /** @var class-string $className */ + $className = $type->getName(); + + $firstParamClass = new ReflectionClass($className); + + if ( + $firstParamClass->getName() === Request::class || + $firstParamClass->isSubclassOf(Request::class) + ) { + return true; + } + + return false; + } + + /** + * @return class-string + * @throws NotFound + */ + private function getControllerClassName(string $name): string + { + $className = $this->classFinder->find('Controllers', $name); + + if (!$className) { + throw new NotFound("Controller '$name' does not exist."); + } + + if (!class_exists($className)) { + throw new NotFound("Class not found for controller '$name'."); + } + + return $className; + } + + /** + * @throws NotFound + */ + private function createController(string $name): object + { + return $this->injectableFactory->createWith($this->getControllerClassName($name), [ + 'name' => $name, + ]); + } + + private function getRequestContentType(Request $request): ?string + { + if ($request instanceof RequestWrapper) { + return $request->getContentType(); + } + + return null; + } +} diff --git a/application/Espo/Core/Api/ErrorOutput.php b/application/Espo/Core/Api/ErrorOutput.php new file mode 100644 index 0000000..156d690 --- /dev/null +++ b/application/Espo/Core/Api/ErrorOutput.php @@ -0,0 +1,328 @@ +. + * + * 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\Api; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\HasBody; +use Espo\Core\Exceptions\HasLogLevel; +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; +use Throwable; + +/** + * Processes an error output. If an exception occurred, it will be passed to here. + */ +class ErrorOutput +{ + /** @var array */ + private $errorDescriptions = [ + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Page Not Found', + 409 => 'Conflict', + 500 => 'Internal Server Error', + 503 => 'Service Unavailable', + ]; + + /** @var int[] */ + private $allowedStatusCodeList = [ + 200, + 201, + 400, + 401, + 403, + 404, + 409, + 500, + 503, + ]; + + /** @var class-string[] */ + private array $printStatusReasonExceptionClassNameList = [ + Error::class, + Forbidden::class, + Conflict::class, + BadRequest::class, + NotFound::class, + ]; + + /** @var array, int> */ + private array $statusCodeMap = [ + ValidationException::class => 409, + ]; + + public const string HEADER_STATUS_REASON = 'X-Status-Reason'; + + public function __construct( + private Log $log, + private ConfigDataProvider $config, + ) {} + + public function process( + Request $request, + Response $response, + Throwable $exception, + ?string $route = null + ): void { + + $this->processInternal($request, $response, $exception, $route); + } + + public function processWithBodyPrinting( + Request $request, + Response $response, + Throwable $exception, + ?string $route = null + ): void { + + $this->processInternal($request, $response, $exception, $route, true); + } + + private function processInternal( + Request $request, + Response $response, + Throwable $exception, + ?string $route = null, + bool $toPrintBody = false + ): void { + + [$message, $logMessage] = $this->prepareMessages($exception); + + if ($route) { + $this->processRoute($route, $request, $exception); + } + + $level = $this->getLevel($exception); + + $this->log->log($level, $logMessage, [ + 'exception' => $exception, + 'request' => $request, + ]); + + $statusCode = $this->getStatusCode($exception); + + $response->setStatus($statusCode); + + $statusReason = null; + + if ($this->toPrintExceptionStatusReason($exception)) { + $statusReason = $this->stripInvalidCharactersFromHeaderValue($message); + } else if ($this->config->exposeExceptions()) { + $strippedMessage = $this->stripInvalidCharactersFromHeaderValue($message); + + if ($strippedMessage) { + $statusReason = 'Exposed: ' . $strippedMessage; + } + } + + if ($statusReason !== null) { + $response->setHeader(self::HEADER_STATUS_REASON, $statusReason); + } + + $this->printBody( + exception: $exception, + response: $response, + toPrintBody: $toPrintBody, + statusCode: $statusCode, + message: $message, + ); + } + + private function exceptionHasBody(Throwable $exception): bool + { + if (!$exception instanceof HasBody) { + return false; + } + + $exceptionBody = $exception->getBody(); + + return $exceptionBody !== null; + } + + private function getCodeDescription(int $statusCode): ?string + { + if (isset($this->errorDescriptions[$statusCode])) { + return $this->errorDescriptions[$statusCode]; + } + + return null; + } + + private static function generateErrorBody(string $header, string $text): string + { + $body = "

" . $header . "

"; + $body .= $text; + + return $body; + } + + private function stripInvalidCharactersFromHeaderValue(string $value): string + { + $pattern = "/[^ \t\x21-\x7E\x80-\xFF]/"; + + /** @var string */ + return preg_replace($pattern, ' ', $value); + } + + private function processRoute(string $route, Request $request, Throwable $exception): void + { + $message = $exception->getMessage(); + + if ($exception->getPrevious() && $exception->getPrevious()->getMessage()) { + $message .= " " . $exception->getPrevious()->getMessage(); + } + + $statusCode = $exception->getCode(); + + $routeParams = $request->getRouteParams(); + + $logMessage = "API ($statusCode) "; + + $logMessageItemList = []; + + if ($message) { + $logMessageItemList[] = $message; + } + + $logMessageItemList[] = $request->getMethod() . ' ' . $request->getResourcePath(); + + $logMessageItemList[] = "Route pattern: " . $route; + + if (!empty($routeParams)) { + $logMessageItemList[] = "Route params: " . print_r($routeParams, true); + } + + $logMessage .= implode("; ", $logMessageItemList); + + $this->log->debug($logMessage); + } + + private function toPrintExceptionStatusReason(Throwable $exception): bool + { + foreach ($this->printStatusReasonExceptionClassNameList as $clasName) { + if ($exception instanceof ($clasName)) { + return true; + } + } + + return false; + } + + private function getLevel(Throwable $exception): string + { + if ($exception instanceof HasLogLevel) { + return $exception->getLogLevel(); + } + + if ($exception instanceof LogicException) { + return LogLevel::ALERT; + } + + if ($exception instanceof RuntimeException) { + return LogLevel::CRITICAL; + } + + return LogLevel::ERROR; + } + + private function printBody( + Throwable $exception, + Response $response, + bool $toPrintBody, + int $statusCode, + string $message, + ): void { + + if ($exception instanceof HasBody && $this->exceptionHasBody($exception)) { + $response->writeBody($exception->getBody() ?? ''); + + $toPrintBody = false; + } + + if (!$toPrintBody) { + return; + } + + $codeDescription = $this->getCodeDescription($statusCode); + + $statusText = isset($codeDescription) ? + $statusCode . ' ' . $codeDescription : + 'HTTP ' . $statusCode; + + if ($message) { + $message = htmlspecialchars($message); + } + + $response->writeBody(self::generateErrorBody($statusText, $message)); + } + + /** + * @return array{0: string, 1: string} + */ + private function prepareMessages(Throwable $exception): array + { + $message = $exception->getMessage(); + $logMessage = $message; + + if ($exception->getPrevious() && $exception->getPrevious()->getMessage()) { + $logMessage .= " " . $exception->getPrevious()->getMessage(); + } + + if ($exception instanceof HasLogMessage) { + $message = $exception->getLogMessage(); + $logMessage = $message; + } + + return [$message, $logMessage]; + } + + /** + * @return int + */ + private function getStatusCode(Throwable $exception): int + { + $statusCode = $this->statusCodeMap[$exception::class] ?? $exception->getCode(); + + if (!in_array($statusCode, $this->allowedStatusCodeList)) { + $statusCode = 500; + } + + return $statusCode; + } +} diff --git a/application/Espo/Core/Api/Method.php b/application/Espo/Core/Api/Method.php new file mode 100644 index 0000000..e2bd6f3 --- /dev/null +++ b/application/Espo/Core/Api/Method.php @@ -0,0 +1,47 @@ +. + * + * 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\Api; + +/** + * @since 9.2.0 + */ +class Method +{ + public const HEAD = 'HEAD'; + public const GET = 'GET'; + public const POST = 'POST'; + public const PUT = 'PUT'; + public const PATCH = 'PATCH'; + public const DELETE = 'DELETE'; + public const PURGE = 'PURGE'; + public const OPTIONS = 'OPTIONS'; + public const TRACE = 'TRACE'; + public const CONNECT = 'CONNECT'; +} diff --git a/application/Espo/Core/Api/MiddlewareProvider.php b/application/Espo/Core/Api/MiddlewareProvider.php new file mode 100644 index 0000000..c2a1b6b --- /dev/null +++ b/application/Espo/Core/Api/MiddlewareProvider.php @@ -0,0 +1,128 @@ +. + * + * 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\Api; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Psr\Http\Server\MiddlewareInterface; + +/** + * @internal + */ +class MiddlewareProvider +{ + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + /** + * @return MiddlewareInterface[] + */ + public function getGlobalMiddlewareList(): array + { + return $this->createFromClassNameList($this->getGlobalMiddlewareClassNameList()); + } + + /** + * @return MiddlewareInterface[] + */ + public function getRouteMiddlewareList(Route $route): array + { + $key = strtolower($route->getMethod()) . '_' . $route->getRoute(); + + /** @var class-string[] $classNameList */ + $classNameList = $this->metadata->get(['app', 'api', 'routeMiddlewareClassNameListMap', $key]) ?? []; + + return $this->createFromClassNameList($classNameList); + } + + /** + * @return MiddlewareInterface[] + */ + public function getActionMiddlewareList(Route $route): array + { + $key = strtolower($route->getMethod()) . '_' . $route->getRoute(); + + /** @var class-string[] $classNameList */ + $classNameList = $this->metadata->get(['app', 'api', 'actionMiddlewareClassNameListMap', $key]) ?? []; + + return $this->createFromClassNameList($classNameList); + } + + /** + * @return MiddlewareInterface[] + */ + public function getControllerMiddlewareList(string $controller): array + { + /** @var class-string[] $classNameList */ + $classNameList = $this->metadata + ->get(['app', 'api', 'controllerMiddlewareClassNameListMap', $controller]) ?? []; + + return $this->createFromClassNameList($classNameList); + } + + /** + * @return MiddlewareInterface[] + */ + public function getControllerActionMiddlewareList(string $method, string $controller, string $action): array + { + $key = $controller . '_' . strtolower($method) . '_' . $action; + + /** @var class-string[] $classNameList */ + $classNameList = $this->metadata + ->get(['app', 'api', 'controllerActionMiddlewareClassNameListMap', $key]) ?? []; + + return $this->createFromClassNameList($classNameList); + } + + /** + * @return class-string[] + */ + private function getGlobalMiddlewareClassNameList(): array + { + return $this->metadata->get(['app', 'api', 'globalMiddlewareClassNameList']) ?? []; + } + + /** + * @param class-string[] $classNameList + * @return MiddlewareInterface[] + */ + private function createFromClassNameList(array $classNameList): array + { + $list = []; + + foreach ($classNameList as $className) { + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } +} diff --git a/application/Espo/Core/Api/ProcessData.php b/application/Espo/Core/Api/ProcessData.php new file mode 100644 index 0000000..e4091d0 --- /dev/null +++ b/application/Espo/Core/Api/ProcessData.php @@ -0,0 +1,66 @@ +. + * + * 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\Api; + +class ProcessData +{ + /** + * @param array $routeParams + */ + public function __construct( + private Route $route, + private string $basePath, + private array $routeParams + ) {} + + /** + * @return Route + */ + public function getRoute(): Route + { + return $this->route; + } + + /** + * @return string + */ + public function getBasePath(): string + { + return $this->basePath; + } + + /** + * @return array + */ + public function getRouteParams(): array + { + return $this->routeParams; + } +} diff --git a/application/Espo/Core/Api/Request.php b/application/Espo/Core/Api/Request.php new file mode 100644 index 0000000..4b58419 --- /dev/null +++ b/application/Espo/Core/Api/Request.php @@ -0,0 +1,143 @@ +. + * + * 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\Api; + +use Psr\Http\Message\ServerRequestInterface as Psr7Request; +use Psr\Http\Message\UriInterface; + +use stdClass; + +/** + * Representation of an HTTP request. + */ +interface Request +{ + /** + * Whether a query parameter is set. + */ + public function hasQueryParam(string $name): bool; + + /** + * Get a query parameter. + */ + public function getQueryParam(string $name): ?string; + + /** + * Get all query parameters. + * + * @return array> + */ + public function getQueryParams(): array; + + /** + * Whether a route parameter is set. + */ + public function hasRouteParam(string $name): bool; + + /** + * Get a route parameter. + */ + public function getRouteParam(string $name): ?string; + + /** + * Get all route parameters. + * + * @return array + */ + public function getRouteParams(): array; + + /** + * Get a header value. Multiple values will be concatenated with a comma. + */ + public function getHeader(string $name): ?string; + + /** + * Whether a header is set. + */ + public function hasHeader(string $name): bool; + + /** + * Get a header values as an array. + * + * @return string[] + */ + public function getHeaderAsArray(string $name): array; + + /** + * Get a request method. + */ + public function getMethod(): string; + + /** + * Get Uri. + */ + public function getUri(): UriInterface; + + /** + * Get a relative path of a request (w/o base path). + */ + public function getResourcePath(): string; + + /** + * Get body contents. + */ + public function getBodyContents(): ?string; + + /** + * Get a parsed body. If JSON array is passed, then will be converted to `{"list": ARRAY}`. + */ + public function getParsedBody(): stdClass; + + /** + * Get a cookie param value. + */ + public function getCookieParam(string $name): ?string; + + /** + * Get a server param value. + * + * @return mixed + */ + public function getServerParam(string $name); + + /** + * Get the content type. + * + * @since 10.1.0 + */ + public function getContentType(): ?string; + + /** + * Get the PSR-7 request. + * + * @since 10.1.0 + */ + public function toPsr7(): Psr7Request; +} diff --git a/application/Espo/Core/Api/RequestNull.php b/application/Espo/Core/Api/RequestNull.php new file mode 100644 index 0000000..0ad1d40 --- /dev/null +++ b/application/Espo/Core/Api/RequestNull.php @@ -0,0 +1,152 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Request as ApiRequest; + +use LogicException; +use Psr\Http\Message\ServerRequestInterface as Psr7Request; +use Psr\Http\Message\UriInterface; +use Slim\Psr7\Factory\RequestFactory; +use Slim\Psr7\Factory\UriFactory; + +use stdClass; + +/** + * An empty stub for Request. + */ +class RequestNull implements ApiRequest +{ + public function hasQueryParam(string $name): bool + { + return false; + } + + /** + * @return null + * @noinspection PhpDocSignatureInspection + */ + public function getQueryParam(string $name): ?string + { + return null; + } + + public function getQueryParams(): array + { + return []; + } + + public function hasRouteParam(string $name): bool + { + return false; + } + + public function getRouteParam(string $name): ?string + { + return null; + } + + public function getRouteParams(): array + { + return []; + } + + public function getHeader(string $name): ?string + { + return null; + } + + public function hasHeader(string $name): bool + { + return false; + } + + /** + * @return string[] + */ + public function getHeaderAsArray(string $name): array + { + return []; + } + + public function getMethod(): string + { + return ''; + } + + public function getUri(): UriInterface + { + return (new UriFactory())->createUri(); + } + + public function getResourcePath(): string + { + return ''; + } + + public function getBodyContents(): ?string + { + return null; + } + + public function getParsedBody(): stdClass + { + return (object) []; + } + + public function getCookieParam(string $name): ?string + { + return null; + } + + /** + * @return mixed + */ + public function getServerParam(string $name) + { + return null; + } + + public function getContentType(): ?string + { + return null; + } + + public function toPsr7(): Psr7Request + { + $request = (new RequestFactory())->createRequest(Method::GET, "http://localhost"); + + if (!$request instanceof Psr7Request) { + throw new LogicException(); + } + + return $request; + } +} diff --git a/application/Espo/Core/Api/RequestWrapper.php b/application/Espo/Core/Api/RequestWrapper.php new file mode 100644 index 0000000..ac2ab4f --- /dev/null +++ b/application/Espo/Core/Api/RequestWrapper.php @@ -0,0 +1,301 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Route\ContentType; +use Espo\Core\Utils\Json; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Api\Request as ApiRequest; + +use Psr\Http\Message\ServerRequestInterface as Psr7Request; +use Psr\Http\Message\UriInterface; + +use stdClass; + +/** + * Adapter for PSR-7 request interface. + */ +class RequestWrapper implements ApiRequest +{ + private ?stdClass $parsedBody = null; + + /** + * @param array $routeParams + */ + public function __construct( + private Psr7Request $psr7Request, + private string $basePath = '', + private array $routeParams = [] + ) {} + + /** + * Get a route or query parameter. Route params have a higher priority. + * + * @todo Don't support NULL $name. + * @deprecated As of v6.0. Use getQueryParam & getRouteParam. Left for backward compatibility. + * + * @return mixed + */ + public function get(?string $name = null) + { + if (is_null($name)) { + return array_merge( + $this->getQueryParams(), + $this->routeParams + ); + } + + if ($this->hasRouteParam($name)) { + return $this->getRouteParam($name); + } + + return $this->psr7Request->getQueryParams()[$name] ?? null; + } + + public function hasRouteParam(string $name): bool + { + return array_key_exists($name, $this->routeParams); + } + + public function getRouteParam(string $name): ?string + { + return $this->routeParams[$name] ?? null; + } + + /** + * @return array + */ + public function getRouteParams(): array + { + return $this->routeParams; + } + + public function hasQueryParam(string $name): bool + { + return array_key_exists($name, $this->psr7Request->getQueryParams()); + } + + public function getQueryParam(string $name): ?string + { + $value = $this->psr7Request->getQueryParams()[$name] ?? null; + + if (!is_string($value)) { + return null; + } + + return $value; + } + + public function getQueryParams(): array + { + return $this->psr7Request->getQueryParams(); + } + + public function getHeader(string $name): ?string + { + if (!$this->psr7Request->hasHeader($name)) { + return null; + } + + return $this->psr7Request->getHeaderLine($name); + } + + public function hasHeader(string $name): bool + { + return $this->psr7Request->hasHeader($name); + } + + /** + * @return string[] + */ + public function getHeaderAsArray(string $name): array + { + if (!$this->psr7Request->hasHeader($name)) { + return []; + } + + return $this->psr7Request->getHeader($name); + } + + public function getMethod(): string + { + return $this->psr7Request->getMethod(); + } + + public function getContentType(): ?string + { + if (!$this->hasHeader('Content-Type')) { + return null; + } + + $contentType = explode( + ';', + $this->psr7Request->getHeader('Content-Type')[0] + )[0]; + + return strtolower($contentType); + } + + public function getBodyContents(): ?string + { + $contents = $this->psr7Request->getBody()->getContents(); + + $this->psr7Request->getBody()->rewind(); + + return $contents; + } + + /** + * @throws BadRequest + */ + public function getParsedBody(): stdClass + { + if ($this->parsedBody === null) { + $this->initParsedBody(); + } + + if ($this->parsedBody === null) { + throw new BadRequest(); + } + + return Util::cloneObject($this->parsedBody); + } + + /** + * @throws BadRequest + */ + private function initParsedBody(): void + { + $contents = $this->getBodyContents(); + + $contentType = $this->getContentType(); + + if ($contentType === ContentType::APPLICATION_JSON && $contents) { + $parsedBody = Json::decode($contents); + + if (is_array($parsedBody)) { + $parsedBody = (object) [ + 'list' => $parsedBody, + ]; + } + + if (!$parsedBody instanceof stdClass) { + throw new BadRequest("Body is not a JSON object."); + } + + $this->parsedBody = $parsedBody; + + return; + } + + if ( + in_array($contentType, [ + ContentType::APPLICATION_X_WWW_FORM_URLENCODED, + ContentType::MULTIPART_FORM_DATA, + ]) && + $contents + ) { + $parsedBody = $this->psr7Request->getParsedBody(); + + if (is_array($parsedBody)) { + $this->parsedBody = (object) $parsedBody; + + return; + } + + if ($parsedBody instanceof stdClass) { + $this->parsedBody = $parsedBody; + + return; + } + } + + $this->parsedBody = (object) []; + } + + public function getCookieParam(string $name): ?string + { + $params = $this->psr7Request->getCookieParams(); + + return $params[$name] ?? null; + } + + /** + * @return mixed + */ + public function getServerParam(string $name) + { + $params = $this->psr7Request->getServerParams(); + + return $params[$name] ?? null; + } + + public function getUri(): UriInterface + { + return $this->psr7Request->getUri(); + } + + public function getResourcePath(): string + { + $path = $this->psr7Request->getUri()->getPath(); + + return substr($path, strlen($this->basePath)); + } + + public function isGet(): bool + { + return $this->getMethod() === Method::GET; + } + + public function isPut(): bool + { + return $this->getMethod() === Method::PUT; + } + + public function isPost(): bool + { + return $this->getMethod() === Method::POST; + } + + public function isPatch(): bool + { + return $this->getMethod() === Method::PATCH; + } + + public function isDelete(): bool + { + return $this->getMethod() === Method::DELETE; + } + + public function toPsr7(): Psr7Request + { + return $this->psr7Request; + } +} diff --git a/application/Espo/Core/Api/Response.php b/application/Espo/Core/Api/Response.php new file mode 100644 index 0000000..d77cd86 --- /dev/null +++ b/application/Espo/Core/Api/Response.php @@ -0,0 +1,117 @@ +. + * + * 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\Api; + +use Psr\Http\Message\ResponseInterface as Psr7Response; +use Psr\Http\Message\StreamInterface; + +/** + * Representation of an HTTP response. An instance is mutable. + */ +interface Response +{ + /** + * Get a status code. + */ + public function getStatusCode(): int; + + /** + * Get a status reason phrase. + */ + public function getReasonPhrase(): string; + + /** + * Set a status code. + */ + public function setStatus(int $code, ?string $reason = null): self; + + /** + * Set a specific header. + */ + public function setHeader(string $name, string $value): self; + + /** + * Add a specific header. + */ + public function addHeader(string $name, string $value): self; + + /** + * Get a header value. + */ + public function getHeader(string $name): ?string; + + /** + * Whether a header is set. + */ + public function hasHeader(string $name): bool; + + /** + * Get all set header names. + * + * @return string[] + */ + public function getHeaderNames(): array; + + /** + * Get a header values as an array. + * + * @return string[] + */ + public function getHeaderAsArray(string $name): array; + + /** + * Write a body. + */ + public function writeBody(string $string): self; + + /** + * Set a body. + */ + public function setBody(StreamInterface $body): self; + + /** + * Get a body. + */ + public function getBody(): StreamInterface; + + /** + * Apply a PSR-7 response. + * + * @since 10.1.0 + */ + public function applyPsr7(Psr7Response $psr7Response): self; + + /** + * Get the PSR-7 response. + * + * @since 10.1.0 + */ + public function toPsr7(): Psr7Response; +} diff --git a/application/Espo/Core/Api/ResponseComposer.php b/application/Espo/Core/Api/ResponseComposer.php new file mode 100644 index 0000000..cdb6525 --- /dev/null +++ b/application/Espo/Core/Api/ResponseComposer.php @@ -0,0 +1,60 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Route\ContentType; +use Slim\Psr7\Factory\ResponseFactory; +use Espo\Core\Utils\Json; +use stdClass; + +class ResponseComposer +{ + /** + * Compose a JSON response. + * + * @param array|stdClass|scalar|null $data A data to encode. + */ + public static function json(mixed $data): Response + { + return self::empty() + ->writeBody(Json::encode($data)) + ->setHeader('Content-Type', ContentType::APPLICATION_JSON); + } + + /** + * Compose an empty response. + */ + public static function empty(): Response + { + $psr7Response = (new ResponseFactory())->createResponse(); + + return new ResponseWrapper($psr7Response); + } +} diff --git a/application/Espo/Core/Api/ResponseWrapper.php b/application/Espo/Core/Api/ResponseWrapper.php new file mode 100644 index 0000000..64934e4 --- /dev/null +++ b/application/Espo/Core/Api/ResponseWrapper.php @@ -0,0 +1,143 @@ +. + * + * 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\Api; + +use Psr\Http\Message\ResponseInterface as Psr7Response; +use Psr\Http\Message\StreamInterface; + +use Espo\Core\Api\Response as ApiResponse; + +/** + * Adapter for PSR-7 response interface. + */ +class ResponseWrapper implements ApiResponse +{ + public function __construct(private Psr7Response $psr7Response) + { + // Slim adds Authorization header. It's not needed. + $this->psr7Response = $this->psr7Response->withoutHeader('Authorization'); + } + + public function setStatus(int $code, ?string $reason = null): Response + { + $this->psr7Response = $this->psr7Response->withStatus($code, $reason ?? ''); + + return $this; + } + + public function getStatusCode(): int + { + return $this->psr7Response->getStatusCode(); + } + + public function getReasonPhrase(): string + { + return $this->psr7Response->getReasonPhrase(); + } + + public function setHeader(string $name, string $value): Response + { + $this->psr7Response = $this->psr7Response->withHeader($name, $value); + + return $this; + } + + public function addHeader(string $name, string $value): Response + { + $this->psr7Response = $this->psr7Response->withAddedHeader($name, $value); + + return $this; + } + + public function getHeader(string $name): ?string + { + if (!$this->psr7Response->hasHeader($name)) { + return null; + } + + return $this->psr7Response->getHeaderLine($name); + } + + public function hasHeader(string $name): bool + { + return $this->psr7Response->hasHeader($name); + } + + /** + * @return string[] + */ + public function getHeaderAsArray(string $name): array + { + if (!$this->psr7Response->hasHeader($name)) { + return []; + } + + return $this->psr7Response->getHeader($name); + } + + /** + * @return string[] + */ + public function getHeaderNames(): array + { + return array_keys($this->psr7Response->getHeaders()); + } + + public function writeBody(string $string): Response + { + $this->psr7Response->getBody()->write($string); + + return $this; + } + + public function setBody(StreamInterface $body): Response + { + $this->psr7Response = $this->psr7Response->withBody($body); + + return $this; + } + + public function getBody(): StreamInterface + { + return $this->psr7Response->getBody(); + } + + public function applyPsr7(Psr7Response $psr7Response): self + { + $this->psr7Response = $psr7Response; + + return $this; + } + + public function toPsr7(): Psr7Response + { + return $this->psr7Response; + } +} diff --git a/application/Espo/Core/Api/Route.php b/application/Espo/Core/Api/Route.php new file mode 100644 index 0000000..84f9632 --- /dev/null +++ b/application/Espo/Core/Api/Route.php @@ -0,0 +1,105 @@ +. + * + * 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\Api; + +class Route +{ + private string $method; + + /** + * @param array $params + * @param ?string[] $consumes + * @param ?class-string $actionClassName + */ + public function __construct( + string $method, + private string $route, + private string $adjustedRoute, + private array $params, + private bool $noAuth, + private ?array $consumes, + private ?string $actionClassName, + ) { + $this->method = strtoupper($method); + } + + /** + * @return ?class-string + */ + public function getActionClassName(): ?string + { + return $this->actionClassName; + } + + public function getMethod(): string + { + return $this->method; + } + + /** + * Get a route. + */ + public function getRoute(): string + { + return $this->route; + } + + /** + * Get an adjusted route for FastRoute. + */ + public function getAdjustedRoute(): string + { + return $this->adjustedRoute; + } + + /** + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + public function noAuth(): bool + { + return $this->noAuth; + } + + /** + * Allowed media types. + * + * @return ?string[] + * @since 10.1.0 + */ + public function getConsumes(): ?array + { + return $this->consumes; + } +} diff --git a/application/Espo/Core/Api/Route/ContentType.php b/application/Espo/Core/Api/Route/ContentType.php new file mode 100644 index 0000000..e690e8f --- /dev/null +++ b/application/Espo/Core/Api/Route/ContentType.php @@ -0,0 +1,41 @@ +. + * + * 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\Api\Route; + +/** + * @since 10.1.0 + */ +class ContentType +{ + public const string APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded'; + public const string MULTIPART_FORM_DATA = 'multipart/form-data'; + public const string APPLICATION_JSON = 'application/json'; + public const string TEXT_PLAIN = 'text/plain'; +} diff --git a/application/Espo/Core/Api/Route/RouteParamsFetcher.php b/application/Espo/Core/Api/Route/RouteParamsFetcher.php new file mode 100644 index 0000000..996e620 --- /dev/null +++ b/application/Espo/Core/Api/Route/RouteParamsFetcher.php @@ -0,0 +1,76 @@ +. + * + * 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\Api\Route; + +use Espo\Core\Api\Route; + +class RouteParamsFetcher +{ + /** + * @param array $args + * @return array + */ + public function fetch(Route $item, array $args): array + { + $params = []; + + $routeParams = $item->getParams(); + + $setKeyList = []; + + foreach (array_keys($routeParams) as $key) { + $value = $routeParams[$key]; + + $paramName = $key; + + if ($value[0] === ':') { + $realKey = substr($value, 1); + + $params[$paramName] = $args[$realKey]; + + $setKeyList[] = $realKey; + + continue; + } + + $params[$paramName] = $value; + } + + foreach ($args as $key => $value) { + if (in_array($key, $setKeyList)) { + continue; + } + + $params[$key] = $value; + } + + return $params; + } +} diff --git a/application/Espo/Core/Api/RouteProcessor.php b/application/Espo/Core/Api/RouteProcessor.php new file mode 100644 index 0000000..40f30a5 --- /dev/null +++ b/application/Espo/Core/Api/RouteProcessor.php @@ -0,0 +1,301 @@ +. + * + * 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\Api; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Authentication\AuthenticationFactory; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Log; +use Espo\Core\ApplicationUser; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ResponseInterface as Psr7Response; +use Psr\Http\Message\ServerRequestInterface as Psr7Request; + +use Slim\MiddlewareDispatcher; + +use Throwable; +use LogicException; +use Exception; + +/** + * Processes routes. Handles authentication. Obtains a controller name, action, body from a request. + * Then processes a controller action or an action. + * + * @internal + */ +class RouteProcessor +{ + public function __construct( + private AuthenticationFactory $authenticationFactory, + private AuthBuilderFactory $authBuilderFactory, + private ErrorOutput $errorOutput, + private Config $config, + private Log $log, + private ApplicationUser $applicationUser, + private ControllerActionProcessor $actionProcessor, + private MiddlewareProvider $middlewareProvider, + private InjectableFactory $injectableFactory, + ) {} + + public function process( + ProcessData $processData, + Psr7Request $request, + Psr7Response $response, + ): Psr7Response { + + $requestWrapped = new RequestWrapper($request, $processData->getBasePath(), $processData->getRouteParams()); + $responseWrapped = new ResponseWrapper($response); + + try { + return $this->processInternal( + processData: $processData, + psrRequest: $request, + request: $requestWrapped, + response: $responseWrapped, + ); + } catch (Exception $exception) { + $this->handleException( + exception: $exception, + request: $requestWrapped, + response: $responseWrapped, + route: $processData->getRoute()->getAdjustedRoute(), + ); + + return $responseWrapped->toPsr7(); + } + } + + /** + * @throws BadRequest + */ + private function processInternal( + ProcessData $processData, + Psr7Request $psrRequest, + RequestWrapper $request, + ResponseWrapper $response, + ): Psr7Response { + + $this->assertContentTypeAllowed($processData, $request); + + $authRequired = !$processData->getRoute()->noAuth(); + + $apiAuth = $this->authBuilderFactory + ->create() + ->setAuthentication($this->authenticationFactory->create()) + ->setAuthRequired($authRequired) + ->build(); + + $authResult = $apiAuth->process($request, $response); + + if (!$authResult->isResolved()) { + return $response->toPsr7(); + } + + if ($authResult->isResolvedUseNoAuth()) { + $this->applicationUser->setupSystemUser(); + } + + ob_start(); + + $response = $this->processAfterAuth($processData, $psrRequest, $response); + + ob_clean(); + + return $response; + } + + /** + * @throws BadRequest + */ + private function processAfterAuth( + ProcessData $processData, + Psr7Request $request, + ResponseWrapper $responseWrapped, + ): Psr7Response { + + $actionClassName = $processData->getRoute()->getActionClassName(); + + if ($actionClassName) { + return $this->processAction( + actionClassName: $actionClassName, + processData: $processData, + request: $request, + responseWrapped: $responseWrapped, + ); + } + + return $this->processControllerAction($processData, $request, $responseWrapped); + } + + /** + * @param class-string $actionClassName + */ + private function processAction( + string $actionClassName, + ProcessData $processData, + Psr7Request $request, + ResponseWrapper $responseWrapped, + ): Psr7Response { + + $action = $this->injectableFactory->create($actionClassName); + + $handler = new ActionHandler( + action: $action, + processData: $processData, + config: $this->config, + ); + + $dispatcher = new MiddlewareDispatcher($handler); + + foreach ($this->middlewareProvider->getActionMiddlewareList($processData->getRoute()) as $middleware) { + $dispatcher->addMiddleware($middleware); + } + + $response = $dispatcher->handle($request); + + // Apply headers added by the authentication. + foreach ($responseWrapped->getHeaderNames() as $name) { + $response = $response->withHeader($name, $responseWrapped->getHeaderAsArray($name)); + } + + return $response; + } + + /** + * @throws BadRequest + */ + private function processControllerAction( + ProcessData $processData, + Psr7Request $request, + ResponseWrapper $responseWrapped, + ): Psr7Response { + + $controller = $this->getControllerName($processData); + $action = $processData->getRouteParams()['action'] ?? null; + $method = $request->getMethod(); + + if (!$action) { + $crudMethodActionMap = $this->config->get('crud') ?? []; + $action = $crudMethodActionMap[strtolower($method)] ?? null; + + if (!$action) { + throw new BadRequest("No action for method `$method`."); + } + } + + $handler = new ControllerActionHandler( + controllerName: $controller, + actionName: $action, + processData: $processData, + responseWrapped: $responseWrapped, + controllerActionProcessor: $this->actionProcessor, + config: $this->config, + ); + + $dispatcher = new MiddlewareDispatcher($handler); + + $this->addControllerMiddlewares($dispatcher, $method, $controller, $action); + + return $dispatcher->handle($request); + } + + private function getControllerName(ProcessData $processData): string + { + $controllerName = $processData->getRouteParams()['controller'] ?? null; + + if (!$controllerName) { + throw new LogicException("Route doesn't have specified controller."); + } + + return ucfirst($controllerName); + } + + private function handleException( + Exception $exception, + Request $request, + Response $response, + string $route, + ): void { + + try { + $this->errorOutput->process( + request: $request, + response: $response, + exception: $exception, + route: $route, + ); + } catch (Throwable $exceptionAnother) { + $this->log->error($exceptionAnother->getMessage()); + + $response->setStatus(500); + } + } + + /** + * @param MiddlewareDispatcher $dispatcher + */ + private function addControllerMiddlewares( + MiddlewareDispatcher $dispatcher, + string $method, + string $controller, + string $action, + ): void { + + $controllerActionMiddlewareList = $this->middlewareProvider->getControllerActionMiddlewareList( + method: $method, + controller: $controller, + action: $action, + ); + + foreach ($controllerActionMiddlewareList as $middleware) { + $dispatcher->addMiddleware($middleware); + } + + $controllerMiddlewareList = $this->middlewareProvider->getControllerMiddlewareList($controller); + + foreach ($controllerMiddlewareList as $middleware) { + $dispatcher->addMiddleware($middleware); + } + } + + /** + * @throws BadRequest + */ + private function assertContentTypeAllowed(ProcessData $processData, RequestWrapper $request): void + { + $consumes = $processData->getRoute()->getConsumes(); + + if ($consumes !== null && !in_array($request->getContentType(), $consumes)) { + throw new BadRequest("Not allowed Content-Type."); + } + } +} diff --git a/application/Espo/Core/Api/Starter.php b/application/Espo/Core/Api/Starter.php new file mode 100644 index 0000000..3b2110b --- /dev/null +++ b/application/Espo/Core/Api/Starter.php @@ -0,0 +1,154 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Route\RouteParamsFetcher; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\Route as RouteUtil; +use Espo\Core\Utils\Log; + +use Slim\App as SlimApp; +use Slim\Exception\HttpBadRequestException; +use Slim\Factory\AppFactory as SlimAppFactory; + +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface as Psr7Response; +use Psr\Http\Message\ServerRequestInterface as Psr7Request; + +/** + * API request processing entry point. + */ +class Starter +{ + private string $routeCacheFile = 'data/cache/application/slim-routes.php'; + + public function __construct( + private RouteProcessor $routeProcessor, + private RouteUtil $routeUtil, + private RouteParamsFetcher $routeParamsFetcher, + private MiddlewareProvider $middlewareProvider, + private Log $log, + private SystemConfig $systemConfig, + ?string $routeCacheFile = null + ) { + $this->routeCacheFile = $routeCacheFile ?? $this->routeCacheFile; + } + + public function start(): void + { + $slim = SlimAppFactory::create(); + + if (RouteUtil::isBadUri()) { + $this->processError($slim); + + return; + } + + if ($this->systemConfig->useCache()) { + $slim->getRouteCollector()->setCacheFile($this->routeCacheFile); + } + + $slim->setBasePath(RouteUtil::detectBasePath()); + $this->addGlobalMiddlewares($slim); + $slim->addRoutingMiddleware(); + $this->addRoutes($slim); + $slim->addErrorMiddleware(false, true, true, $this->log); + + $slim->run(); + } + + /** + * @param SlimApp $slim + */ + private function addGlobalMiddlewares(SlimApp $slim): void + { + foreach ($this->middlewareProvider->getGlobalMiddlewareList() as $middleware) { + $slim->add($middleware); + } + } + + /** + * @param SlimApp $slim + */ + private function addRoutes(SlimApp $slim): void + { + $routeList = $this->routeUtil->getFullList(); + + foreach ($routeList as $item) { + $this->addRoute($slim, $item); + } + } + + /** + * @param SlimApp $slim + */ + private function addRoute(SlimApp $slim, Route $item): void + { + $slimRoute = $slim->map( + [$item->getMethod()], + $item->getAdjustedRoute(), + function (Psr7Request $request, Psr7Response $response, array $args) use ($slim, $item) { + $routeParams = $this->routeParamsFetcher->fetch($item, $args); + + $processData = new ProcessData( + route: $item, + basePath: $slim->getBasePath(), + routeParams: $routeParams, + ); + + return $this->routeProcessor->process($processData, $request, $response); + } + ); + + $middlewareList = $this->middlewareProvider->getRouteMiddlewareList($item); + + foreach ($middlewareList as $middleware) { + $slimRoute->addMiddleware($middleware); + } + } + + /** + * @param SlimApp $slim + */ + private function processError(SlimApp $slim): void + { + $slim->add(function (Psr7Request $request): Psr7Response { + throw new HttpBadRequestException($request, 'Malformed request path.'); + }); + + $slim->addErrorMiddleware( + displayErrorDetails: false, + logErrors: false, + logErrorDetails: false, + ); + + $slim->run(); + } +} diff --git a/application/Espo/Core/Api/Util.php b/application/Espo/Core/Api/Util.php new file mode 100644 index 0000000..6fc9b62 --- /dev/null +++ b/application/Espo/Core/Api/Util.php @@ -0,0 +1,85 @@ +. + * + * 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\Api; + +use Espo\Core\Utils\Config; +use stdClass; + +class Util +{ + public function __construct(private Config $config) {} + + public static function cloneObject(stdClass $source): stdClass + { + $cloned = (object) []; + + foreach (get_object_vars($source) as $k => $v) { + $cloned->$k = self::cloneObjectItem($v); + } + + return $cloned; + } + + /** + * @param mixed $item + * @return mixed + */ + private static function cloneObjectItem($item) + { + if (is_array($item)) { + $cloned = []; + + foreach ($item as $v) { + $cloned[] = self::cloneObjectItem($v); + } + + return $cloned; + } + + if ($item instanceof stdClass) { + return self::cloneObject($item); + } + + if (is_object($item)) { + return clone $item; + } + + return $item; + } + + public function obtainIpFromRequest(Request $request): ?string + { + // Do not add support of any more parameters here. + + $param = $this->config->get('ipAddressServerParam') ?? 'REMOTE_ADDR'; + + return $request->getServerParam($param); + } +} diff --git a/application/Espo/Core/Application.php b/application/Espo/Core/Application.php new file mode 100644 index 0000000..da552ac --- /dev/null +++ b/application/Espo/Core/Application.php @@ -0,0 +1,170 @@ +. + * + * 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; + +use Espo\Core\Application\ApplicationParams; +use Espo\Core\Application\Runner; +use Espo\Core\Application\RunnerParameterized; +use Espo\Core\Container\ContainerBuilder; +use Espo\Core\Application\RunnerRunner; +use Espo\Core\Application\Runner\Params as RunnerParams; +use Espo\Core\Application\Exceptions\RunnerException; +use Espo\Core\Utils\Autoload; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\ClientManager; +use RuntimeException; + +/** + * A central access point of the application. + */ +class Application +{ + protected Container $container; + + public function __construct( + ?ApplicationParams $params = null, + ) { + date_default_timezone_set('UTC'); + + $this->initContainer($params); + $this->initAutoloads(); + $this->initPreloads(); + } + + protected function initContainer(?ApplicationParams $params): void + { + $container = (new ContainerBuilder()) + ->withParams($params) + ->build(); + + if (!$container instanceof Container) { + throw new RuntimeException(); + } + + $this->container = $container; + } + + /** + * Run an application runner. + * + * @param class-string $className A runner class name. + * @param ?RunnerParams $params Runner parameters. + */ + public function run(string $className, ?RunnerParams $params = null): void + { + $runnerRunner = $this->getInjectableFactory()->create(RunnerRunner::class); + + try { + $runnerRunner->run($className, $params); + } catch (RunnerException $e) { + die($e->getMessage()); + } + } + + /** + * Whether the application is installed. + */ + public function isInstalled(): bool + { + return $this->getConfig()->get('isInstalled'); + } + + /** + * Get the service container. + */ + public function getContainer(): Container + { + return $this->container; + } + + /** + * @since 10.0.0 + */ + public function getInjectableFactory(): InjectableFactory + { + return $this->container->getByClass(InjectableFactory::class); + } + + protected function getApplicationUser(): ApplicationUser + { + return $this->container->getByClass(ApplicationUser::class); + } + + protected function getClientManager(): ClientManager + { + return $this->container->getByClass(ClientManager::class); + } + + protected function getMetadata(): Metadata + { + return $this->container->getByClass(Metadata::class); + } + + protected function getConfig(): Config + { + return $this->container->getByClass(Config::class); + } + + protected function initAutoloads(): void + { + $autoload = $this->getInjectableFactory()->create(Autoload::class); + + $autoload->register(); + } + + /** + * Initialize services that has the 'preload' parameter. + */ + protected function initPreloads(): void + { + foreach ($this->getMetadata()->get(['app', 'containerServices']) ?? [] as $name => $defs) { + if ($defs['preload'] ?? false) { + $this->container->get($name); + } + } + } + + /** + * Set a base path of an index file related to the application directory. Used for a portal. + */ + public function setClientBasePath(string $basePath): void + { + $this->getClientManager()->setBasePath($basePath); + } + + /** + * Set up the system user. The system user is used when no user is logged in. + */ + public function setupSystemUser(): void + { + $this->getApplicationUser()->setupSystemUser(); + } +} diff --git a/application/Espo/Core/Application/ApplicationParams.php b/application/Espo/Core/Application/ApplicationParams.php new file mode 100644 index 0000000..98c263c --- /dev/null +++ b/application/Espo/Core/Application/ApplicationParams.php @@ -0,0 +1,51 @@ +. + * + * 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\Application; + +use Espo\Core\Binding\BindingProcessor; + +/** + * @since 9.2.0 + */ +readonly class ApplicationParams +{ + /** + * Important. Use named parameters. Backward compatibility for parameter order is not guaranteed. + * + * @param bool $noErrorHandler Disable error handling for tests. + * @param ?BindingProcessor $binding Additional DI binding for integration tests. Since v9.3. + * @param ?array $services Instances of services. Since 10.0. + */ + public function __construct( + public bool $noErrorHandler = false, + public ?BindingProcessor $binding = null, + public ?array $services = null, + ) {} +} diff --git a/application/Espo/Core/Application/Exceptions/RunnerException.php b/application/Espo/Core/Application/Exceptions/RunnerException.php new file mode 100644 index 0000000..3789c48 --- /dev/null +++ b/application/Espo/Core/Application/Exceptions/RunnerException.php @@ -0,0 +1,35 @@ +. + * + * 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\Application\Exceptions; + +use Exception; + +class RunnerException extends Exception +{} diff --git a/application/Espo/Core/Application/Runner.php b/application/Espo/Core/Application/Runner.php new file mode 100644 index 0000000..7f04c21 --- /dev/null +++ b/application/Espo/Core/Application/Runner.php @@ -0,0 +1,43 @@ +. + * + * 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\Application; + +use Espo\Core\Application\Exceptions\RunnerException; + +/** + * Runs an application. + */ +interface Runner +{ + /** + * @throws RunnerException + */ + public function run(): void; +} diff --git a/application/Espo/Core/Application/Runner/Params.php b/application/Espo/Core/Application/Runner/Params.php new file mode 100644 index 0000000..de2eecb --- /dev/null +++ b/application/Espo/Core/Application/Runner/Params.php @@ -0,0 +1,89 @@ +. + * + * 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\Application\Runner; + +/** + * Parameters for an application runner. + */ +class Params +{ + /** @var array */ + private $data = []; + + public function __construct() {} + + /** + * Get a parameter value. + */ + public function get(string $name): mixed + { + return $this->data[$name] ?? null; + } + + /** + * Whether a parameter is set. + */ + public function has(string $name): bool + { + return array_key_exists($name, $this->data); + } + + /** + * Clone with a parameter value. + */ + public function with(string $name, mixed $value): self + { + $obj = clone $this; + $obj->data[$name] = $value; + + return $obj; + } + + /** + * Create from an associative array. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + $obj = new self(); + $obj->data = $data; + + return $obj; + } + + /** + * Create an empty instance. + */ + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Application/RunnerParameterized.php b/application/Espo/Core/Application/RunnerParameterized.php new file mode 100644 index 0000000..fd5d9b0 --- /dev/null +++ b/application/Espo/Core/Application/RunnerParameterized.php @@ -0,0 +1,44 @@ +. + * + * 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\Application; + +use Espo\Core\Application\Exceptions\RunnerException; +use Espo\Core\Application\Runner\Params; + +/** + * Runs an application with parameters. + */ +interface RunnerParameterized +{ + /** + * @throws RunnerException + */ + public function run(Params $params): void; +} diff --git a/application/Espo/Core/Application/RunnerRunner.php b/application/Espo/Core/Application/RunnerRunner.php new file mode 100644 index 0000000..213d013 --- /dev/null +++ b/application/Espo/Core/Application/RunnerRunner.php @@ -0,0 +1,86 @@ +. + * + * 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\Application; + +use Espo\Core\Utils\Log; +use Espo\Core\ApplicationUser; +use Espo\Core\InjectableFactory; +use Espo\Core\Application\Exceptions\RunnerException; +use Espo\Core\Application\Runner\Params; + +use ReflectionClass; + +/** + * Runs a runner. + */ +class RunnerRunner +{ + public function __construct( + private Log $log, + private ApplicationUser $applicationUser, + private InjectableFactory $injectableFactory + ) {} + + /** + * @param class-string $className + * @throws RunnerException + */ + public function run(string $className, ?Params $params = null): void + { + if (!class_exists($className)) { + $this->log->error("Application runner '$className' does not exist."); + + throw new RunnerException(); + } + + $class = new ReflectionClass($className); + + if ( + $class->getStaticPropertyValue('cli', false) && + !str_starts_with(php_sapi_name() ?: '', 'cli') + ) { + throw new RunnerException("Can be run only via CLI."); + } + + if ($class->getStaticPropertyValue('setupSystemUser', false)) { + $this->applicationUser->setupSystemUser(); + } + + $runner = $this->injectableFactory->create($className); + + if ($runner instanceof RunnerParameterized) { + $runner->run($params ?? Params::create()); + + return; + } + + $runner->run(); + } +} diff --git a/application/Espo/Core/ApplicationRunners/Api.php b/application/Espo/Core/ApplicationRunners/Api.php new file mode 100644 index 0000000..297cb5c --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/Api.php @@ -0,0 +1,47 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\Api\Starter; + +/** + * Runs API request processing. + */ +class Api implements Runner +{ + public function __construct(private Starter $starter) + {} + + public function run(): void + { + $this->starter->start(); + } +} diff --git a/application/Espo/Core/ApplicationRunners/ApplicationRunner.php b/application/Espo/Core/ApplicationRunners/ApplicationRunner.php new file mode 100644 index 0000000..7a76ce0 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/ApplicationRunner.php @@ -0,0 +1,39 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner; + +/** + * @deprecated For backward compatibility. + */ +interface ApplicationRunner extends Runner +{ +} diff --git a/application/Espo/Core/ApplicationRunners/ClearCache.php b/application/Espo/Core/ApplicationRunners/ClearCache.php new file mode 100644 index 0000000..412bea7 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/ClearCache.php @@ -0,0 +1,53 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\DataManager; +use Espo\Core\Exceptions\Error; + +/** + * Clears an application cache. + */ +class ClearCache implements Runner +{ + use Cli; + + public function __construct(private DataManager $dataManager) + {} + + /** + * @throws Error + */ + public function run(): void + { + $this->dataManager->clearCache(); + } +} diff --git a/application/Espo/Core/ApplicationRunners/Cli.php b/application/Espo/Core/ApplicationRunners/Cli.php new file mode 100644 index 0000000..7306f08 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/Cli.php @@ -0,0 +1,38 @@ +. + * + * 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\ApplicationRunners; + +/** + * Can be run only via CLI. + */ +trait Cli +{ + public static bool $cli = true; +} diff --git a/application/Espo/Core/ApplicationRunners/Client.php b/application/Espo/Core/ApplicationRunners/Client.php new file mode 100644 index 0000000..34ada9e --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/Client.php @@ -0,0 +1,54 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\Utils\ClientManager; +use Espo\Core\Utils\Config; + +/** + * Displays the main HTML page. + */ +class Client implements Runner +{ + public function __construct(private ClientManager $clientManager, private Config $config) + {} + + public function run(): void + { + if (!$this->config->get('isInstalled')) { + header("Location: install/"); + + return; + } + + $this->clientManager->display(); + } +} diff --git a/application/Espo/Core/ApplicationRunners/Command.php b/application/Espo/Core/ApplicationRunners/Command.php new file mode 100644 index 0000000..18d4603 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/Command.php @@ -0,0 +1,59 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\Console\CommandManager as ConsoleCommandManager; + +use Exception; + +/** + * Runs a console command. + */ +class Command implements Runner +{ + use Cli; + + public function __construct(private ConsoleCommandManager $commandManager) + {} + + public function run(): void + { + try { + $exitStatus = $this->commandManager->run($_SERVER['argv']); + } catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + + exit(1); + } + + exit($exitStatus); + } +} diff --git a/application/Espo/Core/ApplicationRunners/Cron.php b/application/Espo/Core/ApplicationRunners/Cron.php new file mode 100644 index 0000000..a074e54 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/Cron.php @@ -0,0 +1,77 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Exceptions\RunnerException; +use Espo\Core\Application\Runner; +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\JobManager; +use Espo\Core\Job\PrepareProcessor; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\Log; + +/** + * Runs Cron. + */ +class Cron implements Runner +{ + use Cli; + use SetupSystemUser; + + public function __construct( + private PrepareProcessor $prepareProcessor, + private JobManager $jobManager, + private SystemConfig $config, + private Log $log, + ) {} + + public function run(): void + { + if (!$this->config->isCronEnabled()) { + $this->log->warning("Cron is skipped as 'cronDisabled' is set to true in the config."); + + return; + } + + if ($this->config->isMaintenanceMode()) { + $this->log->warning("Cron run is skipped in maintenance mode."); + + return; + } + + try { + $this->prepareProcessor->process(); + } catch (TooFrequentRun $e) { + throw new RunnerException('Too frequent run.', previous: $e); + } + + $this->jobManager->processMainQueue(); + } +} diff --git a/application/Espo/Core/ApplicationRunners/Daemon.php b/application/Espo/Core/ApplicationRunners/Daemon.php new file mode 100644 index 0000000..b5d4089 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/Daemon.php @@ -0,0 +1,123 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\Job\Processing\Util\ExitSetup; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Log; + +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Runs daemon. The daemon runs the cron more often than once a minute. + */ +class Daemon implements Runner +{ + use Cli; + + private bool $stopped = false; + + public function __construct( + private Config $config, + private Log $log, + private ExitSetup $exitSetup, + ) {} + + public function run(): void + { + $maxProcessNumber = $this->config->get('daemonMaxProcessNumber'); + $interval = $this->config->get('daemonInterval'); + $timeout = $this->config->get('daemonProcessTimeout'); + + $phpExecutablePath = $this->config->get('phpExecutablePath'); + + if (!$phpExecutablePath) { + $phpExecutablePath = (new PhpExecutableFinder)->find(); + } + + if (!$maxProcessNumber || !$interval) { + $this->log->error("Daemon config params are not set."); + + return; + } + + $this->exitSetup->setup(function () { + $this->stopped = true; + }); + + $processList = []; + + while (!$this->isStopped()) { /** @phpstan-ignore-line */ + $toSkip = false; + $runningCount = 0; + + foreach ($processList as $i => $process) { + if ($process->isRunning()) { + $runningCount++; + } else { + unset($processList[$i]); + } + } + + $processList = array_values($processList); + + if ($runningCount >= $maxProcessNumber) { + $toSkip = true; + } + + if (!$toSkip) { + $process = new Process([$phpExecutablePath, 'cron.php']); + + $process->setTimeout($timeout); + + $process->start(); + + $processList[] = $process; + } + + /** @noinspection PhpConditionAlreadyCheckedInspection */ + if ($this->isStopped()) { + break; + } + + sleep($interval); + } + } + + /** + * @phpstan-impure + */ + private function isStopped(): bool + { + return $this->stopped; + } +} diff --git a/application/Espo/Core/ApplicationRunners/EntryPoint.php b/application/Espo/Core/ApplicationRunners/EntryPoint.php new file mode 100644 index 0000000..48a99e5 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/EntryPoint.php @@ -0,0 +1,78 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Exceptions\RunnerException; +use Espo\Core\Application\RunnerParameterized; +use Espo\Core\Application\Runner\Params; +use Espo\Core\EntryPoint\Params as EntryPointParams; +use Espo\Core\EntryPoint\Starter; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use RuntimeException; + +/** + * Runs an entry point. + */ +class EntryPoint implements RunnerParameterized +{ + public const string PARAM_ENTRY_POINT = 'entryPoint'; + public const string PARAM_FINAL = 'final'; + + public function __construct(private Starter $starter) + {} + + public function run(Params $params): void + { + $entryPointParams = new EntryPointParams( + name: $this->getName($params), + final: $params->get(self::PARAM_FINAL) === true, + ); + + try { + $this->starter->start($entryPointParams); + } catch (Forbidden|NotFound $e) { + $message = $e->getMessage(); + + throw new RunnerException($message, previous: $e); + } + } + + private function getName(Params $params): ?string + { + $name = $params->get(self::PARAM_ENTRY_POINT); + + if ($name !== null && !is_string($name)) { + throw new RuntimeException("Bad 'entryPoint' value."); + } + + return $name; + } +} diff --git a/application/Espo/Core/ApplicationRunners/Job.php b/application/Espo/Core/ApplicationRunners/Job.php new file mode 100644 index 0000000..d89803d --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/Job.php @@ -0,0 +1,53 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner\Params; +use Espo\Core\Application\RunnerParameterized; +use Espo\Core\Job\JobManager; + +/** + * Runs a job by ID. A job record should exist in database. + */ +class Job implements RunnerParameterized +{ + use Cli; + use SetupSystemUser; + + public function __construct(private JobManager $jobManager) + {} + + public function run(Params $params): void + { + $id = $params->get('id'); + + $this->jobManager->runJobById($id); + } +} diff --git a/application/Espo/Core/ApplicationRunners/PortalClient.php b/application/Espo/Core/ApplicationRunners/PortalClient.php new file mode 100644 index 0000000..a553f3d --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/PortalClient.php @@ -0,0 +1,107 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Api\ErrorOutput; +use Espo\Core\Api\RequestWrapper; +use Espo\Core\Api\ResponseWrapper; +use Espo\Core\Application\Runner\Params; +use Espo\Core\Application\RunnerParameterized; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Portal\Application as PortalApplication; +use Espo\Core\Portal\ApplicationRunners\Client as PortalPortalClient; +use Espo\Core\Portal\Utils\Url; +use Espo\Core\Utils\ClientManager; +use Espo\Core\Utils\Config; + +use Slim\Factory\ServerRequestCreatorFactory; +use Slim\Psr7\Response; +use Slim\ResponseEmitter; + +use Exception; + +/** + * Runs a portal client. + */ +class PortalClient implements RunnerParameterized +{ + + public function __construct( + private ClientManager $clientManager, + private Config $config, + private ErrorOutput $errorOutput + ) {} + + /** + * @throws BadRequest + */ + public function run(Params $params): void + { + $id = $params->get('id') ?? + Url::detectPortalId() ?? + $this->config->get('defaultPortalId'); + + $basePath = $params->get('basePath') ?? $this->clientManager->getBasePath(); + + $requestWrapped = new RequestWrapper( + ServerRequestCreatorFactory::create()->createServerRequestFromGlobals() + ); + + $responseWrapped = new ResponseWrapper(new Response()); + + if ($requestWrapped->getMethod() !== 'GET') { + throw new BadRequest("Only GET request is allowed."); + } + + try { + if (!$id) { + throw new NotFound("Portal ID not detected."); + } + + $application = new PortalApplication($id); + } catch (Exception $e) { + $this->processError($requestWrapped, $responseWrapped, $e); + + return; + } + + $application->setClientBasePath($basePath); + + $application->run(PortalPortalClient::class); + } + + private function processError(RequestWrapper $request, ResponseWrapper $response, Exception $exception): void + { + $this->errorOutput->processWithBodyPrinting($request, $response, $exception); + + (new ResponseEmitter())->emit($response->toPsr7()); + } +} diff --git a/application/Espo/Core/ApplicationRunners/Preload.php b/application/Espo/Core/ApplicationRunners/Preload.php new file mode 100644 index 0000000..1bc76d2 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/Preload.php @@ -0,0 +1,85 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\Utils\Preload as PreloadUtil; + +use Throwable; + +/** + * Runs a preload. + * + * @see https://www.php.net/manual/en/opcache.preloading.php + */ +class Preload implements Runner +{ + use Cli; + + /** + * @throws Throwable + */ + public function run(): void + { + $preload = new PreloadUtil(); + + try { + $preload->process(); + } catch (Throwable $e) { + $this->processException($e); + + throw $e; + } + + $count = $preload->getCount(); + + echo "Success." . PHP_EOL; + echo "Files loaded: " . $count . "." . PHP_EOL; + } + + protected function processException(Throwable $e): void + { + echo "Error occurred." . PHP_EOL; + + $msg = $e->getMessage(); + + if ($msg) { + echo "Message: $msg" . PHP_EOL; + } + + $file = $e->getFile(); + + if ($file) { + echo "File: $file" . PHP_EOL; + } + + echo "Line: " . $e->getLine() . PHP_EOL; + } +} diff --git a/application/Espo/Core/ApplicationRunners/Rebuild.php b/application/Espo/Core/ApplicationRunners/Rebuild.php new file mode 100644 index 0000000..5de1d15 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/Rebuild.php @@ -0,0 +1,63 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\DataManager; +use Espo\Core\Utils\Log; +use Exception; + +/** + * Rebuilds an application. + */ +class Rebuild implements Runner +{ + use Cli; + + public function __construct(private DataManager $dataManager, private Log $log) + {} + + public function run(): void + { + try { + $this->dataManager->rebuild(); + } catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + + $this->log->error('Rebuild: ' . $e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'exception' => $e, + ]); + + exit(1); + } + } +} diff --git a/application/Espo/Core/ApplicationRunners/SetupSystemUser.php b/application/Espo/Core/ApplicationRunners/SetupSystemUser.php new file mode 100644 index 0000000..096ed73 --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/SetupSystemUser.php @@ -0,0 +1,38 @@ +. + * + * 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\ApplicationRunners; + +/** + * System user will be set up for an application. + */ +trait SetupSystemUser +{ + public static bool $setupSystemUser = true; +} diff --git a/application/Espo/Core/ApplicationRunners/WebSocket.php b/application/Espo/Core/ApplicationRunners/WebSocket.php new file mode 100644 index 0000000..4d27eae --- /dev/null +++ b/application/Espo/Core/ApplicationRunners/WebSocket.php @@ -0,0 +1,49 @@ +. + * + * 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\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\WebSocket\ServerStarter; + +/** + * Runs WebSocket. + */ +class WebSocket implements Runner +{ + use Cli; + + public function __construct(private ServerStarter $serverStarter) + {} + + public function run(): void + { + $this->serverStarter->start(); + } +} diff --git a/application/Espo/Core/ApplicationState.php b/application/Espo/Core/ApplicationState.php new file mode 100644 index 0000000..e557c46 --- /dev/null +++ b/application/Espo/Core/ApplicationState.php @@ -0,0 +1,150 @@ +. + * + * 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; + +use Espo\Entities\Portal as PortalEntity; +use Espo\Entities\User as UserEntity; + +use LogicException; + +/** + * Provides information about an application, current user, portal. + */ +class ApplicationState +{ + private const KEY_USER = 'user'; + private const KEY_PORTAL = 'portal'; + + public function __construct(private Container $container) + {} + + /** + * Whether an application is initialized as a portal. + */ + public function isPortal(): bool + { + return $this->container->has(self::KEY_PORTAL); + } + + /** + * Get a portal ID (if an application is portal). + */ + public function getPortalId(): string + { + if (!$this->isPortal()) { + throw new LogicException("Can't get portal ID for non-portal application."); + } + + return $this->getPortal()->getId(); + } + + /** + * Get a portal entity (if an application is portal). + */ + public function getPortal(): PortalEntity + { + if (!$this->isPortal()) { + throw new LogicException("Can't get portal for non-portal application."); + } + + /** @var PortalEntity */ + return $this->container->get(self::KEY_PORTAL); + } + + /** + * Whether any user is initialized. If not logged, it will also return TRUE, meaning the system used is used. + */ + public function hasUser(): bool + { + return $this->container->has(self::KEY_USER); + } + + /** + * Get a current logged user. If no auth is applied, then the system user will be returned. + */ + public function getUser(): UserEntity + { + if (!$this->hasUser()) { + throw new LogicException("User is not yet available."); + } + + /** @var UserEntity */ + return $this->container->get(self::KEY_USER); + } + + /** + * Get an ID of a current logged user. If no auth is applied, then the system user will be returned. + */ + public function getUserId(): string + { + return $this->getUser()->getId(); + } + + /** + * Whether a user is logged. + */ + public function isLogged(): bool + { + if (!$this->container->has(self::KEY_USER)) { + return false; + } + + if ($this->getUser()->isSystem()) { + return false; + } + + return true; + } + + /** + * Whether logged as an admin. + */ + public function isAdmin(): bool + { + if (!$this->isLogged()) { + return false; + } + + return $this->getUser()->isEffectiveAdmin(); + } + + + /** + * Whether logged as an API user. + */ + public function isApi(): bool + { + if (!$this->isLogged()) { + return false; + } + + return $this->getUser()->isApi(); + } +} diff --git a/application/Espo/Core/ApplicationUser.php b/application/Espo/Core/ApplicationUser.php new file mode 100644 index 0000000..e16d716 --- /dev/null +++ b/application/Espo/Core/ApplicationUser.php @@ -0,0 +1,89 @@ +. + * + * 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; + +use Espo\Core\Utils\SystemUser; +use Espo\Entities\User; +use Espo\Core\ORM\EntityManagerProxy; + +use Espo\ORM\Name\Attribute; +use RuntimeException; + +/** + * Setting a current user for the application. + */ +class ApplicationUser +{ + /** @deprecated As of v7.4. Different IDs may be used. Use Espo\Core\Utils\SystemUser. */ + public const SYSTEM_USER_ID = 'system'; + + public function __construct( + private Container $container, + private EntityManagerProxy $entityManagerProxy + ) {} + + /** + * Set up the system user as a current user. The system user is used when no user is logged in. + */ + public function setupSystemUser(): void + { + $user = $this->entityManagerProxy + ->getRDBRepository(User::ENTITY_TYPE) + ->select([ + Attribute::ID, + 'name', + 'userName', + 'type', + 'isActive', + 'firstName', + 'lastName', + Attribute::DELETED, + ]) + ->where(['userName' => SystemUser::NAME]) + ->findOne(); + + if (!$user) { + throw new RuntimeException("System user is not found."); + } + + $user->set('ipAddress', $_SERVER['REMOTE_ADDR'] ?? null); + $user->set('type', User::TYPE_SYSTEM); + + $this->container->set('user', $user); + } + + /** + * Set a current user. + */ + public function setUser(User $user): void + { + $this->container->set('user', $user); + } +} diff --git a/application/Espo/Core/Authentication/AuthToken/AuthToken.php b/application/Espo/Core/Authentication/AuthToken/AuthToken.php new file mode 100644 index 0000000..91c4cc4 --- /dev/null +++ b/application/Espo/Core/Authentication/AuthToken/AuthToken.php @@ -0,0 +1,68 @@ +. + * + * 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\AuthToken; + +/** + * An auth token record. + */ +interface AuthToken +{ + /** + * Get a token. + */ + public function getToken(): string; + + /** + * Get a user ID. + */ + public function getUserId(): string; + + /** + * Get a portal ID. If a token belongs to a specific portal. + */ + public function getPortalId(): ?string; + + /** + * Get a token secret. Secret is used as an additional security check. + */ + public function getSecret(): ?string; + + /** + * Whether a token is active. + */ + public function isActive(): bool; + + /** + * Get the password version. If the password version is not stored in token, then return NULL. + * + * @since 10.0.0 + */ + public function getPasswordVersion(): ?int; +} diff --git a/application/Espo/Core/Authentication/AuthToken/Data.php b/application/Espo/Core/Authentication/AuthToken/Data.php new file mode 100644 index 0000000..86e404f --- /dev/null +++ b/application/Espo/Core/Authentication/AuthToken/Data.php @@ -0,0 +1,120 @@ +. + * + * 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\AuthToken; + +use RuntimeException; +use SensitiveParameter; + +/** + * An auth token data. Used for auth token creation. + * + * Immutable. + */ +class Data +{ + private string $userId; + private ?string $portalId = null; + private ?string $ipAddress = null; + private bool $createSecret = false; + private ?int $passwordVersion = null; + + private function __construct() + {} + + /** + * A user ID. + */ + public function getUserId(): string + { + return $this->userId; + } + + /** + * A portal ID. + */ + public function getPortalId(): ?string + { + return $this->portalId; + } + + /** + * An ID address. + */ + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + /** + * A password version. + * + * @since 10.0.0 + */ + public function getPasswordVersion(): ?int + { + return $this->passwordVersion; + } + + /** + * To create a secret. + */ + public function toCreateSecret(): bool + { + return $this->createSecret; + } + + /** + * @param array{ + * userId: string, + * portalId?: ?string, + * passwordVersion?: ?int, + * ipAddress?: ?string, + * createSecret?: ?bool, + * } $data + */ + public static function create(#[SensitiveParameter] array $data): self + { + $obj = new self(); + + $userId = $data['userId'] ?? null; + + if (!$userId) { + throw new RuntimeException("No user ID."); + } + + $obj->userId = $userId; + $obj->portalId = $data['portalId'] ?? null; + $obj->ipAddress = $data['ipAddress'] ?? null; + $obj->createSecret = $data['createSecret'] ?? false; + $obj->passwordVersion = $data['passwordVersion'] ?? null; + + return $obj; + } +} diff --git a/application/Espo/Core/Authentication/AuthToken/EspoManager.php b/application/Espo/Core/Authentication/AuthToken/EspoManager.php new file mode 100644 index 0000000..6736019 --- /dev/null +++ b/application/Espo/Core/Authentication/AuthToken/EspoManager.php @@ -0,0 +1,186 @@ +. + * + * 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\AuthToken; + +use Espo\Core\Name\Field; +use Espo\ORM\EntityCollection; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Repository\RDBRepository; +use Espo\Entities\AuthToken as AuthTokenEntity; +use Random\RandomException; +use RuntimeException; + +/** + * A default auth token manager. Auth tokens are stored in database. + * Consider creating a custom implementation if you need to store auth tokens + * in another storage. E.g. a single Redis data store can be utilized with + * multiple Espo replicas (for scalability purposes). + * Defined at metadata > app > containerServices > authTokenManager. + * + * @implements Manager + */ +class EspoManager implements Manager +{ + /** @var RDBRepository */ + private RDBRepository $repository; + + private const int TOKEN_RANDOM_LENGTH = 16; + + public function __construct(EntityManager $entityManager) + { + $this->repository = $entityManager->getRDBRepositoryByClass(AuthTokenEntity::class); + } + + public function get(string $token): ?AuthToken + { + return $this->repository + ->select([ + Attribute::ID, + 'isActive', + 'token', + 'secret', + 'userId', + 'portalId', + 'hash', + AuthTokenEntity::FIELD_PASSWORD_VERSION, + Field::CREATED_AT, + 'lastAccess', + Field::MODIFIED_AT, + ]) + ->where(['token' => $token]) + ->findOne(); + } + + public function create(Data $data): AuthToken + { + $authToken = $this->repository->getNew(); + + $authToken + ->setUserId($data->getUserId()) + ->setPortalId($data->getPortalId()) + ->setPasswordVersion($data->getPasswordVersion()) + ->setIpAddress($data->getIpAddress()) + ->setToken($this->generateToken()) + ->setLastAccessNow(); + + if ($data->toCreateSecret()) { + $authToken->setSecret($this->generateToken()); + } + + $this->validate($authToken); + + $this->repository->save($authToken); + + return $authToken; + } + + /** + * @param AuthTokenEntity $authToken + */ + public function inactivate(AuthToken $authToken): void + { + $this->validateNotChanged($authToken); + + $authToken->setIsActive(false); + + $this->repository->save($authToken); + } + + public function renew(AuthToken $authToken): void + { + $this->validateNotChanged($authToken); + + if ($authToken->isNew()) { + throw new RuntimeException("Can renew only not new auth token."); + } + + $authToken->setLastAccessNow(); + + $this->repository->save($authToken); + } + + public function inactiveOther(AuthToken $authToken): void + { + foreach ($this->findConcurrent($authToken) as $other) { + $this->inactivate($other); + } + } + + private function validate(AuthToken $authToken): void + { + if (!$authToken->getToken()) { + throw new RuntimeException("Empty token."); + } + + if (!$authToken->getUserId()) { + throw new RuntimeException("Empty user ID."); + } + } + + private function validateNotChanged(AuthTokenEntity $authToken): void + { + if ( + $authToken->isAttributeChanged('token') || + $authToken->isAttributeChanged('secret') || + $authToken->isAttributeChanged('hash') || + $authToken->isAttributeChanged('userId') || + $authToken->isAttributeChanged('portalId') + ) { + throw new RuntimeException("Auth token was changed."); + } + } + + private function generateToken(): string + { + $length = self::TOKEN_RANDOM_LENGTH; + + try { + return bin2hex(random_bytes($length)); + } catch (RandomException $e) { + throw new RuntimeException(previous: $e); + } + } + + /** + * @return EntityCollection + */ + private function findConcurrent(AuthTokenEntity $authToken): EntityCollection + { + return $this->repository + ->select([Attribute::ID]) + ->where([ + AuthTokenEntity::ATTR_USER_ID => $authToken->getUserId(), + AuthTokenEntity::FIELD_IS_ACTIVE => true, + Attribute::ID . '!=' => $authToken->getId(), + ]) + ->find(); + } +} diff --git a/application/Espo/Core/Authentication/AuthToken/Manager.php b/application/Espo/Core/Authentication/AuthToken/Manager.php new file mode 100644 index 0000000..74c99b1 --- /dev/null +++ b/application/Espo/Core/Authentication/AuthToken/Manager.php @@ -0,0 +1,74 @@ +. + * + * 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\AuthToken; + +/** + * Fetches and stores auth tokens. + * + * @template TAuthToken of AuthToken = AuthToken + */ +interface Manager +{ + /** + * Get an auth token. If it does not exist, then returns NULL. + * + * @return ?TAuthToken + */ + public function get(string $token): ?AuthToken; + + /** + * Create an auth token and store it. + * + * @return TAuthToken + */ + public function create(Data $data): AuthToken; + + /** + * Make an auth token inactive (invalid). + * + * @param TAuthToken $authToken + */ + public function inactivate(AuthToken $authToken): void; + + /** + * Update a last access date. An implementation can be omitted to avoid a writing operation. + * + * @param TAuthToken $authToken + */ + public function renew(AuthToken $authToken): void; + + /** + * Inactivate concurrent auth tokens for the same user. + * + * @param TAuthToken $authToken + * @since 10.1.0 + */ + public function inactiveOther(AuthToken $authToken): void; +} diff --git a/application/Espo/Core/Authentication/Authentication.php b/application/Espo/Core/Authentication/Authentication.php new file mode 100644 index 0000000..978a714 --- /dev/null +++ b/application/Espo/Core/Authentication/Authentication.php @@ -0,0 +1,876 @@ +. + * + * 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; + +use Espo\Core\Authentication\Repository\UserRepository; +use Espo\Core\Authentication\TwoFactor\MethodProvider as TwoFactorMethodProvider; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Name\Field; +use Espo\Core\Utils\Language\LanguageProxy; +use Espo\ORM\Name\Attribute; +use Espo\Entities\Portal; +use Espo\Entities\User; +use Espo\Entities\AuthLogRecord; +use Espo\Entities\AuthToken as AuthTokenEntity; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Authentication\Logout\Params as LogoutParams; +use Espo\Core\Authentication\Util\MethodProvider; +use Espo\Core\Authentication\Result\FailReason; +use Espo\Core\Authentication\TwoFactor\LoginFactory as TwoFactorLoginFactory; +use Espo\Core\Authentication\AuthToken\Manager as AuthTokenManager; +use Espo\Core\Authentication\AuthToken\Data as AuthTokenData; +use Espo\Core\Authentication\AuthToken\AuthToken; +use Espo\Core\Authentication\Hook\Manager as HookManager; +use Espo\Core\Authentication\Login\Data as LoginData; +use Espo\Core\ApplicationUser; +use Espo\Core\ApplicationState; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Api\Util; +use Espo\Core\Utils\Log; +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Core\Exceptions\ServiceUnavailable; +use Espo\Core\Authentication\Repository\AuthLogRecordRepository; +use LogicException; +use RuntimeException; + +/** + * Handles authentication. The entry point of the auth process. + */ +class Authentication +{ + private const string LOGOUT_USERNAME = '**logout'; + + private const string HEADER_ANOTHER_USER = 'X-Another-User'; + private const string HEADER_LOGOUT_REDIRECT_URL = 'X-Logout-Redirect-Url'; + + private const string COOKIE_AUTH_TOKEN_SECRET = 'auth-token-secret'; + + public function __construct( + private ApplicationUser $applicationUser, + private ApplicationState $applicationState, + private ConfigDataProvider $configDataProvider, + private EntityManagerProxy $entityManager, + private LoginFactory $loginFactory, + private TwoFactorLoginFactory $twoFactorLoginFactory, + private AuthTokenManager $authTokenManager, + private HookManager $hookManager, + private Log $log, + private LogoutFactory $logoutFactory, + private MethodProvider $methodProvider, + private Util $util, + private LanguageProxy $language, + private TwoFactorMethodProvider $twoFactorMethodProvider, + private UserRepository $userRepository, + private AuthLogRecordRepository $authLogRecordRepository, + ) {} + + /** + * Process logging in. + * + * @throws ServiceUnavailable + * @throws Forbidden + */ + public function login(AuthenticationData $data, Request $request, Response $response): Result + { + $username = $data->getUsername(); + $password = $data->getPassword(); + $byTokenOnly = $data->byTokenOnly(); + + if ($failResult = $this->processMethodCheck($data, $request)) { + return $failResult; + } + + $this->processBeforeLoginHook($data, $request); + + if ($failResult = $this->processHasPasswordCheck($data)) { + return $failResult; + } + + [$authToken, $authTokenIsFound] = $this->getAuthToken($data->getMethod(), $password, $request); + + if ($authToken && !$this->processAuthTokenCheck($authToken)) { + return Result::fail(FailReason::DENIED); + } + + if ($data->getMethod() && $this->isByTokenAndUsername($request)) { + return Result::fail(FailReason::DISCREPANT_DATA); + } + + if ($failResult = $this->processAuthTokenNotFoundCheck($data, $request, $authToken)) { + return $failResult; + } + + if ($byTokenOnly) { + if ($authToken === null) { + throw new LogicException(); + } + + $username = $this->getUsernameByAuthToken($authToken); + + if (!$username) { + return $this->processFail(Result::fail(FailReason::USER_NOT_FOUND), $data, $request); + } + } + + $method = $data->getMethod() ?? $this->methodProvider->get(); + + $result = $this->processLogin( + method: $method, + username: $username, + password: $password, + authToken: $authToken, + request: $request, + ); + + $user = $result->getUser(); + + $authLogRecord = !$authTokenIsFound ? + $this->createAuthLogRecord($username, $user, $request, $method) : null; + + if ($result->isFail()) { + return $this->processFail($result, $data, $request, $response); + } + + if (!$user) { + // Supposed not to ever happen. + return $this->processFail(Result::fail(FailReason::USER_NOT_FOUND), $data, $request); + } + + if (!$user->isEffectiveAdmin() && $this->configDataProvider->isMaintenanceMode()) { + $this->throwMaintenanceModeException(); + } + + if (!$this->processUserCheck($user, $authLogRecord)) { + return $this->processFail(Result::fail(FailReason::DENIED), $data, $request); + } + + $this->prepareUser($user, $request); + + [$loggedUser, $anotherUserFailReason] = $this->getLoggedUser($request, $user); + + if (!$loggedUser) { + $anotherUserFailReason = $anotherUserFailReason ?? FailReason::ANOTHER_USER_NOT_FOUND; + + return $this->processFail(Result::fail($anotherUserFailReason), $data, $request); + } + + if ($this->toProcessTwoFactor($result, $authToken)) { + $result = $this->processTwoFactor($result, $request); + + if ($result->isFail()) { + return $this->processTwoFactorFail($result, $data, $request, $authLogRecord); + } + } + + if ($result->isSuccess()) { + $this->applicationUser->setUser($loggedUser); + } + + try { + $this->hookManager->processOnLogin($result, $data, $request); + } catch (Forbidden $e) { + $this->processForbidden($e, $authLogRecord); + } + + if (!$result->isSecondStepRequired() && $request->getHeader(HeaderKey::AUTHORIZATION)) { + $authToken = $this->processAuthTokenFinal( + authToken: $authToken, + authLogRecord: $authLogRecord, + user: $user, + loggedUser: $loggedUser, + request: $request, + response: $response, + ); + } + + $this->processAuthLogRecord($authLogRecord, $authToken, $loggedUser); + + if ($result->isSuccess()) { + return $this->processSuccess($result, $data, $request, $authTokenIsFound); + } + + if ($result->isSecondStepRequired()) { + return $this->processSecondStepRequired($result, $data, $request); + } + + return $result; + } + + private function processAuthTokenFinal( + ?AuthToken $authToken, + ?AuthLogRecord $authLogRecord, + User $user, + User $loggedUser, + Request $request, + Response $response, + ): AuthToken { + + if ($authToken) { + $this->authTokenManager->renew($authToken); + } + + if (!$authToken) { + $authToken = $this->createAuthToken($user, $request, $response); + } + + $authTokenId = null; + + if ($authToken instanceof AuthTokenEntity) { + $authTokenId = $authToken->hasId() ? $authToken->getId() : null; + } + + $loggedUser->set(User::FIELD_TOKEN, $authToken->getToken()); + $loggedUser->set(User::ATTR_AUTH_TOKEN_ID, $authTokenId); + + $authLogRecord?->setAuthTokenId($authTokenId); + + return $authToken; + } + + private function processAuthLogRecord( + ?AuthLogRecord $authLogRecord, + ?AuthToken $authToken, + User $loggedUser, + ): void { + + if ($authLogRecord) { + $this->entityManager->saveEntity($authLogRecord); + } + + if ( + !$authLogRecord && + $authToken instanceof AuthTokenEntity && + $authToken->hasId() + ) { + $authLogRecord = $this->authLogRecordRepository->findOneByAuthTokenId($authToken->getId(), [Attribute::ID]); + } + + if ($authLogRecord) { + $loggedUser->set(User::ATTR_AUTH_LOG_RECORD_ID, $authLogRecord->getId()); + } + } + + private function isPortal(): bool + { + return $this->applicationState->isPortal(); + } + + private function getPortal(): Portal + { + return $this->applicationState->getPortal(); + } + + private function processAuthTokenCheck(AuthToken $authToken): bool + { + if ($this->isPortal() && $authToken->getPortalId() !== $this->getPortal()->getId()) { + $this->log->info("Auth: Trying to log in to portal with a token not related to portal."); + + return false; + } + + if (!$this->isPortal() && $authToken->getPortalId()) { + $this->log->info("Auth: Trying to log in to crm with a token related to portal."); + + return false; + } + + return true; + } + + private function processUserCheck(User $user, ?AuthLogRecord $authLogRecord): bool + { + if (!$user->isActive()) { + $this->log->info("Auth: Trying to log in as user '{username}' which is not active.", [ + 'username' => $user->getUserName(), + ]); + + $this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_INACTIVE_USER); + + return false; + } + + if ($user->isSystem()) { + $this->log->info("Auth: Trying to log in to crm as a system user '{username}'.", [ + 'username' => $user->getUserName(), + ]); + + $this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_IS_SYSTEM_USER); + + return false; + } + + if (!$user->isAdmin() && !$this->isPortal() && $user->isPortal()) { + $this->log->info("Auth: Trying to log in to crm as a portal user '{username}'.", [ + 'username' => $user->getUserName(), + ]); + + $this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_IS_PORTAL_USER); + + return false; + } + + if ($this->isPortal() && !$user->isPortal()) { + $this->log->info("Auth: Trying to log in to portal as user '{username}' which is not portal user.", [ + 'username' => $user->getUserName(), + ]); + + $this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_IS_NOT_PORTAL_USER); + + return false; + } + + if ($this->isPortal()) { + $isUserInPortal = $user->getPortals()->hasId($this->getPortal()->getId()); + + if (!$isUserInPortal) { + $message = "Auth: Trying to log in to portal as user '{username}' " . + "which is portal user but does not belong to portal."; + + $this->log->info($message, ['username' => $user->getUserName()]); + + $this->logDenied($authLogRecord, AuthLogRecord::DENIAL_REASON_USER_IS_NOT_IN_PORTAL); + + return false; + } + } + + return true; + } + + private function processTwoFactor(Result $result, Request $request): Result + { + $user = $result->getUser(); + + if (!$user) { + throw new RuntimeException("No user."); + } + + $method = $this->twoFactorMethodProvider->get($user->getId()); + + if (!$method) { + return $result; + } + + $login = $this->twoFactorLoginFactory->create($method); + + return $login->login($result, $request); + } + + private function createAuthToken(User $user, Request $request, Response $response): AuthToken + { + $createSecret = + $request->getHeader(HeaderKey::CREATE_TOKEN_SECRET) === 'true' && + !$this->configDataProvider->isAuthTokenSecretDisabled(); + + $ipAddress = $this->util->obtainIpFromRequest($request); + + $authTokenData = AuthTokenData::create([ + 'ipAddress' => $ipAddress, + 'userId' => $user->getId(), + 'passwordVersion' => $user->getPasswordVersion(), + 'portalId' => $this->isPortal() ? $this->getPortal()->getId() : null, + 'createSecret' => $createSecret, + ]); + + $authToken = $this->authTokenManager->create($authTokenData); + + if ($createSecret) { + $this->setSecretInCookie($authToken->getSecret(), $response, $request); + } + + if ($this->configDataProvider->preventConcurrentAuthToken()) { + $this->authTokenManager->inactiveOther($authToken); + } + + return $authToken; + } + + /** + * Destroy an auth token. + * + * @param string $token A token to destroy. + * @param Request $request A request. + * @param Response $response A response. + * @throws Forbidden + * @throws NotFound + */ + public function destroyAuthToken(string $token, Request $request, Response $response): void + { + $authToken = $this->authTokenManager->get($token); + + if (!$authToken) { + throw new NotFound("Auth token not found."); + } + + if (!$this->applicationState->hasUser()) { + throw new LogicException("No logged user."); + } + + $user = $this->applicationState->getUser(); + + $this->authTokenManager->inactivate($authToken); + + if ($authToken->getSecret()) { + $sentSecret = $request->getCookieParam(self::COOKIE_AUTH_TOKEN_SECRET); + + if ( + // Still need the ability to destroy auth tokens of another users + // for login-as-another-user feature. + $authToken->getUserId() !== $user->getId() && + $sentSecret !== $authToken->getSecret() + ) { + throw new Forbidden("Can't destroy auth token."); + } + + if ($sentSecret === $authToken->getSecret()) { + $this->setSecretInCookie(null, $response); + } + } + + $method = $this->methodProvider->get(); + + if (!$this->logoutFactory->isCreatable($method)) { + return; + } + + $result = $this->logoutFactory + ->create($method) + ->logout($authToken, LogoutParams::create()); + + $redirectUrl = $result->getRedirectUrl(); + + if ($redirectUrl) { + $response->setHeader(self::HEADER_LOGOUT_REDIRECT_URL, $redirectUrl); + } + } + + private function createAuthLogRecord( + ?string $username, + ?User $user, + Request $request, + ?string $method = null, + ): ?AuthLogRecord { + + if ($username === self::LOGOUT_USERNAME) { + return null; + } + + if ($this->configDataProvider->isAuthLogDisabled()) { + return null; + } + + /** @var AuthLogRecord $authLogRecord */ + $authLogRecord = $this->entityManager->getNewEntity(AuthLogRecord::ENTITY_TYPE); + + $requestUrl = + $request->getUri()->getScheme() . '://' . + $request->getUri()->getHost() . + $request->getUri()->getPath(); + + if (!$username && $user) { + $username = $user->getUserName(); + } + + $authLogRecord + ->setUsername($username) + ->setIpAddress($this->util->obtainIpFromRequest($request)) + ->setRequestTime($request->getServerParam('REQUEST_TIME_FLOAT')) + ->setRequestMethod($request->getMethod()) + ->setRequestUrl($requestUrl) + ->setAuthenticationMethod($method) + ->setPortalId($this->isPortal() ? $this->getPortal()->getId() : null); + + if ($user && $user->isApi() && $this->configDataProvider->isApiUserAuthLogDisabled()) { + return null; + } + + if ($user) { + $authLogRecord->setUserId($user->hasId() ? $user->getId() : null); + + return $authLogRecord; + } + + $authLogRecord + ->setIsDenied() + ->setDenialReason(AuthLogRecord::DENIAL_REASON_CREDENTIALS); + + $this->entityManager->saveEntity($authLogRecord); + + return $authLogRecord; + } + + private function logDenied(?AuthLogRecord $authLogRecord, string $denialReason): void + { + if (!$authLogRecord) { + return; + } + + $authLogRecord + ->setIsDenied() + ->setDenialReason($denialReason); + + $this->entityManager->saveEntity($authLogRecord); + } + + private function setSecretInCookie(?string $secret, Response $response, ?Request $request = null): void + { + $time = $secret ? strtotime('+1000 days') : 1; + + $value = $secret ?? 'deleted'; + + $headerValue = + self::COOKIE_AUTH_TOKEN_SECRET . '=' . urlencode($value) . + '; path=/' . + '; expires=' . gmdate('D, d M Y H:i:s T', $time) . + '; HttpOnly' . + '; SameSite=Lax'; + + if ($request && self::isSecureRequest($request)) { + $headerValue .= "; Secure"; + } + + $response->addHeader('Set-Cookie', $headerValue); + } + + private static function isSecureRequest(Request $request): bool + { + $https = $request->getServerParam('HTTPS'); + + if ($https === 'on') { + return true; + } + + $scheme = $request->getServerParam('REQUEST_SCHEME'); + + if ($scheme === 'https') { + return true; + } + + $forwardedProto = $request->getServerParam('HTTP_X_FORWARDED_PROTO'); + + if ($forwardedProto === 'https') { + return true; + } + + return false; + } + + private function processFail( + Result $result, + AuthenticationData $data, + Request $request, + ?Response $response = null, + ): Result { + + $this->hookManager->processOnFail($result, $data, $request); + + if ($result->getResponse() && $response) { + $response->applyPsr7($result->getResponse()->toPsr7()); + } + + return $result; + } + + private function processSuccess( + Result $result, + AuthenticationData $data, + Request $request, + bool $byToken, + ): Result { + + if ($byToken) { + $this->hookManager->processOnSuccessByToken($result, $data, $request); + + return $result; + } + + $this->hookManager->processOnSuccess($result, $data, $request); + + return $result; + } + + private function processSecondStepRequired( + Result $result, + AuthenticationData $data, + Request $request, + ): Result { + + $this->hookManager->processOnSecondStepRequired($result, $data, $request); + + return $result; + } + + private function getUsernameByAuthToken(AuthToken $authToken): ?string + { + $user = $this->userRepository->findOneById($authToken->getUserId(), [User::FIELD_USER_NAME]); + + return $user?->getUserName(); + } + + /** + * @return array{?User, (FailReason::*)|null} + */ + private function getLoggedUser(Request $request, User $user): array + { + $username = $request->getHeader(self::HEADER_ANOTHER_USER); + + if (!$username) { + return [$user, null]; + } + + if ($this->configDataProvider->isAnotherUserDisabled()) { + return [null, FailReason::ANOTHER_USER_NOT_ALLOWED]; + } + + // Important check. + if (!$user->isEffectiveAdmin()) { + return [null, FailReason::ANOTHER_USER_NOT_ALLOWED]; + } + + $loggedUser = $this->userRepository->findOneByUsername($username); + + if (!$loggedUser) { + return [null, FailReason::ANOTHER_USER_NOT_FOUND]; + } + + if (!$loggedUser->isRegular()) { + return [null, FailReason::ANOTHER_USER_NOT_ALLOWED]; + } + + $loggedUser->loadLinkMultipleField(Field::TEAMS); + + return [$loggedUser, null]; + } + + private function prepareUser(User $user, Request $request): void + { + if ($this->isPortal()) { + $user->set(User::ATTR_PORTAL_ID, $this->getPortal()->getId()); + } + + if (!$this->isPortal()) { + $user->loadLinkMultipleField(Field::TEAMS); + } + + $user->set(User::FIELD_IP_ADDRESS, $this->util->obtainIpFromRequest($request)); + } + + /** + * @throws Forbidden + */ + private function processForbidden(Forbidden $exception, ?AuthLogRecord $authLogRecord = null): never + { + $this->log->warning('Auth: Forbidden. {message}', [ + 'message' => $exception->getMessage(), + 'exception' => $exception, + ]); + + if ($authLogRecord) { + $authLogRecord + ->setIsDenied() + ->setDenialReason(AuthLogRecord::DENIAL_REASON_FORBIDDEN); + + $this->entityManager->saveEntity($authLogRecord); + } + + throw new Forbidden(); + } + + private function processTwoFactorFail( + Result $result, + AuthenticationData $data, + Request $request, + ?AuthLogRecord $authLogRecord + ): Result { + + if ($authLogRecord) { + $authLogRecord + ->setIsDenied() + ->setDenialReason(AuthLogRecord::DENIAL_REASON_WRONG_CODE); + + $this->entityManager->saveEntity($authLogRecord); + } + + return $this->processFail($result, $data, $request); + } + + private function getTwoFactorEnabled(): bool + { + if ($this->isPortal() && !$this->configDataProvider->isTwoFactorInPortalEnabled()) { + return false; + } + + return $this->configDataProvider->isTwoFactorEnabled(); + } + + /** + * @throws Forbidden + * @throws ServiceUnavailable + */ + private function processBeforeLoginHook(AuthenticationData $data, Request $request): void + { + try { + $this->hookManager->processBeforeLogin($data, $request); + } catch (Forbidden $e) { + $this->processForbidden($e); + } + } + + private function toProcessTwoFactor(Result $result, ?AuthToken $authToken): bool + { + return + !$result->bypassSecondStep() && + !$result->isSecondStepRequired() && + !$authToken && + $this->getTwoFactorEnabled(); + } + + /** + * @throws ServiceUnavailable + */ + private function throwMaintenanceModeException(): never + { + throw ServiceUnavailable::createWithBody( + "Application is in maintenance mode.", + Body::create() + ->withMessage($this->language->translateLabel('maintenanceModeError', 'messages')) + ); + } + + private function processLogin( + string $method, + ?string $username, + ?string $password, + ?AuthToken $authToken, + Request $request, + ): Result { + + $login = $this->loginFactory->create($method, $this->isPortal()); + + $loginData = LoginData::createBuilder() + ->setUsername($username) + ->setPassword($password) + ->setAuthToken($authToken) + ->build(); + + return $login->login($loginData, $request); + } + + private function processMethodCheck(AuthenticationData $data, Request $request): ?Result + { + $method = $data->getMethod(); + + if ($method && !$this->configDataProvider->authenticationMethodIsApi($method)) { + $this->log->warning("Auth: Trying to use not allowed authentication method '{method}'.", [ + 'method' => $method, + ]); + + return $this->processFail(Result::fail(FailReason::METHOD_NOT_ALLOWED), $data, $request); + } + + return null; + } + + private function processHasPasswordCheck(AuthenticationData $data): ?Result + { + $password = $data->getPassword(); + $method = $data->getMethod(); + + if (!$method && $password === null) { + $this->log->error("Auth: Trying to log in w/o password."); + + return Result::fail(FailReason::NO_PASSWORD); + } + + return null; + } + + /** + * @return array{?AuthToken, bool} + */ + private function getAuthToken(?string $method, ?string $password, Request $request): array + { + $authToken = null; + + if (!$method) { + if ($password === null) { + throw new LogicException(); + } + + $authToken = $this->authTokenManager->get($password); + } + + if ($authToken?->getSecret()) { + $sentSecret = $request->getCookieParam(self::COOKIE_AUTH_TOKEN_SECRET); + + if (!$sentSecret || !hash_equals($authToken->getSecret(), $sentSecret)) { + $authToken = null; + } + } + + $authTokenIsFound = $authToken !== null; + + if ($authToken && !$authToken->isActive()) { + $authToken = null; + } + + return [$authToken, $authTokenIsFound]; + } + + private function processAuthTokenNotFoundCheck( + AuthenticationData $data, + Request $request, + ?AuthToken $authToken, + ): ?Result { + + if ($authToken) { + return null; + } + + if ($this->isByTokenAndUsername($request) || $data->byTokenOnly()) { + if ($data->getUsername()) { + $this->log->info("Auth: Trying to log in as user '{username}' by token but token is not found.", [ + 'username' => $data->getUsername(), + ]); + } + + return $this->processFail(Result::fail(FailReason::TOKEN_NOT_FOUND), $data, $request); + } + + return null; + } + + private function isByTokenAndUsername(Request $request): bool + { + return $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true'; + } +} diff --git a/application/Espo/Core/Authentication/AuthenticationData.php b/application/Espo/Core/Authentication/AuthenticationData.php new file mode 100644 index 0000000..87ea7c4 --- /dev/null +++ b/application/Espo/Core/Authentication/AuthenticationData.php @@ -0,0 +1,115 @@ +. + * + * 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; + +use SensitiveParameter; + +/** + * Immutable. + */ +class AuthenticationData +{ + private bool $byTokenOnly = false; + + public function __construct( + private ?string $username = null, + private ?string $password = null, + private ?string $method = null + ) {} + + public static function create(): self + { + return new self(); + } + + /** + * A username. + */ + public function getUsername(): ?string + { + return $this->username; + } + + /** + * A password or auth-token. + */ + public function getPassword(): ?string + { + return $this->password; + } + + /** + * A method. + */ + public function getMethod(): ?string + { + return $this->method; + } + + /** + * Authenticate by auth-token only. No username check. + */ + public function byTokenOnly(): bool + { + return $this->byTokenOnly; + } + + public function withUsername(?string $username): self + { + $obj = clone $this; + $obj->username = $username; + + return $obj; + } + + public function withPassword(#[SensitiveParameter] ?string $password): self + { + $obj = clone $this; + $obj->password = $password; + + return $obj; + } + + public function withMethod(?string $method): self + { + $obj = clone $this; + $obj->method = $method; + + return $obj; + } + + public function withByTokenOnly(bool $byTokenOnly): self + { + $obj = clone $this; + $obj->byTokenOnly = $byTokenOnly; + + return $obj; + } +} diff --git a/application/Espo/Core/Authentication/AuthenticationFactory.php b/application/Espo/Core/Authentication/AuthenticationFactory.php new file mode 100644 index 0000000..704aca5 --- /dev/null +++ b/application/Espo/Core/Authentication/AuthenticationFactory.php @@ -0,0 +1,43 @@ +. + * + * 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; + +use Espo\Core\InjectableFactory; + +class AuthenticationFactory +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function create(): Authentication + { + return $this->injectableFactory->create(Authentication::class); + } +} diff --git a/application/Espo/Core/Authentication/ConfigDataProvider.php b/application/Espo/Core/Authentication/ConfigDataProvider.php new file mode 100644 index 0000000..ac2506f --- /dev/null +++ b/application/Espo/Core/Authentication/ConfigDataProvider.php @@ -0,0 +1,219 @@ +. + * + * 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; + +use Espo\Core\Authentication\Login\MetadataParams; +use Espo\Core\Authentication\Logins\Espo; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; + +class ConfigDataProvider +{ + private const string FAILED_ATTEMPTS_PERIOD = '60 seconds'; + private const string FAILED_CODE_ATTEMPTS_PERIOD = '5 minutes'; + private const int MAX_FAILED_ATTEMPT_NUMBER = 10; + private const int MAX_USERNAME_FAILED_ATTEMPT_NUMBER = 30; + private const string USERNAME_FAILED_ATTEMPTS_PERIOD = '60 seconds'; + private const int USERNAME_FAILED_ATTEMPT_DELAY = 2; + + public function __construct( + private Config $config, + private Metadata $metadata, + private Config\SystemConfig $systemConfig, + ) {} + + /** + * A period for max failed attempts checking. + */ + public function getFailedAttemptsPeriod(): string + { + return $this->config->get('authFailedAttemptsPeriod', self::FAILED_ATTEMPTS_PERIOD); + } + + /** + * A period for max failed 2FA code attempts checking. + */ + public function getFailedCodeAttemptsPeriod(): string + { + return $this->config->get('authFailedCodeAttemptsPeriod', self::FAILED_CODE_ATTEMPTS_PERIOD); + } + + /** + * Max failed log in attempts. + */ + public function getMaxFailedAttemptNumber(): int + { + return $this->config->get('authMaxFailedAttemptNumber', self::MAX_FAILED_ATTEMPT_NUMBER); + } + + /** + * Max failed log in attempts for a specific username regardless of the IP address. + */ + public function getMaxUsernameFailedAttemptNumber(): int + { + return $this->config->get('authMaxUsernameFailedAttemptNumber', self::MAX_USERNAME_FAILED_ATTEMPT_NUMBER); + } + + public function isUsernameFailedAttemptsLimitEnabled(): bool + { + return (bool) $this->config->get('authUsernameFailedAttemptsLimitEnabled'); + } + + public function getUsernameFailedAttemptsPeriod(): string + { + return $this->config->get('authUsernameFailedAttemptsPeriod', self::USERNAME_FAILED_ATTEMPTS_PERIOD); + } + + public function getUsernameFailedAttemptsDelay(): int + { + return $this->config->get('authUsernameFailedAttemptsDelay', self::USERNAME_FAILED_ATTEMPT_DELAY); + } + + /** + * Auth token secret won't be created. Can be reasonable for a custom AuthTokenManager implementation. + */ + public function isAuthTokenSecretDisabled(): bool + { + return (bool) $this->config->get('authTokenSecretDisabled'); + } + + /** + * A maintenance mode. Only admin can log in. + */ + public function isMaintenanceMode(): bool + { + return $this->systemConfig->isMaintenanceMode(); + } + + /** + * Whether 2FA is enabled. + */ + public function isTwoFactorEnabled(): bool + { + return (bool) $this->config->get('auth2FA'); + } + + /** + * Whether 2FA is enabled in portals. + * + * @since 10.0.4 + */ + public function isTwoFactorInPortalEnabled(): bool + { + return (bool) $this->config->get('auth2FAInPortal'); + } + + /** + * Allowed methods of 2FA. + * + * @return array + */ + public function getTwoFactorMethodList(): array + { + return $this->config->get('auth2FAMethodList') ?? []; + } + + /** + * A user won't be able to have multiple active auth tokens simultaneously. + */ + public function preventConcurrentAuthToken(): bool + { + return (bool) $this->config->get('authTokenPreventConcurrent'); + } + + /** + * A default authentication method. + */ + public function getDefaultAuthenticationMethod(): string + { + return $this->config->get('authenticationMethod', Espo::NAME); + } + + /** + * Whether an authentication method can be defined by request itself (in a header). + */ + public function authenticationMethodIsApi(string $authenticationMethod): bool + { + return (bool) $this->metadata->get(['authenticationMethods', $authenticationMethod, 'api']); + } + + public function isAnotherUserDisabled(): bool + { + return (bool) $this->config->get('authAnotherUserDisabled'); + } + + public function isAuthLogDisabled(): bool + { + return (bool) $this->config->get('authLogDisabled'); + } + + public function isApiUserAuthLogDisabled(): bool + { + return (bool) $this->config->get('authApiUserLogDisabled'); + } + + /** + * @return MetadataParams[] + */ + public function getLoginMetadataParamsList(): array + { + $list = []; + + /** @var array> $data */ + $data = $this->metadata->get(['authenticationMethods']) ?? []; + + foreach ($data as $method => $item) { + $list[] = MetadataParams::fromRaw($method, $item); + } + + return $list; + } + + public function ipAddressCheck(): bool + { + return (bool) $this->config->get('authIpAddressCheck'); + } + + /** + * @return string[] + */ + public function getIpAddressWhitelist(): array + { + return $this->config->get('authIpAddressWhitelist') ?? []; + } + + /** + * @return string[] + */ + public function getIpAddressCheckExcludedUserIdList(): array + { + return $this->config->get('authIpAddressCheckExcludedUsersIds') ?? []; + } +} diff --git a/application/Espo/Core/Authentication/HeaderKey.php b/application/Espo/Core/Authentication/HeaderKey.php new file mode 100644 index 0000000..1f93910 --- /dev/null +++ b/application/Espo/Core/Authentication/HeaderKey.php @@ -0,0 +1,38 @@ +. + * + * 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; + +class HeaderKey +{ + public const string AUTHORIZATION_BY_TOKEN = 'Espo-Authorization-By-Token'; + public const string AUTHORIZATION_CODE = 'Espo-Authorization-Code'; + public const string AUTHORIZATION = 'Espo-Authorization'; + public const string CREATE_TOKEN_SECRET = 'Espo-Authorization-Create-Token-Secret'; +} diff --git a/application/Espo/Core/Authentication/Helper/UserFinder.php b/application/Espo/Core/Authentication/Helper/UserFinder.php new file mode 100644 index 0000000..db915bb --- /dev/null +++ b/application/Espo/Core/Authentication/Helper/UserFinder.php @@ -0,0 +1,104 @@ +. + * + * 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\Helper; + +use Espo\Core\Authentication\Logins\ApiKey; +use Espo\Core\Authentication\Logins\Hmac; +use Espo\ORM\EntityManager; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; + +/** + * @internal + */ +class UserFinder +{ + /** @var string[] */ + private const array FORBIDDEN_USER_TYPE_LIST = [ + User::TYPE_API, + User::TYPE_SYSTEM, + ]; + + public function __construct(private EntityManager $entityManager) + {} + + public function find(string $username): ?User + { + return $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + User::FIELD_USER_NAME => $username, + User::FIELD_TYPE . '!=' => self::FORBIDDEN_USER_TYPE_LIST, + ]) + ->findOne(); + } + + public function findByAuthTokenData(string $username, string $id, ?int $passwordVersion): ?User + { + $where = [ + User::FIELD_USER_NAME => $username, + Attribute::ID => $id, + User::FIELD_TYPE . '!=' => self::FORBIDDEN_USER_TYPE_LIST, + ]; + + if ($passwordVersion !== null) { + $where[User::FIELD_PASSWORD_VERSION] = $passwordVersion; + } + + return $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where($where) + ->findOne(); + } + + public function findApiHmac(string $apiKey): ?User + { + return $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + User::FIELD_TYPE => User::TYPE_API, + User::FIELD_API_KEY => $apiKey, + User::FIELD_AUTH_METHOD => Hmac::NAME, + ]) + ->findOne(); + } + + public function findApiApiKey(string $apiKey): ?User + { + return $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + User::FIELD_TYPE => User::TYPE_API, + User::FIELD_API_KEY => $apiKey, + User::FIELD_AUTH_METHOD => ApiKey::NAME, + ]) + ->findOne(); + } +} diff --git a/application/Espo/Core/Authentication/Hook/BeforeLogin.php b/application/Espo/Core/Authentication/Hook/BeforeLogin.php new file mode 100644 index 0000000..66850c2 --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/BeforeLogin.php @@ -0,0 +1,47 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Hook; + +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Api\Request; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\ServiceUnavailable; + +/** + * Before logging in, before credentials are checked. + */ +interface BeforeLogin +{ + /** + * @throws Forbidden + * @throws ServiceUnavailable + */ + public function process(AuthenticationData $data, Request $request): void; +} diff --git a/application/Espo/Core/Authentication/Hook/Hooks/FailedAttemptsLimit.php b/application/Espo/Core/Authentication/Hook/Hooks/FailedAttemptsLimit.php new file mode 100644 index 0000000..3b70ae1 --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/Hooks/FailedAttemptsLimit.php @@ -0,0 +1,113 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Hook\Hooks; + +use Espo\Core\Api\Util; +use Espo\Core\Authentication\HeaderKey; +use Espo\Core\Authentication\Hook\BeforeLogin; +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Api\Request; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Authentication\ConfigDataProvider; +use Espo\ORM\EntityManager; +use Espo\Entities\AuthLogRecord; + +use DateTime; +use Espo\ORM\Name\Attribute; +use Exception; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class FailedAttemptsLimit implements BeforeLogin +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + private EntityManager $entityManager, + private Util $util + ) {} + + /** + * @throws Forbidden + */ + public function process(AuthenticationData $data, Request $request): void + { + $isByTokenOnly = !$data->getMethod() && $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true'; + + if ($isByTokenOnly || $this->configDataProvider->isAuthLogDisabled()) { + return; + } + + $failedAttemptsPeriod = $this->configDataProvider->getFailedAttemptsPeriod(); + + $ipAddress = $this->util->obtainIpFromRequest($request); + + $where = [ + 'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->format('U'), + 'isDenied' => true, + 'ipAddress' => $ipAddress, + ]; + + $wasFailed = (bool) $this->entityManager + ->getRDBRepository(AuthLogRecord::ENTITY_TYPE) + ->select([Attribute::ID]) + ->where($where) + ->findOne(); + + if (!$wasFailed) { + return; + } + + $failAttemptCount = $this->entityManager + ->getRDBRepository(AuthLogRecord::ENTITY_TYPE) + ->where($where) + ->count(); + + if ($failAttemptCount <= $this->configDataProvider->getMaxFailedAttemptNumber()) { + return; + } + + throw new Forbidden("Max failed login attempts exceeded for IP address $ipAddress."); + } + + private function getTimeFrom(Request $request, string $failedAttemptsPeriod): DateTime + { + $requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT')); + + try { + $requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); + } + + return $requestTimeFrom; + } +} diff --git a/application/Espo/Core/Authentication/Hook/Hooks/FailedCodeAttemptsLimit.php b/application/Espo/Core/Authentication/Hook/Hooks/FailedCodeAttemptsLimit.php new file mode 100644 index 0000000..d01a09b --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/Hooks/FailedCodeAttemptsLimit.php @@ -0,0 +1,118 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Hook\Hooks; + +use Espo\Core\Api\Request; +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Authentication\ConfigDataProvider; +use Espo\Core\Authentication\Hook\BeforeLogin; +use Espo\Core\Exceptions\Forbidden; +use Espo\Entities\AuthLogRecord; +use Espo\ORM\EntityManager; + +use DateTime; +use Espo\ORM\Name\Attribute; +use Exception; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class FailedCodeAttemptsLimit implements BeforeLogin +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + private EntityManager $entityManager, + ) {} + + /** + * @throws Forbidden + */ + public function process(AuthenticationData $data, Request $request): void + { + if ( + $request->getHeader('Espo-Authorization-Code') === null || + $this->configDataProvider->isAuthLogDisabled() + ) { + return; + } + + $isByTokenOnly = !$data->getMethod() && $request->getHeader('Espo-Authorization-By-Token') === 'true'; + + if ($isByTokenOnly) { + return; + } + + $failedAttemptsPeriod = $this->configDataProvider->getFailedCodeAttemptsPeriod(); + + $where = [ + 'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->format('U'), + 'isDenied' => true, + 'username' => $data->getUsername(), + 'denialReason' => AuthLogRecord::DENIAL_REASON_WRONG_CODE, + ]; + + $wasFailed = (bool) $this->entityManager + ->getRDBRepository(AuthLogRecord::ENTITY_TYPE) + ->select([Attribute::ID]) + ->where($where) + ->findOne(); + + if (!$wasFailed) { + return; + } + + $failAttemptCount = $this->entityManager + ->getRDBRepository(AuthLogRecord::ENTITY_TYPE) + ->where($where) + ->count(); + + if ($failAttemptCount <= $this->configDataProvider->getMaxFailedAttemptNumber()) { + return; + } + + $username = $data->getUsername() ?? ''; + + throw new Forbidden("Max failed 2FA login attempts exceeded for username '$username'."); + } + + private function getTimeFrom(Request $request, string $failedAttemptsPeriod): DateTime + { + $requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT')); + + try { + $requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); + } + + return $requestTimeFrom; + } +} diff --git a/application/Espo/Core/Authentication/Hook/Hooks/IpAddressWhitelist.php b/application/Espo/Core/Authentication/Hook/Hooks/IpAddressWhitelist.php new file mode 100644 index 0000000..c6a3b06 --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/Hooks/IpAddressWhitelist.php @@ -0,0 +1,87 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Hook\Hooks; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Util; +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Authentication\ConfigDataProvider; +use Espo\Core\Authentication\Hook\OnLogin; +use Espo\Core\Authentication\Result; +use Espo\Core\Authentication\Util\IpAddressUtil; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Utils\Config; + +class IpAddressWhitelist implements OnLogin +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + private Util $util, + private Config $config, + private IpAddressUtil $ipAddressUtil + ) {} + + public function process(Result $result, AuthenticationData $data, Request $request): void + { + if (!$this->configDataProvider->ipAddressCheck()) { + return; + } + + $ipAddress = $this->util->obtainIpFromRequest($request); + + if ( + $ipAddress && + $this->ipAddressUtil->isInWhitelist($ipAddress, $this->configDataProvider->getIpAddressWhitelist()) + ) { + return; + } + + $user = $result->getUser(); + + if ($user && $user->isPortal()) { + return; + } + + if ($user && $user->isSuperAdmin() && $this->config->get('restrictedMode')) { + return; + } + + if ( + $user && + in_array($user->getId(), $this->configDataProvider->getIpAddressCheckExcludedUserIdList()) + ) { + return; + } + + $username = $user ? $user->getUserName() : '?'; + + throw new Forbidden("Not allowed IP address $ipAddress, user: $username."); + } +} diff --git a/application/Espo/Core/Authentication/Hook/Hooks/UsernameFailedAttemptsLimit.php b/application/Espo/Core/Authentication/Hook/Hooks/UsernameFailedAttemptsLimit.php new file mode 100644 index 0000000..8ad84c3 --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/Hooks/UsernameFailedAttemptsLimit.php @@ -0,0 +1,130 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Hook\Hooks; + +use DateTime; +use Espo\Core\Api\Request; +use Espo\Core\Api\Util; +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Authentication\ConfigDataProvider; +use Espo\Core\Authentication\HeaderKey; +use Espo\Core\Authentication\Hook\BeforeLogin; +use Espo\Core\Authentication\Util\DelayUtil; +use Espo\Entities\AuthLogRecord; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Exception; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class UsernameFailedAttemptsLimit implements BeforeLogin +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + private EntityManager $entityManager, + private Util $util, + private DelayUtil $delayUtil, + ) {} + + public function process(AuthenticationData $data, Request $request): void + { + $isByTokenOnly = !$data->getMethod() && $request->getHeader(HeaderKey::AUTHORIZATION_BY_TOKEN) === 'true'; + + if ( + $isByTokenOnly || + $this->configDataProvider->isAuthLogDisabled() || + !$this->configDataProvider->isUsernameFailedAttemptsLimitEnabled() || + $data->getUsername() === null + ) { + return; + } + + $failedAttemptsPeriod = $this->configDataProvider->getUsernameFailedAttemptsPeriod(); + $delay = $this->configDataProvider->getUsernameFailedAttemptsDelay(); + + $ipAddress = $this->util->obtainIpFromRequest($request); + + $repo = $this->entityManager->getRDBRepositoryByClass(AuthLogRecord::class); + + $where = [ + 'username' => $data->getUsername(), + 'requestTime>' => $this->getTimeFrom($request, $failedAttemptsPeriod)->format('U'), + 'isDenied' => true, + ]; + + $wasFailed = (bool) $repo + ->where($where) + ->findOne(); + + if (!$wasFailed) { + return; + } + + $failAttemptCount = $repo + ->where($where) + ->count(); + + if ($failAttemptCount < $this->configDataProvider->getMaxUsernameFailedAttemptNumber()) { + return; + } + + if ( + // Prevent blocking for an IP address that has been logged in before. + $ipAddress !== null && + $repo + ->select([Attribute::ID]) + ->where([ + 'username' => $data->getUsername(), + 'ipAddress' => $ipAddress, + 'isDenied' => false, + ]) + ->findOne() + ) { + return; + } + + $this->delayUtil->delay($delay * 1000); + } + + private function getTimeFrom(Request $request, string $failedAttemptsPeriod): DateTime + { + $requestTime = intval($request->getServerParam('REQUEST_TIME_FLOAT')); + + try { + $requestTimeFrom = (new DateTime('@' . $requestTime))->modify('-' . $failedAttemptsPeriod); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); + } + + return $requestTimeFrom; + } +} diff --git a/application/Espo/Core/Authentication/Hook/Manager.php b/application/Espo/Core/Authentication/Hook/Manager.php new file mode 100644 index 0000000..1519a63 --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/Manager.php @@ -0,0 +1,195 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Hook; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\ServiceUnavailable; +use Espo\Core\Utils\Metadata; +use Espo\Core\InjectableFactory; +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Api\Request; +use Espo\Core\Authentication\Result; + +class Manager +{ + + public function __construct(private Metadata $metadata, private InjectableFactory $injectableFactory) + {} + + /** + * @throws ServiceUnavailable + * @throws Forbidden + */ + public function processBeforeLogin(AuthenticationData $data, Request $request): void + { + foreach ($this->getBeforeLoginHookList() as $hook) { + $hook->process($data, $request); + } + } + + /** + * @throws Forbidden + */ + public function processOnLogin(Result $result, AuthenticationData $data, Request $request): void + { + foreach ($this->getOnLoginHookList() as $hook) { + $hook->process($result, $data, $request); + } + } + + public function processOnFail(Result $result, AuthenticationData $data, Request $request): void + { + foreach ($this->getOnFailHookList() as $hook) { + $hook->process($result, $data, $request); + } + } + + public function processOnSuccess(Result $result, AuthenticationData $data, Request $request): void + { + foreach ($this->getOnSuccessHookList() as $hook) { + $hook->process($result, $data, $request); + } + } + + public function processOnSuccessByToken(Result $result, AuthenticationData $data, Request $request): void + { + foreach ($this->getOnSuccessByTokenHookList() as $hook) { + $hook->process($result, $data, $request); + } + } + + public function processOnSecondStepRequired(Result $result, AuthenticationData $data, Request $request): void + { + foreach ($this->getOnSecondStepRequiredHookList() as $hook) { + $hook->process($result, $data, $request); + } + } + + /** + * @return class-string[] + */ + private function getHookClassNameList(string $type): array + { + $key = $type . 'HookClassNameList'; + + /** @var class-string[] */ + return $this->metadata->get(['app', 'authentication', $key]) ?? []; + } + + /** + * @return BeforeLogin[] + */ + private function getBeforeLoginHookList(): array + { + $list = []; + + foreach ($this->getHookClassNameList('beforeLogin') as $className) { + /** @var class-string $className */ + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } + + /** + * @return OnLogin[] + */ + private function getOnLoginHookList(): array + { + $list = []; + + foreach ($this->getHookClassNameList('onLogin') as $className) { + /** @var class-string $className */ + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } + + /** + * @return OnResult[] + */ + private function getOnFailHookList(): array + { + $list = []; + + foreach ($this->getHookClassNameList('onFail') as $className) { + /** @var class-string $className */ + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } + + /** + * @return OnResult[] + */ + private function getOnSuccessHookList(): array + { + $list = []; + + foreach ($this->getHookClassNameList('onSuccess') as $className) { + /** @var class-string $className */ + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } + + /** + * @return OnResult[] + */ + private function getOnSuccessByTokenHookList(): array + { + $list = []; + + foreach ($this->getHookClassNameList('onSuccessByToken') as $className) { + /** @var class-string $className */ + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } + + /** + * @return OnResult[] + */ + private function getOnSecondStepRequiredHookList(): array + { + $list = []; + + foreach ($this->getHookClassNameList('onSecondStepRequired') as $className) { + /** @var class-string $className */ + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } +} diff --git a/application/Espo/Core/Authentication/Hook/OnLogin.php b/application/Espo/Core/Authentication/Hook/OnLogin.php new file mode 100644 index 0000000..f8c19eb --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/OnLogin.php @@ -0,0 +1,46 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Hook; + +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Api\Request; +use Espo\Core\Authentication\Result; +use Espo\Core\Exceptions\Forbidden; + +/** + * Once a login result is ready. + */ +interface OnLogin +{ + /** + * @throws Forbidden + */ + public function process(Result $result, AuthenticationData $data, Request $request): void; +} diff --git a/application/Espo/Core/Authentication/Hook/OnResult.php b/application/Espo/Core/Authentication/Hook/OnResult.php new file mode 100644 index 0000000..bbef5d8 --- /dev/null +++ b/application/Espo/Core/Authentication/Hook/OnResult.php @@ -0,0 +1,42 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Authentication\Hook; + +use Espo\Core\Authentication\AuthenticationData; +use Espo\Core\Api\Request; +use Espo\Core\Authentication\Result; + +/** + * Once a login result is ready. + */ +interface OnResult +{ + public function process(Result $result, AuthenticationData $data, Request $request): void; +} diff --git a/application/Espo/Core/Authentication/Jwt/DefaultKeyFactory.php b/application/Espo/Core/Authentication/Jwt/DefaultKeyFactory.php new file mode 100644 index 0000000..70dd586 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/DefaultKeyFactory.php @@ -0,0 +1,50 @@ +. + * + * 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\Jwt; + +use Espo\Core\Authentication\Jwt\Exceptions\UnsupportedKey; +use Espo\Core\Authentication\Jwt\Keys\Rsa; +use stdClass; + +class DefaultKeyFactory implements KeyFactory +{ + private const TYPE_RSA = 'RSA'; + + public function create(stdClass $raw): Key + { + $kty = $raw->kty ?? null; + + if ($kty === self::TYPE_RSA) { + return Rsa::fromRaw($raw); + } + + throw new UnsupportedKey(); + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Exceptions/Expired.php b/application/Espo/Core/Authentication/Jwt/Exceptions/Expired.php new file mode 100644 index 0000000..35ba5cb --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Exceptions/Expired.php @@ -0,0 +1,32 @@ +. + * + * 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\Jwt\Exceptions; + +class Expired extends Invalid {} diff --git a/application/Espo/Core/Authentication/Jwt/Exceptions/Invalid.php b/application/Espo/Core/Authentication/Jwt/Exceptions/Invalid.php new file mode 100644 index 0000000..f55fd31 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Exceptions/Invalid.php @@ -0,0 +1,34 @@ +. + * + * 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\Jwt\Exceptions; + +use Exception; + +class Invalid extends Exception {} diff --git a/application/Espo/Core/Authentication/Jwt/Exceptions/NotBefore.php b/application/Espo/Core/Authentication/Jwt/Exceptions/NotBefore.php new file mode 100644 index 0000000..fcc0dd1 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Exceptions/NotBefore.php @@ -0,0 +1,32 @@ +. + * + * 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\Jwt\Exceptions; + +class NotBefore extends Invalid {} diff --git a/application/Espo/Core/Authentication/Jwt/Exceptions/SignatureNotVerified.php b/application/Espo/Core/Authentication/Jwt/Exceptions/SignatureNotVerified.php new file mode 100644 index 0000000..115458c --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Exceptions/SignatureNotVerified.php @@ -0,0 +1,32 @@ +. + * + * 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\Jwt\Exceptions; + +class SignatureNotVerified extends Invalid {} diff --git a/application/Espo/Core/Authentication/Jwt/Exceptions/UnsupportedKey.php b/application/Espo/Core/Authentication/Jwt/Exceptions/UnsupportedKey.php new file mode 100644 index 0000000..75bbf40 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Exceptions/UnsupportedKey.php @@ -0,0 +1,34 @@ +. + * + * 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\Jwt\Exceptions; + +use Exception; + +class UnsupportedKey extends Exception {} diff --git a/application/Espo/Core/Authentication/Jwt/Key.php b/application/Espo/Core/Authentication/Jwt/Key.php new file mode 100644 index 0000000..42b0d16 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Key.php @@ -0,0 +1,39 @@ +. + * + * 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\Jwt; + +interface Key +{ + public function getKid(): string; + + public function getKty(): string; + + public function getAlg(): ?string; +} diff --git a/application/Espo/Core/Authentication/Jwt/KeyFactory.php b/application/Espo/Core/Authentication/Jwt/KeyFactory.php new file mode 100644 index 0000000..1c6076a --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/KeyFactory.php @@ -0,0 +1,41 @@ +. + * + * 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\Jwt; + +use Espo\Core\Authentication\Jwt\Exceptions\UnsupportedKey; +use stdClass; + +interface KeyFactory +{ + /** + * @throws UnsupportedKey + */ + public function create(stdClass $raw): Key; +} diff --git a/application/Espo/Core/Authentication/Jwt/Keys/Rsa.php b/application/Espo/Core/Authentication/Jwt/Keys/Rsa.php new file mode 100644 index 0000000..98670bd --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Keys/Rsa.php @@ -0,0 +1,99 @@ +. + * + * 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\Jwt\Keys; + +use Espo\Core\Authentication\Jwt\Key; +use UnexpectedValueException; +use stdClass; + +/** + * Immutable. + */ +class Rsa implements Key +{ + private string $kid; + private string $kty; + private ?string $alg; + private string $n; + private string $e; + + private function __construct(stdClass $raw) + { + $kid = $raw->kid ?? null; + $kty = $raw->kty ?? null; + $alg = $raw->alg ?? null; + $n = $raw->n ?? null; + $e = $raw->e ?? null; + + if ($kid === null || $kty === null) { + throw new UnexpectedValueException("Bad JWK value."); + } + + if ($n === null || $e === null) { + throw new UnexpectedValueException("Bad JWK RSE key. No `n` or `e` values."); + } + + $this->kid = $kid; + $this->kty = $kty; + $this->alg = $alg; + $this->n = $n; + $this->e = $e; + } + + public static function fromRaw(stdClass $raw): self + { + return new self($raw); + } + + public function getKid(): string + { + return $this->kid; + } + + public function getKty(): string + { + return $this->kty; + } + + public function getAlg(): ?string + { + return $this->alg; + } + + public function getN(): string + { + return $this->n; + } + + public function getE(): string + { + return $this->e; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/SignatureVerifier.php b/application/Espo/Core/Authentication/Jwt/SignatureVerifier.php new file mode 100644 index 0000000..d185805 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/SignatureVerifier.php @@ -0,0 +1,35 @@ +. + * + * 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\Jwt; + +interface SignatureVerifier +{ + public function verify(Token $token): bool; +} diff --git a/application/Espo/Core/Authentication/Jwt/SignatureVerifierFactory.php b/application/Espo/Core/Authentication/Jwt/SignatureVerifierFactory.php new file mode 100644 index 0000000..4bca3e0 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/SignatureVerifierFactory.php @@ -0,0 +1,35 @@ +. + * + * 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\Jwt; + +interface SignatureVerifierFactory +{ + public function create(string $algorithm): SignatureVerifier; +} diff --git a/application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Hmac.php b/application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Hmac.php new file mode 100644 index 0000000..0915505 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Hmac.php @@ -0,0 +1,85 @@ +. + * + * 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\Jwt\SignatureVerifiers; + +use Espo\Core\Authentication\Jwt\Token; +use Espo\Core\Authentication\Jwt\SignatureVerifier; +use LogicException; +use RuntimeException; + +class Hmac implements SignatureVerifier +{ + private const SUPPORTED_ALGORITHM_LIST = [ + self::HS256, + self::HS384, + self::HS512, + ]; + + private const ALGORITHM_MAP = [ + self::HS256 => 'SHA256', + self::HS384 => 'SHA384', + self::HS512 => 'SHA512', + ]; + + private const HS256 = 'HS256'; + private const HS384 = 'HS384'; + private const HS512 = 'HS512'; + + private string $algorithm; + private string $key; + + public function __construct( + string $algorithm, + string $key + ) { + $this->algorithm = $algorithm; + $this->key = $key; + + if (!in_array($algorithm, self::SUPPORTED_ALGORITHM_LIST)) { + throw new RuntimeException("Unsupported algorithm $algorithm."); + } + } + + public function verify(Token $token): bool + { + $input = $token->getSigningInput(); + $signature = $token->getSignature(); + + $functionAlgorithm = self::ALGORITHM_MAP[$this->algorithm] ?? null; + + if (!$functionAlgorithm) { + throw new LogicException(); + } + + $hash = hash_hmac($functionAlgorithm, $input, $this->key, true); + + return $hash === $signature; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Rsa.php b/application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Rsa.php new file mode 100644 index 0000000..9a2ca73 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/SignatureVerifiers/Rsa.php @@ -0,0 +1,131 @@ +. + * + * 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\Jwt\SignatureVerifiers; + +use Espo\Core\Authentication\Jwt\Key; +use Espo\Core\Authentication\Jwt\Keys\Rsa as RsaKey; +use Espo\Core\Authentication\Jwt\Token; +use Espo\Core\Authentication\Jwt\SignatureVerifier; +use Espo\Core\Authentication\Jwt\Util; +use LogicException; +use phpseclib3\Crypt\PublicKeyLoader; +use phpseclib3\Math\BigInteger; +use RuntimeException; + +class Rsa implements SignatureVerifier +{ + private const SUPPORTED_ALGORITHM_LIST = [ + self::RS256, + self::RS384, + self::RS512, + ]; + + private const ALGORITHM_MAP = [ + self::RS256 => 'SHA256', + self::RS384 => 'SHA384', + self::RS512 => 'SHA512', + ]; + + private const RS256 = 'RS256'; + private const RS384 = 'RS384'; + private const RS512 = 'RS512'; + + private string $algorithm; + /** @var Key[] */ + private array $keys; + + /** + * @param Key[] $keys + */ + public function __construct(string $algorithm, array $keys) + { + $this->algorithm = $algorithm; + $this->keys = $keys; + + if (!in_array($algorithm, self::SUPPORTED_ALGORITHM_LIST)) { + throw new RuntimeException("Unsupported algorithm $algorithm."); + } + } + + public function verify(Token $token): bool + { + $input = $token->getSigningInput(); + $signature = $token->getSignature(); + $kid = $token->getHeader()->getKid(); + + $functionAlgorithm = self::ALGORITHM_MAP[$this->algorithm] ?? null; + + if (!$functionAlgorithm) { + throw new LogicException(); + } + + $key = array_values( + array_filter($this->keys, fn ($key) => $key->getKid() === $kid) + )[0] ?? null; + + if (!$key) { + return false; + } + + if (!$key instanceof RsaKey) { + throw new RuntimeException("Wrong key."); + } + + $publicKey = openssl_pkey_get_public($this->getPemFromKey($key)); + + if ($publicKey === false) { + throw new RuntimeException("Bad RSA public key."); + } + + $result = openssl_verify($input, $signature, $publicKey, $functionAlgorithm); + + if ($result === false) { + throw new RuntimeException("RSA public key verify error: " . openssl_error_string()); + } + + return $result === 1; + } + + private function getPemFromKey(RsaKey $key): string + { + $publicKey = PublicKeyLoader::load([ + 'n' => new BigInteger('0x' . bin2hex(Util::base64UrlDecode($key->getN())), 16), + 'e' => new BigInteger('0x' . bin2hex(Util::base64UrlDecode($key->getE())), 16), + ]); + + $pem = $publicKey->toString('PKCS8'); + + if (!is_string($pem)) { + throw new RuntimeException(); + } + + return $pem; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Token.php b/application/Espo/Core/Authentication/Jwt/Token.php new file mode 100644 index 0000000..b35685e --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Token.php @@ -0,0 +1,112 @@ +. + * + * 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\Jwt; + +use Espo\Core\Authentication\Jwt\Token\Header; +use Espo\Core\Authentication\Jwt\Token\Payload; +use RuntimeException; + +/** + * JWT token. + * + * Immutable. + */ +class Token +{ + private string $token; + private string $headerPart; + private string $payloadPart; + private string $signaturePart; + private string $headerRaw; + private string $payloadRaw; + private string $signatureRaw; + private Header $header; + private Payload $payload; + + private function __construct(string $token) + { + $this->token = $token; + + $parts = explode('.', $token); + + if (count($parts) < 3) { + throw new RuntimeException("Too few JWT parts."); + } + + list($this->headerPart, $this->payloadPart, $this->signaturePart) = $parts; + + $this->headerRaw = Util::base64UrlDecode($this->headerPart); + $this->payloadRaw = Util::base64UrlDecode($this->payloadPart); + $this->signatureRaw = Util::base64UrlDecode($this->signaturePart); + + $this->header = Header::fromRaw($this->headerRaw); + $this->payload = Payload::fromRaw($this->payloadRaw); + } + + public static function create(string $token): self + { + return new self($token); + } + + public function getToken(): string + { + return $this->token; + } + + public function getSigningInput(): string + { + return $this->headerPart . '.' . $this->payloadPart; + } + + public function getHeader(): Header + { + return $this->header; + } + + public function getPayload(): Payload + { + return $this->payload; + } + + public function getSignature(): string + { + return $this->signatureRaw; + } + + public function getHeaderRaw(): string + { + return $this->headerRaw; + } + + public function getPayloadRaw(): string + { + return $this->payloadRaw; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Token/Header.php b/application/Espo/Core/Authentication/Jwt/Token/Header.php new file mode 100644 index 0000000..01e3261 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Token/Header.php @@ -0,0 +1,123 @@ +. + * + * 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\Jwt\Token; + +use Espo\Core\Utils\Json; +use RuntimeException; +use JsonException; +use stdClass; + +/** + * Immutable. + */ +class Header +{ + private string $alg; + private ?string $kid; + /** @var array */ + private array $data; + + /** + * @param array $data + */ + private function __construct( + string $alg, + ?string $kid, + array $data + ) { + $this->alg = $alg; + $this->kid = $kid; + $this->data = $data; + } + + /** + * @return mixed + */ + public function get(string $name) + { + return $this->data[$name] ?? null; + } + + public static function fromRaw(string $raw): self + { + $parsed = null; + + try { + $parsed = Json::decode($raw); + } catch (JsonException) {} + + if (!$parsed instanceof stdClass) { + throw new RuntimeException(); + } + + $alg = self::obtainFromParsedString($parsed, 'alg'); + $kid = self::obtainFromParsedStringNull($parsed, 'kid'); + + return new self( + $alg, + $kid, + get_object_vars($parsed) + ); + } + + /** @noinspection PhpSameParameterValueInspection */ + private static function obtainFromParsedString(stdClass $parsed, string $name): string + { + $value = $parsed->$name ?? null; + + if (!is_string($value)) { + throw new RuntimeException("No or bad `$name` in JWT header."); + } + + return $value; + } + + /** @noinspection PhpSameParameterValueInspection */ + private static function obtainFromParsedStringNull(stdClass $parsed, string $name): ?string + { + $value = $parsed->$name ?? null; + + if ($value !== null && !is_string($value)) { + throw new RuntimeException("Bad `$name` in JWT header."); + } + + return $value; + } + + public function getAlg(): string + { + return $this->alg; + } + + public function getKid(): ?string + { + return $this->kid; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Token/Payload.php b/application/Espo/Core/Authentication/Jwt/Token/Payload.php new file mode 100644 index 0000000..2e18e9a --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Token/Payload.php @@ -0,0 +1,235 @@ +. + * + * 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\Jwt\Token; + +use Espo\Core\Utils\Json; +use RuntimeException; +use JsonException; +use stdClass; + +/** + * Immutable. + */ +class Payload +{ + private ?string $sub; + private ?string $iss; + /** @var string[] */ + private array $aud; + private ?int $exp; + private ?int $iat; + private ?int $nbf; + private ?string $nonce; + private ?int $authTime; + private ?string $sid; + /** @var array */ + private array $data; + + /** + * @param string[] $aud + * @param array $data + */ + private function __construct( + ?string $sub, + ?string $iss, + array $aud, + ?int $exp, + ?int $iat, + ?int $nbf, + ?string $nonce, + ?int $authTime, + ?string $sid, + array $data + ) { + $this->sub = $sub; + $this->iss = $iss; + $this->aud = $aud; + $this->exp = $exp; + $this->iat = $iat; + $this->nbf = $nbf; + $this->nonce = $nonce; + $this->authTime = $authTime; + $this->sid = $sid; + $this->data = $data; + } + + public function getSub(): ?string + { + return $this->sub; + } + + public function getIss(): ?string + { + return $this->iss; + } + + public function getExp(): ?int + { + return $this->exp; + } + + public function getIat(): ?int + { + return $this->iat; + } + + public function getNbf(): ?int + { + return $this->nbf; + } + + /** + * @return string[] + */ + public function getAud(): array + { + return $this->aud; + } + + public function getNonce(): ?string + { + return $this->nonce; + } + + public function getAuthTime(): ?int + { + return $this->authTime; + } + + /** @noinspection PhpUnused */ + public function getSid(): ?string + { + return $this->sid; + } + + /** + * @return mixed + */ + public function get(string $name) + { + return $this->data[$name] ?? null; + } + + public static function fromRaw(string $raw): self + { + $parsed = null; + + try { + $parsed = Json::decode($raw); + } catch (JsonException) {} + + if (!$parsed instanceof stdClass) { + throw new RuntimeException(); + } + + $sub = $parsed->sub ?? null; + $iss = $parsed->iss ?? null; + $aud = $parsed->aud ?? null; + $exp = $parsed->exp ?? null; + $iat = $parsed->iat ?? null; + $nbf = $parsed->nbf ?? null; + $nonce = $parsed->nonce ?? null; + $authTime = $parsed->auth_time ?? null; + $sid = $parsed->sid ?? null; + + if (is_string($aud)) { + $aud = [$aud]; + } + + if ($aud === null) { + $aud = []; + } + + if ($iss !== null && !is_string($sub)) { + throw new RuntimeException("Bad `sub`."); + } + + if ($iss !== null && !is_string($iss)) { + throw new RuntimeException("Bad `iss`."); + } + + if (!is_array($aud)) { + throw new RuntimeException("Bad `aud`."); + } + + if ($exp !== null && !is_numeric($exp)) { + throw new RuntimeException("Bad `exp`."); + } + + if ($iat !== null && !is_numeric($iat)) { + throw new RuntimeException("Bad `iat`."); + } + + if ($nbf !== null && !is_numeric($nbf)) { + throw new RuntimeException("Bad `nbf`."); + } + + if ($nonce !== null && !is_string($nonce)) { + throw new RuntimeException("Bad `nonce`."); + } + + if ($authTime !== null && !is_numeric($authTime)) { + throw new RuntimeException("Bad `auth_time`."); + } + + if ($sid !== null && !is_string($sid)) { + throw new RuntimeException("Bad `sid`."); + } + + if ($exp !== null) { + $exp = (int) $exp; + } + + if ($iat !== null) { + $iat = (int) $iat; + } + + if ($nbf !== null) { + $nbf = (int) $nbf; + } + + if ($authTime !== null) { + $authTime = (int) $authTime; + } + + return new self( + $sub, + $iss, + $aud, + $exp, + $iat, + $nbf, + $nonce, + $authTime, + $sid, + get_object_vars($parsed) + ); + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Util.php b/application/Espo/Core/Authentication/Jwt/Util.php new file mode 100644 index 0000000..31f857e --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Util.php @@ -0,0 +1,51 @@ +. + * + * 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\Jwt; + +use RuntimeException; + +class Util +{ + public static function base64UrlDecode(string $string): string + { + $extra = 4 - strlen($string) % 4; + $extra = $extra < 4 ? $extra : 0; + + $preparedString = strtr($string . str_repeat('=', $extra), '-_', '+/'); + + $decoded = base64_decode($preparedString, true); + + if ($decoded === false) { + throw new RuntimeException("Base64url decoding error."); + } + + return $decoded; + } +} diff --git a/application/Espo/Core/Authentication/Jwt/Validator.php b/application/Espo/Core/Authentication/Jwt/Validator.php new file mode 100644 index 0000000..f4a9b99 --- /dev/null +++ b/application/Espo/Core/Authentication/Jwt/Validator.php @@ -0,0 +1,68 @@ +. + * + * 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\Jwt; + +use Espo\Core\Authentication\Jwt\Exceptions\Expired; +use Espo\Core\Authentication\Jwt\Exceptions\NotBefore; + +class Validator +{ + private const DEFAULT_TIME_LEEWAY = 60 * 4; + private int $timeLeeway; + private ?int $now; + + public function __construct( + ?int $timeLeeway = null, + ?int $now = null + ) { + $this->timeLeeway = $timeLeeway ?? self::DEFAULT_TIME_LEEWAY; + $this->now = $now; + } + + /** + * @throws Expired + * @throws NotBefore + */ + public function validate(Token $token): void + { + $exp = $token->getPayload()->getExp(); + $nbf = $token->getPayload()->getNbf(); + + $now = $this->now ?? time(); + + if ($exp && $exp + $this->timeLeeway <= $now) { + throw new Expired("JWT expired."); + } + + if ($nbf && $now < $nbf - $this->timeLeeway) { + throw new NotBefore("JWT used before allowed time."); + } + } +} diff --git a/application/Espo/Core/Authentication/Ldap/Client.php b/application/Espo/Core/Authentication/Ldap/Client.php new file mode 100644 index 0000000..151290e --- /dev/null +++ b/application/Espo/Core/Authentication/Ldap/Client.php @@ -0,0 +1,34 @@ +. + * + * 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\Ldap; + +use Laminas\Ldap\Ldap; + +class Client extends Ldap {} diff --git a/application/Espo/Core/Authentication/Ldap/ClientFactory.php b/application/Espo/Core/Authentication/Ldap/ClientFactory.php new file mode 100644 index 0000000..e58d0c2 --- /dev/null +++ b/application/Espo/Core/Authentication/Ldap/ClientFactory.php @@ -0,0 +1,44 @@ +. + * + * 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\Ldap; + +use Laminas\Ldap\Exception\LdapException; + +class ClientFactory +{ + /** + * @param array $options + * @throws LdapException + */ + public function create(array $options): Client + { + return new Client($options); + } +} diff --git a/application/Espo/Core/Authentication/Ldap/LdapLogin.php b/application/Espo/Core/Authentication/Ldap/LdapLogin.php new file mode 100644 index 0000000..437263c --- /dev/null +++ b/application/Espo/Core/Authentication/Ldap/LdapLogin.php @@ -0,0 +1,490 @@ +. + * + * 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\Ldap; + +use Espo\Core\Api\Util; +use Espo\Core\Binding\Attributes\Qualify; +use Espo\Core\FieldProcessing\Relation\LinkMultipleSaver; +use Espo\Core\FieldProcessing\EmailAddress\Saver as EmailAddressSaver; +use Espo\Core\FieldProcessing\PhoneNumber\Saver as PhoneNumberSaver; +use Espo\Core\FieldProcessing\Saver\Params as SaverParams; +use Espo\Core\Api\Request; +use Espo\Core\Authentication\AuthToken\AuthToken; +use Espo\Core\Authentication\Ldap\Client as Client; +use Espo\Core\Authentication\Ldap\ClientFactory as ClientFactory; +use Espo\Core\Authentication\Ldap\Utils as LDAPUtils; +use Espo\Core\Authentication\Login; +use Espo\Core\Authentication\Login\Data; +use Espo\Core\Authentication\Logins\Espo; +use Espo\Core\Authentication\Result; +use Espo\Core\Authentication\Result\FailReason; +use Espo\Core\Name\Field; +use Espo\Core\ORM\EntityManager; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Language; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\PasswordHash; +use Espo\Entities\User; +use Exception; +use Laminas\Ldap\Exception\LdapException; +use Laminas\Ldap\Ldap; +use SensitiveParameter; + +/** + * @noinspection PhpUnused + */ +class LdapLogin implements Login +{ + public const NAME = 'LDAP'; + + private LDAPUtils $utils; + private ?Client $client = null; + + private Language $language; + + public function __construct( + private Config $config, + private EntityManager $entityManager, + private PasswordHash $passwordHash, + #[Qualify(Language::QUALIFIER_DEFAULT)] + Language $defaultLanguage, + private Log $log, + private Espo $baseLogin, + private ClientFactory $clientFactory, + private LinkMultipleSaver $linkMultipleSaver, + private EmailAddressSaver $emailAddressSaver, + private PhoneNumberSaver $phoneNumberSaver, + private Util $util, + private bool $isPortal = false + ) { + $this->language = $defaultLanguage; + + $this->utils = new LDAPUtils($config); + } + + /** + * @var array + * @noinspection PhpUnusedPrivateFieldInspection + */ + private $ldapFieldMap = [ + 'userName' => 'userNameAttribute', + 'firstName' => 'userFirstNameAttribute', + 'lastName' => 'userLastNameAttribute', + 'title' => 'userTitleAttribute', + 'emailAddress' => 'userEmailAddressAttribute', + 'phoneNumber' => 'userPhoneNumberAttribute', + ]; + + /** + * @var array + * @noinspection PhpUnusedPrivateFieldInspection + */ + private $userFieldMap = [ + 'teamsIds' => 'userTeamsIds', + 'defaultTeamId' => 'userDefaultTeamId', + ]; + + /** + * @var array + * @noinspection PhpUnusedPrivateFieldInspection + */ + private $portalUserFieldMap = [ + 'portalsIds' => 'portalUserPortalsIds', + 'portalRolesIds' => 'portalUserRolesIds', + ]; + + /** + * @throws LdapException + */ + public function login(Data $data, Request $request): Result + { + $username = $data->getUsername(); + $password = $data->getPassword(); + $authToken = $data->getAuthToken(); + + $isPortal = $this->isPortal; + + if ($authToken) { + $user = $this->loginByToken($username, $authToken, $request); + + if ($user) { + return Result::success($user); + } + + return Result::fail(FailReason::WRONG_CREDENTIALS); + } + + if (!$password || $username == '**logout') { + return Result::fail(FailReason::NO_PASSWORD); + } + + if ($isPortal) { + $useLdapAuthForPortalUser = $this->utils->getOption('portalUserLdapAuth'); + + if (!$useLdapAuthForPortalUser) { + return $this->baseLogin->login($data, $request); + } + } + + $ldapClient = $this->getLdapClient(); + + /* Login LDAP system user (ldapUsername, ldapPassword) */ + try { + $ldapClient->bind(); + } catch (Exception $e) { + $options = $this->utils->getLdapClientOptions(); + + $this->log->error("LDAP: Could not connect to LDAP server host.", [ + 'host' => $options['host'], + 'exception' => $e, + ]); + + /** @var string $username */ + + $adminUser = $this->adminLogin($username, $password); + + if (!isset($adminUser)) { + return Result::fail(); + } + + $this->log->info("LDAP: Administrator '{username}' was logged in by Espo method.", [ + 'username' => $username, + ]); + } + + $userDn = null; + + if (!isset($adminUser)) { + /** @var string $username */ + + try { + $userDn = $this->findLdapUserDnByUsername($username); + } catch (Exception $e) { + $this->log->error("Error while finding DN for '{username}'.", [ + 'username' => $username, + 'exception' => $e, + ]); + } + + if (!isset($userDn)) { + $this->log->error("LDAP: Authentication failed for '{username}'; user is not found.", [ + 'username' => $username, + ]); + + $adminUser = $this->adminLogin($username, $password); + + if (!isset($adminUser)) { + return Result::fail(); + } + + $this->log->info("LDAP: Administrator '{username}' was logged in by Espo method.", [ + 'username' => $username, + ]); + } + + $this->log->debug("User '{username}' with DN '{dn}' is found .", [ + 'username' => $username, + 'dn' => $userDn, + ]); + + try { + $ldapClient->bind($userDn, $password); + } catch (Exception $e) { + $this->log->error("LDAP: Authentication failed for '{username}'.", [ + 'username' => $username, + 'exception' => $e, + ]); + + return Result::fail(); + } + } + + $user = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->where([ + 'userName' => $username, + 'type!=' => [ + User::TYPE_API, + User::TYPE_SYSTEM, + User::TYPE_SUPER_ADMIN, + ], + ]) + ->findOne(); + + if (!isset($user)) { + if (!$this->utils->getOption('createEspoUser')) { + $this->log->warning("LDAP: '{username}' authenticated, but user is not created in Espo.", [ + 'username' => $username, + ]); + + return Result::fail(FailReason::USER_NOT_FOUND); + } + + /** @var string $userDn */ + + $userData = $ldapClient->getEntry($userDn); + + $user = $this->createUser($userData, $isPortal); + } + + if (!$user) { + return Result::fail(); + } + + return Result::success($user); + } + + private function getLdapClient(): Client + { + if (!isset($this->client)) { + $options = $this->utils->getLdapClientOptions(); + + try { + $this->client = $this->clientFactory->create($options); + } catch (Exception $e) { + $this->log->error("LDAP error.", ['exception' => $e]); + } + } + + /** @var Client */ + return $this->client; + } + + /** + * Login by authorization token. + */ + private function loginByToken(?string $username, AuthToken $authToken, Request $request): ?User + { + if ($username === null) { + return null; + } + + $userId = $authToken->getUserId(); + + /** @var ?User $user */ + $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId); + + if (!$user) { + return null; + } + + $tokenUsername = $user->getUserName() ?? ''; + + if (strtolower($username) !== strtolower($tokenUsername)) { + $ip = $this->util->obtainIpFromRequest($request); + + $this->log->alert("Unauthorized access attempt for user '{username}' from IP '{ip}'.", [ + 'username' => $username, + 'ip' => $ip, + ]); + + return null; + } + + /** @var ?User */ + return $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->where([ + 'userName' => $username, + ]) + ->findOne(); + } + + private function adminLogin(string $username, #[SensitiveParameter] string $password): ?User + { + $user = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + 'userName' => $username, + 'type' => [User::TYPE_ADMIN, User::TYPE_SUPER_ADMIN], + ]) + ->findOne(); + + if (!$user) { + return null; + } + + if (!$this->passwordHash->verify($password, $user->getPassword())) { + return null; + } + + return $user; + } + + /** + * Create Espo user with data gets from LDAP server. + * + * @param array $userData + */ + private function createUser(array $userData, bool $isPortal = false): ?User + { + $this->log->info("LDAP: Creating new user."); + $this->log->debug("LDAP: user data: {userData}", ['userData' => print_r($userData, true)]); + + $data = []; + + $ldapFields = $this->loadFields('ldap'); + + foreach ($ldapFields as $espo => $ldap) { + $ldap = strtolower($ldap); + + if (isset($userData[$ldap][0])) { + $this->log->debug("LDAP: Create a user with [{user1}] = [{user2}].", [ + 'user1' => $espo, + 'user2' => $userData[$ldap][0], + ]); + + $data[$espo] = $userData[$ldap][0]; + } + } + + if ($isPortal) { + $userAttributes = $this->loadFields('portalUser'); + + $userAttributes['type'] = User::TYPE_PORTAL; + } else { + $userAttributes = $this->loadFields('user'); + } + + foreach ($userAttributes as $key => $value) { + $data[$key] = $value; + } + + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getNew(); + + $user->setMultiple($data); + + $this->entityManager->saveEntity($user, [ + // Prevent `user` service being loaded by hooks. + SaveOption::SKIP_HOOKS => true, + SaveOption::KEEP_NEW => true, + ]); + + $saverParams = SaverParams::create()->withRawOptions(['skipLinkMultipleHooks' => true]); + + $this->linkMultipleSaver->process($user, Field::TEAMS, $saverParams); + $this->linkMultipleSaver->process($user, 'portals', $saverParams); + $this->linkMultipleSaver->process($user, 'portalRoles', $saverParams); + $this->emailAddressSaver->process($user, $saverParams); + $this->phoneNumberSaver->process($user, $saverParams); + + $user->setAsNotNew(); + $user->updateFetchedValues(); + + return $this->entityManager->getRDBRepositoryByClass(User::class)->getById($user->getId()); + } + + /** + * Find LDAP user DN by their username. + * + * @throws LdapException + */ + private function findLdapUserDnByUsername(string $username): ?string + { + $ldapClient = $this->getLdapClient(); + $options = $this->utils->getOptions(); + + $filterString = !empty($options['userLoginFilter']) ? + $this->convertToFilterFormat($options['userLoginFilter']) : ''; + + $objectClass = $options['userObjectClass']; + $attribute = $options['userNameAttribute']; + $usernameEscaped = $this->escapeUsernameFilter($username); + + $searchString = "(&(objectClass=$objectClass)($attribute=$usernameEscaped)$filterString)"; + + /** @var array $result */ + /** @noinspection PhpRedundantOptionalArgumentInspection */ + $result = $ldapClient->search($searchString, null, Ldap::SEARCH_SCOPE_SUB); + + $this->log->debug("LDAP: user search string: {string}.", ['string' => $searchString]); + + foreach ($result as $item) { + return $item['dn']; + } + + return null; + } + + private function escapeUsernameFilter(string $username): string + { + $map = [ + '\\' => '\\5c', + '*' => '\\2a', + '(' => '\\28', + ')' => '\\29', + "\x00" => '\\00', + ]; + + return strtr($username, $map); + } + + /** + * Check and convert filter item into LDAP format. + */ + private function convertToFilterFormat(string $filter): string + { + $filter = trim($filter); + + if (!str_starts_with($filter, '(')) { + $filter = '(' . $filter; + } + + if (!str_ends_with($filter, ')')) { + $filter = $filter . ')'; + } + + return $filter; + } + + /** + * Load fields for a user. + * + * @return array + */ + private function loadFields(string $type): array + { + $options = $this->utils->getOptions(); + + $typeMap = $type . 'FieldMap'; + + $fields = []; + + foreach ($this->$typeMap as $fieldName => $fieldValue) { + /** @var string $fieldName */ + if (isset($options[$fieldValue])) { + $fields[$fieldName] = $options[$fieldValue]; + } + } + + return $fields; + } +} diff --git a/application/Espo/Core/Authentication/Ldap/Utils.php b/application/Espo/Core/Authentication/Ldap/Utils.php new file mode 100644 index 0000000..36c62eb --- /dev/null +++ b/application/Espo/Core/Authentication/Ldap/Utils.php @@ -0,0 +1,193 @@ +. + * + * 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\Ldap; + +use Espo\Core\Utils\Config; + +class Utils +{ + private Config $config; + + /** + * @var ?array + */ + private ?array $options = null; + + /** + * @var array + */ + private $fieldMap = [ + 'host' => 'ldapHost', + 'port' => 'ldapPort', + 'useSsl' => 'ldapSecurity', + 'useStartTls' => 'ldapSecurity', + 'username' => 'ldapUsername', + 'password' => 'ldapPassword', + 'bindRequiresDn' => 'ldapBindRequiresDn', + 'baseDn' => 'ldapBaseDn', + 'accountCanonicalForm' => 'ldapAccountCanonicalForm', + 'accountDomainName' => 'ldapAccountDomainName', + 'accountDomainNameShort' => 'ldapAccountDomainNameShort', + 'accountFilterFormat' => 'ldapAccountFilterFormat', + 'optReferrals' => 'ldapOptReferrals', + 'tryUsernameSplit' => 'ldapTryUsernameSplit', + 'networkTimeout' => 'ldapNetworkTimeout', + 'createEspoUser' => 'ldapCreateEspoUser', + 'userNameAttribute' => 'ldapUserNameAttribute', + 'userTitleAttribute' => 'ldapUserTitleAttribute', + 'userFirstNameAttribute' => 'ldapUserFirstNameAttribute', + 'userLastNameAttribute' => 'ldapUserLastNameAttribute', + 'userEmailAddressAttribute' => 'ldapUserEmailAddressAttribute', + 'userPhoneNumberAttribute' => 'ldapUserPhoneNumberAttribute', + 'userLoginFilter' => 'ldapUserLoginFilter', + 'userTeamsIds' => 'ldapUserTeamsIds', + 'userDefaultTeamId' => 'ldapUserDefaultTeamId', + 'userObjectClass' => 'ldapUserObjectClass', + 'portalUserLdapAuth' => 'ldapPortalUserLdapAuth', + 'portalUserPortalsIds' => 'ldapPortalUserPortalsIds', + 'portalUserRolesIds' => 'ldapPortalUserRolesIds', + ]; + + /** + * @var array + */ + private $permittedEspoOptions = [ + 'createEspoUser', + 'userNameAttribute', + 'userObjectClass', + 'userTitleAttribute', + 'userFirstNameAttribute', + 'userLastNameAttribute', + 'userEmailAddressAttribute', + 'userPhoneNumberAttribute', + 'userLoginFilter', + 'userTeamsIds', + 'userDefaultTeamId', + 'portalUserLdapAuth', + 'portalUserPortalsIds', + 'portalUserRolesIds', + ]; + + /** + * AccountCanonicalForm Map between Espo and Laminas value. + * + * @var array + */ + private $accountCanonicalFormMap = [ + 'Dn' => 1, + 'Username' => 2, + 'Backslash' => 3, + 'Principal' => 4, + ]; + + public function __construct(?Config $config = null) + { + if (isset($config)) { + $this->config = $config; + } + } + + /** + * Get Options from espo config according to $this->fieldMap. + * + * @return array + */ + public function getOptions(): array + { + if (isset($this->options)) { + return $this->options; + } + + $options = []; + + foreach ($this->fieldMap as $ldapName => $espoName) { + $option = $this->config->get($espoName); + + if (isset($option)) { + $options[$ldapName] = $option; + } + } + + $this->options = $this->normalizeOptions($options); + + return $this->options; + } + + /** + * Normalize options to LDAP client format + * + * @param array $options + * @return array + */ + public function normalizeOptions(array $options): array + { + $useSsl = ($options['useSsl'] ?? null) == 'SSL'; + $useStartTls = ($options['useStartTls'] ?? null) == 'TLS'; + $accountCanonicalFormKey = $options['accountCanonicalForm'] ?? 'Dn'; + + $options['useSsl'] = $useSsl; + $options['useStartTls'] = $useStartTls; + $options['accountCanonicalForm'] = $this->accountCanonicalFormMap[$accountCanonicalFormKey] ?? 1; + + return $options; + } + + /** + * Get an LDAP option. + * + * @param string $name + * @param mixed $returns A default value. + * @return mixed + */ + public function getOption($name, $returns = null) + { + if (!isset($this->options)) { + $this->getOptions(); + } + + if (isset($this->options[$name])) { + return $this->options[$name]; + } + + return $returns; + } + + /** + * Get Laminas options for using Laminas\Ldap. + * + * @return array + */ + public function getLdapClientOptions(): array + { + $options = $this->getOptions(); + + return array_diff_key($options, array_flip($this->permittedEspoOptions)); + } +} diff --git a/application/Espo/Core/Authentication/Login.php b/application/Espo/Core/Authentication/Login.php new file mode 100644 index 0000000..baf4b08 --- /dev/null +++ b/application/Espo/Core/Authentication/Login.php @@ -0,0 +1,45 @@ +. + * + * 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; + +use Espo\Core\Api\Request; +use Espo\Core\Authentication\Login\Data; + +/** + * Performs credentials checking. For the basic authorization a username & password are used. + * For other authorization methods credentials must be fetched from the request. + */ +interface Login +{ + /** + * Check credentials. + */ + public function login(Data $data, Request $request): Result; +} diff --git a/application/Espo/Core/Authentication/Login/Data.php b/application/Espo/Core/Authentication/Login/Data.php new file mode 100644 index 0000000..3906f51 --- /dev/null +++ b/application/Espo/Core/Authentication/Login/Data.php @@ -0,0 +1,88 @@ +. + * + * 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\Login; + +use Espo\Core\Authentication\AuthToken\AuthToken; +use SensitiveParameter; + +/** + * Login data to be passed to the 'login' method. + */ +class Data +{ + private ?string $username; + private ?string $password; + private ?AuthToken $authToken; + + public function __construct( + ?string $username, + #[SensitiveParameter] ?string $password, + ?AuthToken $authToken = null + ) { + $this->username = $username; + $this->password = $password; + $this->authToken = $authToken; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getAuthToken(): ?AuthToken + { + return $this->authToken; + } + + public function hasUsername(): bool + { + return !is_null($this->username); + } + + public function hasPassword(): bool + { + return !is_null($this->password); + } + + public function hasAuthToken(): bool + { + return !is_null($this->authToken); + } + + public static function createBuilder(): DataBuilder + { + return new DataBuilder(); + } +} diff --git a/application/Espo/Core/Authentication/Login/DataBuilder.php b/application/Espo/Core/Authentication/Login/DataBuilder.php new file mode 100644 index 0000000..6fc9139 --- /dev/null +++ b/application/Espo/Core/Authentication/Login/DataBuilder.php @@ -0,0 +1,66 @@ +. + * + * 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\Login; + +use Espo\Core\Authentication\AuthToken\AuthToken; +use SensitiveParameter; + +class DataBuilder +{ + private ?string $username = null; + private ?string $password = null; + private ?AuthToken $authToken = null; + + public function setUsername(?string $username): self + { + $this->username = $username; + + return $this; + } + + public function setPassword(#[SensitiveParameter] ?string $password): self + { + $this->password = $password; + + return $this; + } + + public function setAuthToken(?AuthToken $authToken): self + { + $this->authToken = $authToken; + + return $this; + } + + public function build(): Data + { + return new Data($this->username, $this->password, $this->authToken); + } +} diff --git a/application/Espo/Core/Authentication/Login/MetadataParams.php b/application/Espo/Core/Authentication/Login/MetadataParams.php new file mode 100644 index 0000000..e60daa4 --- /dev/null +++ b/application/Espo/Core/Authentication/Login/MetadataParams.php @@ -0,0 +1,79 @@ +. + * + * 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\Login; + +/** + * Immutable. + */ +class MetadataParams +{ + /** + * @internal + */ + public function __construct( + private string $method, + private bool $api = false, + private ?string $credentialsHeader = null, + private ?string $credentialsHeaderScheme = null, + ) {} + + /** + * @param array $data + */ + public static function fromRaw(string $method, array $data): self + { + return new self( + method: $method, + api: $data['api'] ?? false, + credentialsHeader: $data['credentialsHeader'] ?? null, + credentialsHeaderScheme: $data['credentialsHeaderScheme'] ?? null, + ); + } + + public function getMethod(): string + { + return $this->method; + } + + public function getCredentialsHeader(): ?string + { + return $this->credentialsHeader; + } + + public function isApi(): bool + { + return $this->api; + } + + public function getCredentialsHeaderScheme(): ?string + { + return $this->credentialsHeaderScheme; + } +} diff --git a/application/Espo/Core/Authentication/Login/MethodResolver.php b/application/Espo/Core/Authentication/Login/MethodResolver.php new file mode 100644 index 0000000..755b895 --- /dev/null +++ b/application/Espo/Core/Authentication/Login/MethodResolver.php @@ -0,0 +1,85 @@ +. + * + * 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\Login; + +use Espo\Core\Api\Request; +use Espo\Core\Authentication\ConfigDataProvider; +use Espo\Core\Authentication\HeaderKey; + +/** + * @internal + */ +class MethodResolver +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + ) {} + + public function resolve(Request $request): ?string + { + if ($request->hasHeader(HeaderKey::AUTHORIZATION)) { + return null; + } + + $paramsList = $this->configDataProvider->getLoginMetadataParamsList(); + + $paramsList = array_filter($paramsList, fn ($it) => $it->isApi()); + + usort($paramsList, function (MetadataParams $a, MetadataParams $b) { + return ($b->getCredentialsHeaderScheme() !== null) <=> ($a->getCredentialsHeaderScheme() !== null); + }); + + $paramsList = array_filter($paramsList, function ($params) use ($request): bool { + $headerName = $params->getCredentialsHeader(); + + if (!$headerName || !$request->hasHeader($headerName)) { + return false; + } + + $headerValue = $request->getHeader($headerName) ?? ''; + + $scheme = $params->getCredentialsHeaderScheme(); + + if ($scheme !== null && !str_starts_with($headerValue, $scheme . ' ')) { + return false; + } + + return true; + }); + + $paramsList = array_values($paramsList); + + if (count($paramsList)) { + return $paramsList[0]->getMethod(); + } + + return null; + } +} diff --git a/application/Espo/Core/Authentication/LoginFactory.php b/application/Espo/Core/Authentication/LoginFactory.php new file mode 100644 index 0000000..be0cba7 --- /dev/null +++ b/application/Espo/Core/Authentication/LoginFactory.php @@ -0,0 +1,66 @@ +. + * + * 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; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +class LoginFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private ConfigDataProvider $configDataProvider + ) {} + + public function create(string $method, bool $isPortal = false): Login + { + /** @var class-string $className */ + $className = $this->metadata->get(['authenticationMethods', $method, 'implementationClassName']); + + if (!$className) { + $sanitizedName = preg_replace('/[^a-zA-Z0-9]+/', '', $method); + + /** @var class-string $className */ + $className = "Espo\\Core\\Authentication\\Logins\\" . $sanitizedName; + } + + return $this->injectableFactory->createWith($className, [ + 'isPortal' => $isPortal, + ]); + } + + public function createDefault(): Login + { + $method = $this->configDataProvider->getDefaultAuthenticationMethod(); + + return $this->create($method); + } +} diff --git a/application/Espo/Core/Authentication/Logins/ApiKey.php b/application/Espo/Core/Authentication/Logins/ApiKey.php new file mode 100644 index 0000000..b4b9579 --- /dev/null +++ b/application/Espo/Core/Authentication/Logins/ApiKey.php @@ -0,0 +1,71 @@ +. + * + * 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\Logins; + +use Espo\Core\Api\Request; +use Espo\Core\Authentication\Helper\UserFinder; +use Espo\Core\Authentication\Login; +use Espo\Core\Authentication\Login\Data; +use Espo\Core\Authentication\Result; +use Espo\Core\Authentication\Result\FailReason; + +class ApiKey implements Login +{ + public const string NAME = 'ApiKey'; + + /** + * @since 10.1.0 + */ + public const string HEADER_API_KEY = 'X-Api-Key'; + + private UserFinder $userFinder; + + public function __construct(UserFinder $userFinder) + { + $this->userFinder = $userFinder; + } + + public function login(Data $data, Request $request): Result + { + $apiKey = $request->getHeader(self::HEADER_API_KEY); + + if (!$apiKey) { + return Result::fail(FailReason::WRONG_CREDENTIALS); + } + + $user = $this->userFinder->findApiApiKey($apiKey); + + if (!$user) { + return Result::fail(FailReason::WRONG_CREDENTIALS); + } + + return Result::success($user); + } +} diff --git a/application/Espo/Core/Authentication/Logins/Espo.php b/application/Espo/Core/Authentication/Logins/Espo.php new file mode 100644 index 0000000..8ca92da --- /dev/null +++ b/application/Espo/Core/Authentication/Logins/Espo.php @@ -0,0 +1,92 @@ +. + * + * 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\Logins; + +use Espo\Core\Api\Request; +use Espo\Core\Authentication\AuthToken\AuthToken; +use Espo\Core\Authentication\Helper\UserFinder; +use Espo\Core\Authentication\Login; +use Espo\Core\Authentication\Login\Data; +use Espo\Core\Authentication\Result; +use Espo\Core\Authentication\Result\FailReason; +use Espo\Core\Utils\PasswordHash; +use Espo\Entities\User; + +class Espo implements Login +{ + public const string NAME = 'Espo'; + + public function __construct( + private UserFinder $userFinder, + private PasswordHash $passwordHash + ) {} + + public function login(Data $data, Request $request): Result + { + $username = $data->getUsername(); + $password = $data->getPassword(); + $authToken = $data->getAuthToken(); + + if (!$username) { + return Result::fail(FailReason::NO_USERNAME); + } + + if (!$password) { + return Result::fail(FailReason::NO_PASSWORD); + } + + if ($authToken) { + $user = $this->findUserByAuthTokenData($username, $authToken); + } else { + $user = $this->userFinder->find($username); + + if ($user && !$this->passwordHash->verify($password, $user->getPassword())) { + $user = null; + } + } + + if (!$user) { + return Result::fail(FailReason::WRONG_CREDENTIALS); + } + + if ($authToken && $user->getId() !== $authToken->getUserId()) { + return Result::fail(FailReason::USER_TOKEN_MISMATCH); + } + + return Result::success($user); + } + + + private function findUserByAuthTokenData(string $username, AuthToken $authToken): ?User + { + return $this->userFinder + ->findByAuthTokenData($username, $authToken->getUserId(), $authToken->getPasswordVersion()); + } +} diff --git a/application/Espo/Core/Authentication/Logins/Hmac.php b/application/Espo/Core/Authentication/Logins/Hmac.php new file mode 100644 index 0000000..84bb28f --- /dev/null +++ b/application/Espo/Core/Authentication/Logins/Hmac.php @@ -0,0 +1,90 @@ +. + * + * 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\Logins; + +use Espo\Core\Api\Request; +use Espo\Core\Authentication\Helper\UserFinder; +use Espo\Core\Authentication\Login; +use Espo\Core\Authentication\Login\Data; +use Espo\Core\Authentication\Result; +use Espo\Core\Authentication\Result\FailReason; +use Espo\Core\Utils\ApiKey; + +use RuntimeException; + +class Hmac implements Login +{ + public const string NAME = 'Hmac'; + + /** + * @since 10.1.0 + */ + public const string HEADER_HMAC_AUTHORIZATION = 'X-Hmac-Authorization'; + + public function __construct(private UserFinder $userFinder, private ApiKey $apiKeyUtil) + {} + + public function login(Data $data, Request $request): Result + { + $authString = base64_decode($request->getHeader(self::HEADER_HMAC_AUTHORIZATION) ?? ''); + + [$apiKey, $hash] = explode(':', $authString, 2); + + if (!$apiKey) { + return Result::fail(FailReason::WRONG_CREDENTIALS); + } + + $user = $this->userFinder->findApiHmac($apiKey); + + if (!$user) { + return Result::fail(FailReason::WRONG_CREDENTIALS); + } + + $secretKey = $this->apiKeyUtil->getSecretKeyForUserId($user->getId()); + + if (!$secretKey) { + throw new RuntimeException("No secret key for API user '" . $user->getId() . "'."); + } + + $string = $request->getMethod() . ' ' . $request->getResourcePath(); + + // As of v8.4.1. + if (hash_equals(ApiKey::hash($secretKey, $string), $hash)) { + return Result::success($user); + } + + // @todo Remove in v11.0. + if (hash_equals(ApiKey::hashLegacy($secretKey, $string), $hash)) { + return Result::success($user); + } + + return Result::fail(FailReason::HASH_NOT_MATCHED); + } +} diff --git a/application/Espo/Core/Authentication/Logout.php b/application/Espo/Core/Authentication/Logout.php new file mode 100644 index 0000000..4111619 --- /dev/null +++ b/application/Espo/Core/Authentication/Logout.php @@ -0,0 +1,42 @@ +. + * + * 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; + +use Espo\Core\Authentication\AuthToken\AuthToken; +use Espo\Core\Authentication\Logout\Params; +use Espo\Core\Authentication\Logout\Result as Result; + +/** + * Called on auth-token destroy. + */ +interface Logout +{ + public function logout(AuthToken $authToken, Params $params): Result; +} diff --git a/application/Espo/Core/Authentication/Logout/Params.php b/application/Espo/Core/Authentication/Logout/Params.php new file mode 100644 index 0000000..e5a45ed --- /dev/null +++ b/application/Espo/Core/Authentication/Logout/Params.php @@ -0,0 +1,43 @@ +. + * + * 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\Logout; + +/** + * Immutable. + */ +class Params +{ + private function __construct() {} + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Authentication/Logout/Result.php b/application/Espo/Core/Authentication/Logout/Result.php new file mode 100644 index 0000000..78cb6c2 --- /dev/null +++ b/application/Espo/Core/Authentication/Logout/Result.php @@ -0,0 +1,58 @@ +. + * + * 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\Logout; + +/** + * Immutable. + */ +class Result +{ + private ?string $redirectUrl = null; + + private function __construct() {} + + public static function create(): self + { + return new self(); + } + + public function withRedirectUrl(?string $redirectUrl): self + { + $obj = clone $this; + $obj->redirectUrl = $redirectUrl; + + return $obj; + } + + public function getRedirectUrl(): ?string + { + return $this->redirectUrl; + } +} diff --git a/application/Espo/Core/Authentication/LogoutFactory.php b/application/Espo/Core/Authentication/LogoutFactory.php new file mode 100644 index 0000000..789585f --- /dev/null +++ b/application/Espo/Core/Authentication/LogoutFactory.php @@ -0,0 +1,65 @@ +. + * + * 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; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +use RuntimeException; + +class LogoutFactory +{ + public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata) + {} + + public function create(string $method): Logout + { + $className = $this->getClassName($method); + + if (!$className) { + throw new RuntimeException(); + } + + return $this->injectableFactory->create($className); + } + + public function isCreatable(string $method): bool + { + return (bool) $this->getClassName($method); + } + + /** + * @return ?class-string + */ + private function getClassName(string $method): ?string + { + return $this->metadata->get(['authenticationMethods', $method, 'logoutClassName']); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php b/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php new file mode 100644 index 0000000..c987c7f --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/BackchannelLogout.php @@ -0,0 +1,107 @@ +. + * + * 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; + +use Espo\Core\Authentication\AuthToken\AuthToken; +use Espo\Core\Authentication\AuthToken\Manager as AuthTokenManager; +use Espo\Core\Authentication\Jwt\Exceptions\Invalid; +use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified; +use Espo\Core\Authentication\Jwt\Token; +use Espo\Core\Authentication\Jwt\Validator; +use Espo\Core\Authentication\Oidc\UserProvider\UserRepository; +use Espo\Core\Utils\Log; +use Espo\Entities\AuthToken as AuthTokenEntity; +use Espo\ORM\EntityManager; + +/** + * Compatible only with default Espo auth tokens. + * + * @todo Use a token-sessionId map to retrieve tokens. Send sid claim in id_token. + */ +class BackchannelLogout +{ + public function __construct( + private Log $log, + private Validator $validator, + private TokenValidator $tokenValidator, + private ConfigDataProvider $configDataProvider, + private UserRepository $userRepository, + private EntityManager $entityManager, + private AuthTokenManager $authTokenManger + ) {} + + /** + * @throws SignatureNotVerified + * @throws Invalid + */ + public function logout(string $rawToken): void + { + $token = Token::create($rawToken); + + $this->log->debug("OIDC logout: JWT header: " . $token->getHeaderRaw()); + $this->log->debug("OIDC logout: JWT payload: " . $token->getPayloadRaw()); + + $this->validator->validate($token); + $this->tokenValidator->validateSignature($token); + $this->tokenValidator->validateFields($token); + + $usernameClaim = $this->configDataProvider->getUsernameClaim(); + + if (!$usernameClaim) { + throw new Invalid("No username claim in config."); + } + + $username = $token->getPayload()->get($usernameClaim); + + if (!$username) { + throw new Invalid("No username claim `$usernameClaim` in token."); + } + + $user = $this->userRepository->findByUsername($username); + + if (!$user) { + return; + } + + $authTokenList = $this->entityManager + ->getRDBRepositoryByClass(AuthTokenEntity::class) + ->where([ + 'userId' => $user->getId(), + 'isActive' => true, + ]) + ->find(); + + foreach ($authTokenList as $authToken) { + assert($authToken instanceof AuthToken); + + $this->authTokenManger->inactivate($authToken); + } + } +} diff --git a/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php b/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php new file mode 100644 index 0000000..0182b46 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php @@ -0,0 +1,239 @@ +. + * + * 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; + +use Espo\Core\ApplicationState; +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Core\Utils\Config; +use Espo\Entities\AuthenticationProvider; +use stdClass; + +class ConfigDataProvider +{ + private const JWKS_CACHE_PERIOD = '10 minutes'; + + private Config|AuthenticationProvider $object; + + public function __construct( + private Config $config, + private ApplicationState $applicationState, + private EntityManagerProxy $entityManager + ) { + $this->object = $this->getAuthenticationProvider() ?? $this->config; + } + + private function isAuthenticationProvider(): bool + { + return $this->object instanceof AuthenticationProvider; + } + + private function getAuthenticationProvider(): ?AuthenticationProvider + { + if (!$this->applicationState->isPortal()) { + return null; + } + + $link = $this->applicationState->getPortal()->getAuthenticationProvider(); + + if (!$link) { + return null; + } + + /** @var ?AuthenticationProvider */ + return $this->entityManager->getEntityById(AuthenticationProvider::ENTITY_TYPE, $link->getId()); + } + + public function getSiteUrl(): string + { + $siteUrl = $this->isAuthenticationProvider() ? + $this->applicationState->getPortal()->getUrl() : + $this->config->get('siteUrl'); + + return rtrim($siteUrl, '/'); + } + + public function getRedirectUri(): string + { + return $this->getSiteUrl() . '/oauth-callback.php'; + } + + public function getClientId(): ?string + { + return $this->object->get('oidcClientId'); + } + + public function getClientSecret(): ?string + { + return $this->object->get('oidcClientSecret'); + } + + public function getAuthorizationEndpoint(): ?string + { + return $this->object->get('oidcAuthorizationEndpoint'); + } + + public function getTokenEndpoint(): ?string + { + return $this->object->get('oidcTokenEndpoint'); + } + + public function getUserInfoEndpoint(): ?string + { + return $this->object->get('oidcUserInfoEndpoint'); + } + + public function getJwksEndpoint(): ?string + { + return $this->object->get('oidcJwksEndpoint'); + } + + /** + * @return string[] + */ + public function getJwtSignatureAlgorithmList(): array + { + return $this->object->get('oidcJwtSignatureAlgorithmList') ?? []; + } + + /** + * @return string[] + */ + public function getScopes(): array + { + /** @var string[] */ + return $this->object->get('oidcScopes') ?? []; + } + + public function getLogoutUrl(): ?string + { + return $this->object->get('oidcLogoutUrl'); + } + + public function getUsernameClaim(): ?string + { + return $this->object->get('oidcUsernameClaim'); + } + + public function createUser(): bool + { + return (bool) $this->object->get('oidcCreateUser'); + } + + public function sync(): bool + { + return (bool) $this->object->get('oidcSync'); + } + + public function syncTeams(): bool + { + if ($this->isAuthenticationProvider()) { + return false; + } + + return (bool) $this->config->get('oidcSyncTeams'); + } + + public function fallback(): bool + { + if ($this->isAuthenticationProvider()) { + return false; + } + + return (bool) $this->config->get('oidcFallback'); + } + + public function allowRegularUserFallback(): bool + { + if ($this->isAuthenticationProvider()) { + return false; + } + + return (bool) $this->config->get('oidcAllowRegularUserFallback'); + } + + public function allowAdminUser(): bool + { + if ($this->isAuthenticationProvider()) { + return false; + } + + return (bool) $this->config->get('oidcAllowAdminUser'); + } + + public function getGroupClaim(): ?string + { + if ($this->isAuthenticationProvider()) { + return null; + } + + return $this->config->get('oidcGroupClaim'); + } + + /** + * @return ?string[] + */ + public function getTeamIds(): ?array + { + if ($this->isAuthenticationProvider()) { + return null; + } + + return $this->config->get('oidcTeamsIds') ?? []; + } + + public function getTeamColumns(): ?stdClass + { + if ($this->isAuthenticationProvider()) { + return null; + } + + return $this->config->get('oidcTeamsColumns') ?? (object) []; + } + + public function getAuthorizationPrompt(): string + { + 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'); + } + + public function getJwksCachePeriod(): string + { + return $this->config->get('oidcJwksCachePeriod') ?? self::JWKS_CACHE_PERIOD; + } +} diff --git a/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php b/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php new file mode 100644 index 0000000..140d56a --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/DefaultSignatureVerifierFactory.php @@ -0,0 +1,88 @@ +. + * + * 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; + +use Espo\Core\Authentication\Jwt\SignatureVerifier; +use Espo\Core\Authentication\Jwt\SignatureVerifierFactory; +use Espo\Core\Authentication\Jwt\SignatureVerifiers\Hmac; +use Espo\Core\Authentication\Jwt\SignatureVerifiers\Rsa; +use RuntimeException; + +class DefaultSignatureVerifierFactory implements SignatureVerifierFactory +{ + private const RS256 = 'RS256'; + private const RS384 = 'RS384'; + private const RS512 = 'RS512'; + private const HS256 = 'HS256'; + private const HS384 = 'HS384'; + private const HS512 = 'HS512'; + + private const ALGORITHM_VERIFIER_CLASS_NAME_MAP = [ + self::RS256 => Rsa::class, + self::RS384 => Rsa::class, + self::RS512 => Rsa::class, + self::HS256 => Hmac::class, + self::HS384 => Hmac::class, + self::HS512 => Hmac::class, + ]; + + public function __construct( + private KeysProvider $keysProvider, + private ConfigDataProvider $configDataProvider + ) {} + + public function create(string $algorithm): SignatureVerifier + { + /** @var ?class-string $className */ + $className = self::ALGORITHM_VERIFIER_CLASS_NAME_MAP[$algorithm] ?? null; + + if (!$className) { + throw new RuntimeException("Not supported algorithm $algorithm."); + } + + if ($className === Rsa::class) { + $keys = $this->keysProvider->get(); + + return new Rsa($algorithm, $keys); + } + + if ($className === Hmac::class) { + $key = $this->configDataProvider->getClientSecret(); + + if (!$key) { + throw new RuntimeException("No client secret."); + } + + return new Hmac($algorithm, $key); + } + + throw new RuntimeException(); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/KeysProvider.php b/application/Espo/Core/Authentication/Oidc/KeysProvider.php new file mode 100644 index 0000000..70dfb11 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/KeysProvider.php @@ -0,0 +1,201 @@ +. + * + * 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; + +use Espo\Core\Authentication\Jwt\Exceptions\UnsupportedKey; +use Espo\Core\Authentication\Jwt\Key; +use Espo\Core\Authentication\Jwt\KeyFactory; +use Espo\Core\Field\DateTime; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Json; +use Espo\Core\Utils\Log; +use JsonException; +use RuntimeException; +use stdClass; + +class KeysProvider +{ + private const CACHE_KEY = 'oidcJwks'; + private const REQUEST_TIMEOUT = 10; + + public function __construct( + private DataCache $dataCache, + private ConfigDataProvider $configDataProvider, + private KeyFactory $factory, + private Log $log, + private SystemConfig $systemConfig, + ) {} + + /** + * @return Key[] + */ + public function get(): array + { + $list = []; + + $rawKeys = $this->getRaw(); + + foreach ($rawKeys as $raw) { + try { + $list[] = $this->factory->create($raw); + } catch (UnsupportedKey) { + $this->log->debug("OIDC: Unsupported key " . print_r($raw, true)); + } + } + + return $list; + } + + /** + * @return stdClass[] + */ + private function getRaw(): array + { + $raw = $this->getRawFromCache(); + + if (!$raw) { + $raw = $this->load(); + + $this->storeRawToCache($raw); + } + + return $raw; + } + + /** + * @return stdClass[] + */ + private function load(): array + { + $endpoint = $this->configDataProvider->getJwksEndpoint(); + + if (!$endpoint) { + throw new RuntimeException("JSON Web Key Set endpoint not specified in settings."); + } + + $curl = curl_init(); + + curl_setopt_array($curl, [ + CURLOPT_URL => $endpoint, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => 'GET', + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP, + ]); + + /** @var string|false $response */ + $response = curl_exec($curl); + $error = curl_error($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + + if ($response === false) { + $response = ''; + } + + if ($error) { + throw new RuntimeException("OIDC: JWKS request error. Status: $status."); + } + + $parsedResponse = null; + + try { + $parsedResponse = Json::decode($response); + } catch (JsonException) {} + + if (!$parsedResponse instanceof stdClass || !isset($parsedResponse->keys)) { + throw new RuntimeException("OIDC: JWKS bad response."); + } + + return $parsedResponse->keys; + } + + /** + * @return ?stdClass[] + */ + private function getRawFromCache(): ?array + { + if (!$this->systemConfig->useCache()) { + return null; + } + + if (!$this->dataCache->has(self::CACHE_KEY)) { + return null; + } + + $data = $this->dataCache->get(self::CACHE_KEY); + + if (!$data instanceof stdClass) { + return null; + } + + /** @var ?int $timestamp */ + $timestamp = $data->timestamp; + + if (!$timestamp) { + return null; + } + + $period = '-' . $this->configDataProvider->getJwksCachePeriod(); + + if ($timestamp < DateTime::createNow()->modify($period)->toTimestamp()) { + return null; + } + + /** @var ?stdClass[] $keys */ + $keys = $data->keys ?? null; + + if ($keys === null) { + return null; + } + + return $keys; + } + + /** + * @param stdClass[] $raw + */ + private function storeRawToCache(array $raw): void + { + if (!$this->systemConfig->useCache()) { + return; + } + + $data = (object) [ + 'timestamp' => time(), + 'keys' => $raw, + ]; + + $this->dataCache->store(self::CACHE_KEY, $data); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/Login.php b/application/Espo/Core/Authentication/Oidc/Login.php new file mode 100644 index 0000000..d1db93a --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/Login.php @@ -0,0 +1,334 @@ +. + * + * 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; + +use Espo\Core\Api\Request; +use Espo\Core\ApplicationState; +use Espo\Core\Authentication\Login as LoginInterface; +use Espo\Core\Authentication\Login\Data; +use Espo\Core\Authentication\Jwt\Token; +use Espo\Core\Authentication\Logins\Espo; +use Espo\Core\Authentication\Jwt\Exceptions\Invalid; +use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified; +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; +use LogicException; +use RuntimeException; +use SensitiveParameter; +use stdClass; + +class Login implements LoginInterface +{ + public const NAME = 'Oidc'; + + private const OIDC_USERNAME = '**oidc'; + 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, + private ConfigDataProvider $configDataProvider, + private Validator $validator, + private TokenValidator $tokenValidator, + private UserProvider $userProvider, + private ApplicationState $applicationState, + private UserInfoDataProvider $userInfoDataProvider, + private Session $session, + ) {} + + public function login(Data $data, Request $request): Result + { + if ($data->getUsername() !== self::OIDC_USERNAME) { + return $this->loginFallback($data, $request); + } + + $code = $data->getPassword(); + + if (!$code) { + return Result::fail(FailReason::NO_PASSWORD); + } + + return $this->loginWithCode($code, $request); + } + + private function loginWithCode(string $code, Request $request): Result + { + $endpoint = $this->configDataProvider->getTokenEndpoint(); + $clientId = $this->configDataProvider->getClientId(); + $clientSecret = $this->configDataProvider->getClientSecret(); + $redirectUri = $this->configDataProvider->getRedirectUri(); + + if (!$endpoint) { + throw new RuntimeException("No token endpoint."); + } + + if (!$clientId) { + throw new RuntimeException("No client ID."); + } + + if (!$clientSecret) { + throw new RuntimeException("No client secret."); + } + + [$rawToken, $failResult, $accessToken] = + $this->requestToken($endpoint, $clientId, $code, $redirectUri, $clientSecret); + + if ($failResult) { + return $failResult; + } + + if (!$rawToken) { + throw new LogicException(); + } + + try { + $token = Token::create($rawToken); + } catch (RuntimeException $e) { + $message = self::composeLogMessage('JWT parsing error.'); + + if ($e->getMessage()) { + $message .= " " . $e->getMessage(); + } + + $this->log->error($message); + + throw new RuntimeException("JWT parsing error."); + } + + $this->log->debug("OIDC: JWT header: " . $token->getHeaderRaw()); + $this->log->debug("OIDC: JWT payload: " . $token->getPayloadRaw()); + + try { + $this->validateToken($token); + } catch (Invalid $e) { + $this->log->error("OIDC: " . $e->getMessage()); + + return Result::fail(FailReason::DENIED); + } + + $tokenPayload = $token->getPayload(); + + $nonce = $request->getHeader(self::NONCE_HEADER); + + if ($nonce && $nonce !== $tokenPayload->getNonce()) { + $this->log->warning(self::composeLogMessage('JWT nonce mismatch.')); + + return Result::fail(FailReason::DENIED); + } + + $userInfo = $this->getUserInfo($tokenPayload, $accessToken); + + $user = $this->userProvider->get($userInfo); + + if (!$user) { + $this->log->debug("OIDC: User not found."); + + return Result::fail(FailReason::USER_NOT_FOUND); + } + + return Result::success($user)->withBypassSecondStep(); + } + + private function loginFallback(Data $data, Request $request): Result + { + if ( + !$data->getAuthToken() && + !$this->configDataProvider->fallback() + ) { + return Result::fail(FailReason::METHOD_NOT_ALLOWED); + } + + if ( + !$data->getAuthToken() && + $this->applicationState->isPortal() + ) { + return Result::fail(FailReason::METHOD_NOT_ALLOWED); + } + + $result = $this->espoLogin->login($data, $request); + + $user = $result->getUser(); + + if (!$user) { + return $result; + } + + if ($data->getAuthToken()) { + // Allow fallback when logged by auth token. + return $result; + } + + if ( + $user->isRegular() && + !$this->configDataProvider->allowRegularUserFallback() + // Portal users are allowed. + ) { + return Result::fail(FailReason::METHOD_NOT_ALLOWED); + } + + if ($user->isPortal()) { + return Result::fail(FailReason::METHOD_NOT_ALLOWED); + } + + return $result; + } + + /** + * @param non-empty-string $endpoint + * @return array{?string, ?Result, ?string} + */ + private function requestToken( + string $endpoint, + string $clientId, + string $code, + string $redirectUri, + string $clientSecret + ): array { + + $params = [ + 'grant_type' => 'authorization_code', + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'code' => $code, + '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, [ + CURLOPT_URL => $endpoint, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_POSTFIELDS => http_build_query($params), + CURLOPT_HTTPHEADER => ['content-type: application/x-www-form-urlencoded'], + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP, + ]); + + /** @var string|false $response */ + $response = curl_exec($curl); + $error = curl_error($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + + if ($response === false) { + $response = ''; + } + + if ($error || is_int($status) && ($status >= 400 && $status < 500)) { + if ($status === 400) { + $this->log->error(self::composeLogMessage('Bad token request.', $status, $response)); + + throw new RuntimeException(); + } + + $this->log->warning(self::composeLogMessage('Token request error.', $status, $response)); + + return [null, Result::fail(FailReason::DENIED), null]; + } + + $parsedResponse = null; + + try { + $parsedResponse = Json::decode($response); + } catch (JsonException) {} + + if (!$parsedResponse instanceof stdClass) { + $this->log->error(self::composeLogMessage('Bad token response.', $status, $response)); + + throw new RuntimeException(); + } + + $token = $parsedResponse->id_token ?? null; + $accessToken = $parsedResponse->access_token ?? null; + + if (!$token || !is_string($token)) { + $this->log->error(self::composeLogMessage('Bad token response.', $status, $response)); + + throw new RuntimeException(); + } + + return [$token, null, $accessToken]; + } + + private static function composeLogMessage(string $text, ?int $status = null, ?string $response = null): string + { + if ($status === null) { + return "OIDC: $text"; + } + + return "OIDC: $text; Status: $status; Response: $response"; + } + + /** + * @throws SignatureNotVerified + * @throws Invalid + */ + private function validateToken(Token $token): void + { + $this->validator->validate($token); + $this->tokenValidator->validateFields($token); + $this->tokenValidator->validateSignature($token); + } + + private function getUserInfo(Token\Payload $payload, #[SensitiveParameter] ?string $accessToken): UserInfo + { + $endpoint = $this->configDataProvider->getUserInfoEndpoint(); + + if (!$endpoint) { + return new UserInfo($payload, []); + } + + if (!$accessToken) { + throw new RuntimeException("OIDC: No access token received."); + } + + $data = $this->userInfoDataProvider->get($accessToken); + + return new UserInfo($payload, $data); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/Logout.php b/application/Espo/Core/Authentication/Oidc/Logout.php new file mode 100644 index 0000000..2d24670 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/Logout.php @@ -0,0 +1,61 @@ +. + * + * 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; + +use Espo\Core\Authentication\AuthToken\AuthToken; +use Espo\Core\Authentication\Logout as LogoutInterface; +use Espo\Core\Authentication\Logout\Params; +use Espo\Core\Authentication\Logout\Result; + +/** + * @noinspection PhpUnused + */ +class Logout implements LogoutInterface +{ + public function __construct( + private ConfigDataProvider $configDataProvider + ) {} + + public function logout(AuthToken $authToken, Params $params): Result + { + $url = $this->configDataProvider->getLogoutUrl(); + $clientId = $this->configDataProvider->getClientId() ?? ''; + $siteUrl = $this->configDataProvider->getSiteUrl(); + + if ($url) { + $url = str_replace('{clientId}', urlencode($clientId), $url); + $url = str_replace('{siteUrl}', urlencode($siteUrl), $url); + } + + // @todo Check session is set in auth token to bypass fallback logins. + + return Result::create()->withRedirectUrl($url); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/PkceUtil.php b/application/Espo/Core/Authentication/Oidc/PkceUtil.php new file mode 100644 index 0000000..e365649 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/PkceUtil.php @@ -0,0 +1,54 @@ +. + * + * 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), '+/', '-_'), '='); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/TokenValidator.php b/application/Espo/Core/Authentication/Oidc/TokenValidator.php new file mode 100644 index 0000000..22686e5 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/TokenValidator.php @@ -0,0 +1,89 @@ +. + * + * 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; + +use Espo\Core\Authentication\Jwt\Exceptions\Invalid; +use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified; +use Espo\Core\Authentication\Jwt\SignatureVerifierFactory; +use Espo\Core\Authentication\Jwt\Token; +use RuntimeException; + +class TokenValidator +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + private SignatureVerifierFactory $signatureVerifierFactory + ) {} + + /** + * @throws SignatureNotVerified + * @throws Invalid + */ + public function validateSignature(Token $token): void + { + $algorithm = $token->getHeader()->getAlg(); + + $allowedAlgorithmList = $this->configDataProvider->getJwtSignatureAlgorithmList(); + + if (!in_array($algorithm, $allowedAlgorithmList)) { + throw new Invalid("JWT signing algorithm `$algorithm` not allowed."); + } + + $verifier = $this->signatureVerifierFactory->create($algorithm); + + if (!$verifier->verify($token)) { + throw new SignatureNotVerified("JWT signature not verified."); + } + } + + /** + * @throws Invalid + */ + public function validateFields(Token $token): void + { + $oidcClientId = $this->configDataProvider->getClientId(); + + if (!$oidcClientId) { + throw new RuntimeException("OIDC: No client ID."); + } + + if (!in_array($oidcClientId, $token->getPayload()->getAud())) { + throw new Invalid("JWT the `aud` field does not contain matching client ID."); + } + + if (!$token->getPayload()->getSub()) { + throw new Invalid("JWT does not contain the `sub` value."); + } + + if (!$token->getPayload()->getIss()) { + throw new Invalid("JWT does not contain the `iss` value."); + } + } +} diff --git a/application/Espo/Core/Authentication/Oidc/UserInfoDataProvider.php b/application/Espo/Core/Authentication/Oidc/UserInfoDataProvider.php new file mode 100644 index 0000000..5347ff4 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserInfoDataProvider.php @@ -0,0 +1,119 @@ +. + * + * 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; + +use Espo\Core\Utils\Json; +use Espo\Core\Utils\Log; +use JsonException; +use RuntimeException; +use SensitiveParameter; + +class UserInfoDataProvider +{ + private const REQUEST_TIMEOUT = 10; + + public function __construct( + private ConfigDataProvider $configDataProvider, + private Log $log, + ) {} + + /** + * @return array + */ + public function get(#[SensitiveParameter] string $accessToken): array + { + return $this->load($accessToken); + } + + /** + * @return array + */ + private function load(#[SensitiveParameter] string $accessToken): array + { + $endpoint = $this->configDataProvider->getUserInfoEndpoint(); + + if (!$endpoint) { + throw new RuntimeException("No userinfo endpoint."); + } + + $curl = curl_init(); + + curl_setopt_array($curl, [ + CURLOPT_URL => $endpoint, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => 'GET', + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $accessToken, + 'Accept: application/json', + ], + ]); + + /** @var string|false $response */ + $response = curl_exec($curl); + $error = curl_error($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + + if ($response === false) { + $response = ''; + } + + if ($error || is_int($status) && ($status >= 400 && $status < 500)) { + $this->log->error(self::composeLogMessage('UserInfo response error.', $status, $response)); + + throw new RuntimeException("OIDC: Userinfo request error."); + } + + $parsedResponse = null; + + try { + $parsedResponse = Json::decode($response, true); + } catch (JsonException) {} + + if (!is_array($parsedResponse)) { + throw new RuntimeException("OIDC: Bad userinfo response."); + } + + return $parsedResponse; + } + + private static function composeLogMessage(string $text, ?int $status = null, ?string $response = null): string + { + if ($status === null) { + return "OIDC: $text"; + } + + return "OIDC: $text; Status: $status; Response: $response"; + } +} diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider.php b/application/Espo/Core/Authentication/Oidc/UserProvider.php new file mode 100644 index 0000000..98da961 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserProvider.php @@ -0,0 +1,38 @@ +. + * + * 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; + +use Espo\Core\Authentication\Oidc\UserProvider\UserInfo; +use Espo\Entities\User; + +interface UserProvider +{ + public function get(UserInfo $userInfo): ?User; +} diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserInfoPopulator.php b/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserInfoPopulator.php new file mode 100644 index 0000000..d6772e9 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserInfoPopulator.php @@ -0,0 +1,60 @@ +. + * + * 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\UserProvider; + +use Espo\Entities\User; + +class DefaultUserInfoPopulator implements UserInfoPopulator +{ + public function populate(UserInfo $userInfo, User $user): void + { + $values = $this->getUserDataFromToken($userInfo); + + $user->setMultiple($values); + } + + /** + * @return array + */ + private function getUserDataFromToken(UserInfo $userInfo): array + { + return [ + 'emailAddress' => $userInfo->get('email'), + 'phoneNumber' => $userInfo->get('phone_number'), + 'firstName' => $userInfo->get('given_name'), + 'lastName' => $userInfo->get('family_name'), + 'middleName' => $userInfo->get('middle_name'), + 'gender' => + in_array($userInfo->get('gender'), ['male', 'female']) ? + ucfirst($userInfo->get('gender') ?? '') : + null, + ]; + } +} diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserProvider.php b/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserProvider.php new file mode 100644 index 0000000..117440d --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/DefaultUserProvider.php @@ -0,0 +1,171 @@ +. + * + * 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\UserProvider; + +use Espo\Core\ApplicationState; +use Espo\Core\Authentication\Oidc\ConfigDataProvider; +use Espo\Core\Authentication\Oidc\UserProvider; +use Espo\Core\Utils\Log; +use Espo\Entities\User; + +use RuntimeException; + +class DefaultUserProvider implements UserProvider +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + private Sync $sync, + private UserRepository $userRepository, + private ApplicationState $applicationState, + private Log $log, + ) {} + + public function get(UserInfo $userInfo): ?User + { + $user = $this->findUser($userInfo); + + if ($user === false) { + return null; + } + + if ($user) { + $this->syncUser($user, $userInfo); + + return $user; + } + + return $this->tryToCreateUser($userInfo); + } + + /** + * @return User|false|null + */ + private function findUser(UserInfo $userInfo): User|bool|null + { + $usernameClaim = $this->configDataProvider->getUsernameClaim(); + + if (!$usernameClaim) { + throw new RuntimeException("No username claim in config."); + } + + $username = $userInfo->get($usernameClaim); + + if (!$username) { + throw new RuntimeException("No username claim `$usernameClaim` in token and userinfo."); + } + + $username = $this->sync->normalizeUsername($username); + + $user = $this->userRepository->findByUsername($username); + + if (!$user) { + return null; + } + + $userId = $user->getId(); + + if (!$user->isActive()) { + $this->log->info("Oidc: User $userId found but it's not active."); + + return false; + } + + $isPortal = $this->applicationState->isPortal(); + + if (!$isPortal && !$user->isRegular() && !$user->isEffectiveAdmin()) { + $this->log->info("Oidc: User $userId found but it's neither regular user nor admin."); + + return false; + } + + if ($isPortal && !$user->isPortal()) { + $this->log->info("Oidc: User $userId found but it's not portal user."); + + return false; + } + + if ($isPortal) { + $portalId = $this->applicationState->getPortalId(); + + if (!$user->getPortals()->hasId($portalId)) { + $this->log->info("Oidc: User $userId found but it's not related to current portal."); + + return false; + } + } + + if ($user->isSuperAdmin()) { + $this->log->info("Oidc: User $userId found but it's super-admin, not allowed."); + + return false; + } + + if ($user->isAdmin() && !$this->configDataProvider->allowAdminUser()) { + $this->log->info("Oidc: User $userId found but it's admin, not allowed."); + + return false; + } + + return $user; + } + + private function tryToCreateUser(UserInfo $userInfo): ?User + { + if (!$this->configDataProvider->createUser()) { + return null; + } + + $usernameClaim = $this->configDataProvider->getUsernameClaim(); + + if (!$usernameClaim) { + throw new RuntimeException("Could not create a user. No OIDC username claim in config."); + } + + $username = $userInfo->get($usernameClaim); + + if (!$username) { + throw new RuntimeException("Could not create a user. No username claim in token and userinfo."); + } + + return $this->sync->createUser($userInfo); + } + + private function syncUser(User $user, UserInfo $userInfo): void + { + if ( + !$this->configDataProvider->sync() && + !$this->configDataProvider->syncTeams() + ) { + return; + } + + $this->sync->syncUser($user, $userInfo); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/Sync.php b/application/Espo/Core/Authentication/Oidc/UserProvider/Sync.php new file mode 100644 index 0000000..8b2d79d --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/Sync.php @@ -0,0 +1,228 @@ +. + * + * 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\UserProvider; + +use Espo\Core\Acl\Cache\Clearer as AclCacheClearer; +use Espo\Core\ApplicationState; +use Espo\Core\Authentication\Oidc\ConfigDataProvider; +use Espo\Core\Field\LinkMultiple; +use Espo\Core\Name\Field; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\PasswordHash; +use Espo\Core\Utils\Util; +use Espo\Entities\User; +use RuntimeException; + +class Sync +{ + public function __construct( + private UsernameValidator $usernameValidator, + private Config $config, + private ConfigDataProvider $configDataProvider, + private UserRepository $userRepository, + private PasswordHash $passwordHash, + private AclCacheClearer $aclCacheClearer, + private ApplicationState $applicationState, + private UserInfoPopulator $userInfoPopulator, + ) {} + + public function createUser(UserInfo $userInfo): User + { + $username = $this->getUsernameFromToken($userInfo); + + $this->usernameValidator->validate($username); + + $user = $this->userRepository->getNew(); + + $user->setType(User::TYPE_REGULAR); + $user->setUserName($username); + + $user->setMultiple([ + 'password' => $this->passwordHash->hash(Util::generatePassword(10, 4, 2, true)), + ]); + + $this->userInfoPopulator->populate($userInfo, $user); + $user->set($this->getUserTeamsDataFromToken($userInfo)); + + if ($this->applicationState->isPortal()) { + $portalId = $this->applicationState->getPortalId(); + + $user->setType(User::TYPE_PORTAL); + $user->setPortals(LinkMultiple::create()->withAddedId($portalId)); + } + + $this->userRepository->save($user); + + return $user; + } + + public function syncUser(User $user, UserInfo $payload): void + { + $username = $this->getUsernameFromToken($payload); + + $this->usernameValidator->validate($username); + + if ($user->getUserName() !== $username) { + throw new RuntimeException("Could not sync user. Username mismatch."); + } + + if ($this->configDataProvider->sync()) { + $this->userInfoPopulator->populate($payload, $user); + } + + $clearAclCache = false; + + if ($this->configDataProvider->syncTeams()) { + $user->loadLinkMultipleField(Field::TEAMS); + + $user->set($this->getUserTeamsDataFromToken($payload)); + + $clearAclCache = $user->isAttributeChanged('teamsIds'); + } + + $this->userRepository->save($user); + + if ($clearAclCache) { + $this->aclCacheClearer->clearForUser($user); + } + } + + /** + * @return array + */ + private function getUserTeamsDataFromToken(UserInfo $userInfo): array + { + return [ + 'teamsIds' => $this->getTeamIdList($userInfo), + ]; + } + + private function getUsernameFromToken(UserInfo $userInfo): string + { + $usernameClaim = $this->configDataProvider->getUsernameClaim(); + + if (!$usernameClaim) { + throw new RuntimeException("No OIDC username claim in config."); + } + + $username = $userInfo->get($usernameClaim); + + if (!$username) { + throw new RuntimeException("No username claim returned in token."); + } + + if (!is_string($username)) { + throw new RuntimeException("Bad username claim returned in token."); + } + + return $this->normalizeUsername($username); + } + + /** + * @return string[] + */ + private function getTeamIdList(UserInfo $userInfo): array + { + $idList = $this->configDataProvider->getTeamIds() ?? []; + $columns = $this->configDataProvider->getTeamColumns() ?? (object) []; + + if ($idList === []) { + return []; + } + + $groupList = $this->getGroups($userInfo); + + $resultIdList = []; + + foreach ($idList as $id) { + $group = ($columns->$id ?? (object) [])->group ?? null; + + if (!$group || in_array($group, $groupList)) { + $resultIdList[] = $id; + } + } + + return $resultIdList; + } + + /** + * @return string[] + */ + private function getGroups(UserInfo $userInfo): array + { + $groupClaim = $this->configDataProvider->getGroupClaim(); + + if (!$groupClaim) { + return []; + } + + $value = $userInfo->get($groupClaim); + + if (!$value) { + return []; + } + + if (is_string($value)) { + return [$value]; + } + + if (!is_array($value)) { + return []; + } + + $list = []; + + foreach ($value as $item) { + if (is_string($item)) { + $list[] = $item; + } + } + + return $list; + } + + public function normalizeUsername(string $username): string + { + /** @var ?string $regExp */ + $regExp = $this->config->get('userNameRegularExpression'); + + if (!$regExp) { + throw new RuntimeException("No `userNameRegularExpression` in config."); + } + + $username = strtolower($username); + + /** @var string $result */ + $result = preg_replace("/$regExp/", '_', $username); + + /** @var string */ + return str_replace(' ', '_', $result); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/UserInfo.php b/application/Espo/Core/Authentication/Oidc/UserProvider/UserInfo.php new file mode 100644 index 0000000..13eb4a6 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/UserInfo.php @@ -0,0 +1,49 @@ +. + * + * 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\UserProvider; + +use Espo\Core\Authentication\Jwt\Token\Payload; + +class UserInfo +{ + /** + * @internal + * @param array $data + */ + public function __construct( + private Payload $payload, + private array $data, + ) {} + + public function get(string $name): mixed + { + return $this->payload->get($name) ?? $this->data[$name] ?? null; + } +} diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/UserInfoPopulator.php b/application/Espo/Core/Authentication/Oidc/UserProvider/UserInfoPopulator.php new file mode 100644 index 0000000..d902754 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/UserInfoPopulator.php @@ -0,0 +1,40 @@ +. + * + * 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\UserProvider; + +use Espo\Entities\User; + +/** + * @since 10.0.0 + */ +interface UserInfoPopulator +{ + public function populate(UserInfo $userInfo, User $user): void; +} diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/UserRepository.php b/application/Espo/Core/Authentication/Oidc/UserProvider/UserRepository.php new file mode 100644 index 0000000..f4e59f0 --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/UserRepository.php @@ -0,0 +1,85 @@ +. + * + * 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\UserProvider; + +use Espo\Core\FieldProcessing\EmailAddress\Saver as EmailAddressSaver; +use Espo\Core\FieldProcessing\PhoneNumber\Saver as PhoneNumberSaver; +use Espo\Core\FieldProcessing\Relation\LinkMultipleSaver; +use Espo\Core\FieldProcessing\Saver\Params as SaverParams; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Entities\User; +use Espo\ORM\EntityManager; + +class UserRepository +{ + public function __construct( + private EntityManager $entityManager, + private LinkMultipleSaver $linkMultipleSaver, + private EmailAddressSaver $emailAddressSaver, + private PhoneNumberSaver $phoneNumberSaver + ) {} + + public function getNew(): User + { + return $this->entityManager->getRDBRepositoryByClass(User::class)->getNew(); + } + + public function save(User $user): void + { + $this->entityManager->saveEntity($user, [ + // Prevent `user` service being loaded by hooks. + SaveOption::SKIP_HOOKS => true, + SaveOption::KEEP_NEW => true, + SaveOption::KEEP_DIRTY => true, + ]); + + $saverParams = SaverParams::create()->withRawOptions(['skipLinkMultipleHooks' => true]); + + $this->linkMultipleSaver->process($user, Field::TEAMS, $saverParams); + $this->linkMultipleSaver->process($user, 'portals', $saverParams); + $this->linkMultipleSaver->process($user, 'portalRoles', $saverParams); + $this->emailAddressSaver->process($user, $saverParams); + $this->phoneNumberSaver->process($user, $saverParams); + + $user->setAsNotNew(); + $user->updateFetchedValues(); + + $this->entityManager->refreshEntity($user); + } + + public function findByUsername(string $username): ?User + { + return $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where(['userName' => $username]) + ->findOne(); + } +} diff --git a/application/Espo/Core/Authentication/Oidc/UserProvider/UsernameValidator.php b/application/Espo/Core/Authentication/Oidc/UserProvider/UsernameValidator.php new file mode 100644 index 0000000..9f14b1d --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/UserProvider/UsernameValidator.php @@ -0,0 +1,53 @@ +. + * + * 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\UserProvider; + +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use RuntimeException; + +class UsernameValidator +{ + public function __construct(private EntityManager $entityManager) {} + + public function validate(string $username): void + { + $maxLength = $this->entityManager + ->getDefs() + ->getEntity(User::ENTITY_TYPE) + ->getAttribute('userName') + ->getLength(); + + if ($maxLength && $maxLength < strlen($username)) { + throw new RuntimeException("Value in username claim exceeds max length of `$maxLength`. " . + "Increase maxLength parameter for User.userName field (up to 255)."); + } + } +} diff --git a/application/Espo/Core/Authentication/Repository/AuthLogRecordRepository.php b/application/Espo/Core/Authentication/Repository/AuthLogRecordRepository.php new file mode 100644 index 0000000..9e614e9 --- /dev/null +++ b/application/Espo/Core/Authentication/Repository/AuthLogRecordRepository.php @@ -0,0 +1,58 @@ +. + * + * 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\Repository; + +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Entities\AuthLogRecord; + +class AuthLogRecordRepository +{ + public function __construct( + private EntityManagerProxy $entityManager, + ) {} + + /** + * @param ?string[] $select + */ + public function findOneByAuthTokenId(string $authTokenId, ?array $select = null): ?AuthLogRecord + { + $builder = $this->entityManager + ->getRDBRepositoryByClass(AuthLogRecord::class); + + if ($select) { + $builder->select($select); + } + + return $builder + ->where([AuthLogRecord::ATTR_AUTH_TOKEN_ID => $authTokenId]) + ->order(AuthLogRecord::FIELD_REQUEST_TIME, true) + ->findOne(); + } +} diff --git a/application/Espo/Core/Authentication/Repository/UserRepository.php b/application/Espo/Core/Authentication/Repository/UserRepository.php new file mode 100644 index 0000000..9357dff --- /dev/null +++ b/application/Espo/Core/Authentication/Repository/UserRepository.php @@ -0,0 +1,66 @@ +. + * + * 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\Repository; + +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; + +class UserRepository +{ + public function __construct( + private EntityManagerProxy $entityManager, + ) {} + + /** + * @param ?string[] $select + */ + public function findOneById(string $id, ?array $select = null): ?User + { + $builder = $this->entityManager + ->getRDBRepositoryByClass(User::class); + + if ($select) { + $builder->select($select); + } + + return $builder + ->where([Attribute::ID => $id]) + ->findOne(); + } + + public function findOneByUsername(string $username): ?User + { + return $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([User::FIELD_USER_NAME => $username]) + ->findOne(); + } +} diff --git a/application/Espo/Core/Authentication/Result.php b/application/Espo/Core/Authentication/Result.php new file mode 100644 index 0000000..2829f7c --- /dev/null +++ b/application/Espo/Core/Authentication/Result.php @@ -0,0 +1,222 @@ +. + * + * 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; + +use Espo\Core\Api\Response; +use Espo\Core\Authentication\Result\Data; +use Espo\Entities\User; + +use stdClass; + +/** + * An authentication result. + * + * Immutable. + */ +class Result +{ + public const string STATUS_SUCCESS = 'success'; + public const string STATUS_SECOND_STEP_REQUIRED = 'secondStepRequired'; + public const string STATUS_FAIL = 'fail'; + + private ?User $user; + private string $status; + private ?string $message = null; + private ?string $view = null; + private ?string $failReason = null; + private bool $bypassSecondStep = false; + private ?Data $data; + private ?Response $response = null; + + private function __construct(string $status, ?User $user = null, ?Data $data = null) + { + $this->user = $user; + $this->status = $status; + + $this->data = $data; + + if ($data) { + $this->message = $data->getMessage(); + $this->view = $data->getView(); + $this->failReason = $data->getFailReason(); + } + } + + /** + * Create an instance for a successful login. + */ + public static function success(User $user): self + { + return new self(self::STATUS_SUCCESS, $user); + } + + /** + * Create an instance for a failed login. + * + * @param ?string $reason A fail reason. + * @param ?Response $response An error response. + */ + public static function fail(?string $reason = null, ?Response $response = null): self + { + $data = $reason ? + Data::createWithFailReason($reason) : + Data::create(); + + $object = new self( + status: self::STATUS_FAIL, + data: $data, + ); + + $object->response = $response; + + return $object; + } + + /** + * Create an instance for a login requiring a second step. E.g. for 2FA. + */ + public static function secondStepRequired(User $user, Data $data): self + { + return new self(self::STATUS_SECOND_STEP_REQUIRED, $user, $data); + } + + /** + * Login is successful. + */ + public function isSuccess(): bool + { + return $this->status === self::STATUS_SUCCESS; + } + + /** + * The second step is required. + */ + public function isSecondStepRequired(): bool + { + return $this->status === self::STATUS_SECOND_STEP_REQUIRED; + } + + /** + * To bypass the second step. + * + * @since 8.4.0 + */ + public function bypassSecondStep(): bool + { + return $this->bypassSecondStep; + } + + /** + * Login is failed. + */ + public function isFail(): bool + { + return $this->status === self::STATUS_FAIL; + } + + /** + * Get a user. + */ + public function getUser(): ?User + { + return $this->user; + } + + /** + * @deprecated Use `getUser`. + */ + public function getLoggedUser(): ?User + { + return $this->user; + } + + /** + * A status. + */ + public function getStatus(): string + { + return $this->status; + } + + /** + * A client view to redirect to for a second step. + */ + public function getView(): ?string + { + return $this->view; + } + + /** + * A message to show to a user for a second step. + */ + public function getMessage(): ?string + { + return $this->message; + } + + /** + * Additional data that can be needed for the second step. + */ + public function getData(): ?stdClass + { + return $this->data?->getData(); + } + + /** + * A fail reason. + */ + public function getFailReason(): ?string + { + return $this->failReason; + } + + /** + * Clone with bypass second step. + * + * @since 8.4.0 + */ + public function withBypassSecondStep(bool $bypassSecondStep = true): self + { + $obj = clone $this; + $obj->bypassSecondStep = $bypassSecondStep; + + return $obj; + } + + /** + * An error response. + * + * @since 10.1.0 + */ + public function getResponse(): ?Response + { + return $this->response; + } +} diff --git a/application/Espo/Core/Authentication/Result/Data.php b/application/Espo/Core/Authentication/Result/Data.php new file mode 100644 index 0000000..604a987 --- /dev/null +++ b/application/Espo/Core/Authentication/Result/Data.php @@ -0,0 +1,125 @@ +. + * + * 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\Result; + +use stdClass; + +/** + * Immutable. + */ +class Data +{ + /** + * @since 10.1.0 + */ + public const string PARAM_CODE_LENGTH = 'codeLength'; + + /** @var array */ + private array $data = []; + + /** @noinspection PhpSameParameterValueInspection */ + private function __construct( + private ?string $message = null, + private ?string $failReason = null, + private ?string $view = null, + ) {} + + public static function create(): self + { + return new self(); + } + + public static function createWithFailReason(string $failReason): self + { + return new self(null, $failReason); + } + + public static function createWithMessage(string $message): self + { + return new self($message); + } + + public function getView(): ?string + { + return $this->view; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function getFailReason(): ?string + { + return $this->failReason; + } + + public function getData(): stdClass + { + return (object) $this->data; + } + + public function withMessage(?string $message): self + { + $obj = clone $this; + $obj->message = $message; + + return $obj; + } + + /** @noinspection PhpUnused */ + public function withFailReason(?string $failReason): self + { + $obj = clone $this; + $obj->failReason = $failReason; + + return $obj; + } + + /** @noinspection PhpUnused */ + public function withView(?string $view): self + { + $obj = clone $this; + $obj->view = $view; + + return $obj; + } + + /** + * @param mixed $value + */ + public function withDataItem(string $name, $value): self + { + $obj = clone $this; + $obj->data[$name] = $value; + + return $obj; + } +} diff --git a/application/Espo/Core/Authentication/Result/FailReason.php b/application/Espo/Core/Authentication/Result/FailReason.php new file mode 100644 index 0000000..cd10092 --- /dev/null +++ b/application/Espo/Core/Authentication/Result/FailReason.php @@ -0,0 +1,48 @@ +. + * + * 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\Result; + +class FailReason +{ + public const DENIED = 'Denied'; + public const CODE_NOT_VERIFIED = 'Code not verified'; + public const NO_USERNAME = 'No username'; + public const NO_PASSWORD = 'No password'; + public const TOKEN_NOT_FOUND = 'Token not found'; + public const USER_NOT_FOUND = 'User not found'; + public const WRONG_CREDENTIALS = 'Wrong credentials'; + public const USER_TOKEN_MISMATCH = 'User and token mismatch'; + public const HASH_NOT_MATCHED = 'Hash not matched'; + public const METHOD_NOT_ALLOWED = 'Not allowed authentication method'; + public const DISCREPANT_DATA = 'Discrepant authentication data'; + public const ANOTHER_USER_NOT_FOUND = 'Another user not found'; + public const ANOTHER_USER_NOT_ALLOWED = 'Another user not allowed'; + public const ERROR = 'Error'; +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Email/EmailLogin.php b/application/Espo/Core/Authentication/TwoFactor/Email/EmailLogin.php new file mode 100644 index 0000000..90a3059 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Email/EmailLogin.php @@ -0,0 +1,116 @@ +. + * + * 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\TwoFactor\Email; + +use Espo\Core\Authentication\HeaderKey; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Mail\Exceptions\SendingError; +use Espo\Core\Utils\Log; +use Espo\ORM\EntityManager; +use Espo\Entities\User; +use Espo\Entities\UserData; +use Espo\Repositories\UserData as UserDataRepository; +use Espo\Core\Authentication\TwoFactor\Login; +use Espo\Core\Authentication\Result; +use Espo\Core\Authentication\Result\Data as ResultData; +use Espo\Core\Authentication\Result\FailReason; +use Espo\Core\Api\Request; + +use RuntimeException; + +class EmailLogin implements Login +{ + public const NAME = 'Email'; + + public function __construct( + private EntityManager $entityManager, + private Util $util, + private Log $log + ) {} + + public function login(Result $result, Request $request): Result + { + $code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE); + + $user = $result->getUser(); + + if (!$user) { + throw new RuntimeException("No user."); + } + + if (!$code) { + try { + $this->util->sendCode($user); + } catch (Forbidden|SendingError $e) { + $this->log->error("Could not send 2FA code for user {$user->getUserName()}. " . $e->getMessage()); + + return Result::fail(FailReason::ERROR); + } + + return Result::secondStepRequired($user, $this->getResultData()); + } + + if ($this->verifyCode($user, $code)) { + return $result; + } + + return Result::fail(FailReason::CODE_NOT_VERIFIED); + } + + private function getResultData(): ResultData + { + return ResultData::createWithMessage('enterCodeSentInEmail'); + } + + private function verifyCode(User $user, string $code): bool + { + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + return false; + } + + if (!$userData->get('auth2FA')) { + return false; + } + + if ($userData->get('auth2FAMethod') !== self::NAME) { + return false; + } + + return $this->util->verifyCode($user, $code); + } + + private function getUserDataRepository(): UserDataRepository + { + /** @var UserDataRepository */ + return $this->entityManager->getRepository(UserData::ENTITY_TYPE); + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Email/EmailUserSetup.php b/application/Espo/Core/Authentication/TwoFactor/Email/EmailUserSetup.php new file mode 100644 index 0000000..712cd42 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Email/EmailUserSetup.php @@ -0,0 +1,69 @@ +. + * + * 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\TwoFactor\Email; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Entities\User; +use Espo\Core\Authentication\TwoFactor\UserSetup; + +use stdClass; + +/** + * @noinspection PhpUnused + */ +class EmailUserSetup implements UserSetup +{ + public function __construct(private Util $util) + {} + + public function getData(User $user): stdClass + { + return (object) [ + 'emailAddressList' => $user->getEmailAddressGroup()->getAddressList(), + ]; + } + + public function verifyData(User $user, stdClass $payloadData): bool + { + $code = $payloadData->code ?? null; + + if ($code === null) { + throw new BadRequest("No code."); + } + + $codeModified = str_replace(' ', '', trim($code)); + + if (!$codeModified) { + return false; + } + + return $this->util->verifyCode($user, $codeModified); + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Email/Util.php b/application/Espo/Core/Authentication/TwoFactor/Email/Util.php new file mode 100644 index 0000000..c10805a --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Email/Util.php @@ -0,0 +1,344 @@ +. + * + * 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\TwoFactor\Email; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Mail\Exceptions\SendingError; +use Espo\Core\Name\Field; +use Espo\Core\Utils\Config; +use Espo\Core\Mail\EmailSender; +use Espo\Core\Mail\EmailFactory; +use Espo\Core\Utils\TemplateFileManager; +use Espo\Core\Htmlizer\HtmlizerFactory; +use Espo\Core\Field\DateTime; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\Entities\User; +use Espo\Entities\Email; +use Espo\Entities\TwoFactorCode; +use Espo\Entities\UserData; +use Espo\Repositories\UserData as UserDataRepository; + +use RuntimeException; + +use const STR_PAD_LEFT; + +class Util +{ + /** + * A lifetime of a code. + */ + private const CODE_LIFETIME_PERIOD = '10 minutes'; + + /** + * A max number of attempts to try a single code. + */ + private const CODE_ATTEMPTS_COUNT = 5; + + /** + * A length of a code. + */ + private const CODE_LENGTH = 7; + + /** + * A max number of codes tried by a user in a period defined by `CODE_LIMIT_PERIOD`. + */ + private const CODE_LIMIT = 5; + + /** + * A period for limiting trying to too many codes. + */ + private const CODE_LIMIT_PERIOD = '10 minutes'; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + private EmailSender $emailSender, + private TemplateFileManager $templateFileManager, + private HtmlizerFactory $htmlizerFactory, + private EmailFactory $emailFactory + ) {} + + /** + * @throws Forbidden + */ + public function storeEmailAddress(User $user, string $emailAddress): void + { + $this->checkEmailAddressIsUsers($user, $emailAddress); + + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + throw new RuntimeException("UserData not found."); + } + + $userData->set('auth2FAEmailAddress', $emailAddress); + + $this->entityManager->saveEntity($userData); + } + + public function verifyCode(User $user, string $code): bool + { + $codeEntity = $this->findCodeEntity($user); + + if (!$codeEntity) { + return false; + } + + if ($codeEntity->getAttemptsLeft() <= 1) { + $this->decrementAttemptsLeft($codeEntity); + $this->inactivateExistingCodeRecords($user); + + return false; + } + + if ($codeEntity->getCode() !== $code) { + $this->decrementAttemptsLeft($codeEntity); + + return false; + } + + if (!$this->isCodeValidByLifetime($codeEntity)) { + $this->inactivateExistingCodeRecords($user); + + return false; + } + + $this->inactivateExistingCodeRecords($user); + + return true; + } + + /** + * @throws SendingError + * @throws Forbidden + */ + public function sendCode(User $user, ?string $emailAddress = null): void + { + if ($emailAddress === null) { + $emailAddress = $this->getEmailAddress($user); + } + + $this->checkEmailAddressIsUsers($user, $emailAddress); + $this->checkCodeLimit($user); + + $code = $this->generateCode(); + + $this->inactivateExistingCodeRecords($user); + $this->createCodeRecord($user, $code); + + $email = $this->createEmail($user, $code, $emailAddress); + + $this->emailSender->send($email); + } + + private function isCodeValidByLifetime(TwoFactorCode $codeEntity): bool + { + $period = $this->config->get('auth2FAEmailCodeLifetimePeriod') ?? self::CODE_LIFETIME_PERIOD; + + $validUntil = $codeEntity->getCreatedAt()->modify($period); + + if (DateTime::createNow()->diff($validUntil)->invert) { + return false; + } + + return true; + } + + private function findCodeEntity(User $user): ?TwoFactorCode + { + /** @var ?TwoFactorCode */ + return $this->entityManager + ->getRDBRepository(TwoFactorCode::ENTITY_TYPE) + ->where([ + 'method' => EmailLogin::NAME, + 'userId' => $user->getId(), + 'isActive' => true, + ]) + ->findOne(); + } + + /** + * @throws Forbidden + */ + private function getEmailAddress(User $user): string + { + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + throw new RuntimeException("UserData not found."); + } + + $emailAddress = $userData->get('auth2FAEmailAddress'); + + if ($emailAddress) { + return $emailAddress; + } + + if ($user->getEmailAddressGroup()->getCount() === 0) { + throw new Forbidden("User does not have email address."); + } + + /** @var string */ + return $user->getEmailAddressGroup()->getPrimaryAddress(); + } + + /** + * @throws Forbidden + */ + private function checkEmailAddressIsUsers(User $user, string $emailAddress): void + { + $userAddressList = array_map( + function (string $item) { + return strtolower($item); + }, + $user->getEmailAddressGroup()->getAddressList() + ); + + if (!in_array(strtolower($emailAddress), $userAddressList)) { + throw new Forbidden("Email address is not one of user's."); + } + } + + /** + * @throws Forbidden + */ + private function checkCodeLimit(User $user): void + { + $limit = $this->config->get('auth2FAEmailCodeLimit') ?? self::CODE_LIMIT; + $period = $this->config->get('auth2FAEmailCodeLimitPeriod') ?? self::CODE_LIMIT_PERIOD; + + $from = DateTime::createNow() + ->modify('-' . $period) + ->toString(); + + $count = $this->entityManager + ->getRDBRepository(TwoFactorCode::ENTITY_TYPE) + ->where( + Cond::and( + Cond::equal(Cond::column('method'), 'Email'), + Cond::equal(Cond::column('userId'), $user->getId()), + Cond::greaterOrEqual(Cond::column(Field::CREATED_AT), $from), + Cond::lessOrEqual(Cond::column('attemptsLeft'), 0), + ) + ) + ->count(); + + if ($count >= $limit) { + throw new Forbidden("Max code count exceeded."); + } + } + + private function generateCode(): string + { + $codeLength = $this->config->get('auth2FAEmailCodeLength') ?? self::CODE_LENGTH; + + $max = pow(10, $codeLength) - 1; + + /** @noinspection PhpUnhandledExceptionInspection */ + return str_pad( + (string) random_int(0, $max), + $codeLength, + '0', + STR_PAD_LEFT + ); + } + + private function createEmail(User $user, string $code, string $emailAddress): Email + { + $subjectTpl = $this->templateFileManager->getTemplate('twoFactorCode', 'subject'); + $bodyTpl = $this->templateFileManager->getTemplate('twoFactorCode', 'body'); + + $htmlizer = $this->htmlizerFactory->create(); + + $data = [ + 'code' => $code, + ]; + + $subject = $htmlizer->render($user, $subjectTpl, $data, true); + $body = $htmlizer->render($user, $bodyTpl, $data, true); + + $email = $this->emailFactory->create(); + + $email->setSubject($subject); + $email->setBody($body); + $email->addToAddress($emailAddress); + + return $email; + } + + private function inactivateExistingCodeRecords(User $user): void + { + $query = $this->entityManager + ->getQueryBuilder() + ->update() + ->in(TwoFactorCode::ENTITY_TYPE) + ->where([ + 'userId' => $user->getId(), + 'method' => EmailLogin::NAME, + ]) + ->set([ + 'isActive' => false, + ]) + ->build(); + + $this->entityManager + ->getQueryExecutor() + ->execute($query); + } + + private function createCodeRecord(User $user, string $code): void + { + $this->entityManager->createEntity(TwoFactorCode::ENTITY_TYPE, [ + 'code' => $code, + 'userId' => $user->getId(), + 'method' => EmailLogin::NAME, + 'attemptsLeft' => $this->getCodeAttemptsCount(), + ]); + } + + private function getUserDataRepository(): UserDataRepository + { + /** @var UserDataRepository */ + return $this->entityManager->getRepository(UserData::ENTITY_TYPE); + } + + private function decrementAttemptsLeft(TwoFactorCode $codeEntity): void + { + $codeEntity->decrementAttemptsLeft(); + + $this->entityManager->saveEntity($codeEntity); + } + + private function getCodeAttemptsCount(): int + { + return $this->config->get('auth2FAEmailCodeAttemptsCount') ?? self::CODE_ATTEMPTS_COUNT; + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Exceptions/NotConfigured.php b/application/Espo/Core/Authentication/TwoFactor/Exceptions/NotConfigured.php new file mode 100644 index 0000000..d9d6ea6 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Exceptions/NotConfigured.php @@ -0,0 +1,35 @@ +. + * + * 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\TwoFactor\Exceptions; + +use Exception; + +class NotConfigured extends Exception +{} diff --git a/application/Espo/Core/Authentication/TwoFactor/Login.php b/application/Espo/Core/Authentication/TwoFactor/Login.php new file mode 100644 index 0000000..2c0cbb5 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Login.php @@ -0,0 +1,41 @@ +. + * + * 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\TwoFactor; + +use Espo\Core\Authentication\Result; +use Espo\Core\Api\Request; + +/** + * Processes second-step logging in. + */ +interface Login +{ + public function login(Result $result, Request $request): Result; +} diff --git a/application/Espo/Core/Authentication/TwoFactor/LoginFactory.php b/application/Espo/Core/Authentication/TwoFactor/LoginFactory.php new file mode 100644 index 0000000..f050271 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/LoginFactory.php @@ -0,0 +1,53 @@ +. + * + * 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\TwoFactor; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +use LogicException; + +class LoginFactory +{ + public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata) + {} + + public function create(string $method): Login + { + /** @var ?class-string $className */ + $className = $this->metadata->get(['app', 'authentication2FAMethods', $method, 'loginClassName']); + + if (!$className) { + throw new LogicException("No login-class class for '$method'."); + } + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/MethodProvider.php b/application/Espo/Core/Authentication/TwoFactor/MethodProvider.php new file mode 100644 index 0000000..4bfb627 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/MethodProvider.php @@ -0,0 +1,72 @@ +. + * + * 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\TwoFactor; + +use Espo\Core\Authentication\ConfigDataProvider; +use Espo\Tools\User\UserDataProvider; + +/** + * @internal + */ +class MethodProvider +{ + public function __construct( + private UserDataProvider $userDataProvider, + private ConfigDataProvider $configDataProvider, + ) {} + + /** + * Obtain 2FA method for a user. + */ + public function get(string $userId): ?string + { + $userData = $this->userDataProvider->get($userId); + + if (!$userData) { + return null; + } + + if (!$userData->getAuth2FA()) { + return null; + } + + $method = $userData->getAuth2FAMethod(); + + if (!$method) { + return null; + } + + if (!in_array($method, $this->configDataProvider->getTwoFactorMethodList())) { + return null; + } + + return $method; + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php b/application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php new file mode 100644 index 0000000..9bc6faa --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Sms/SmsLogin.php @@ -0,0 +1,121 @@ +. + * + * 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\TwoFactor\Sms; + +use Espo\Core\Authentication\HeaderKey; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Utils\Log; +use Espo\ORM\EntityManager; +use Espo\Entities\User; +use Espo\Entities\UserData; +use Espo\Repositories\UserData as UserDataRepository; +use Espo\Core\Authentication\TwoFactor\Login; +use Espo\Core\Authentication\Result; +use Espo\Core\Authentication\Result\Data as ResultData; +use Espo\Core\Authentication\Result\FailReason; +use Espo\Core\Api\Request; + +use RuntimeException; + +class SmsLogin implements Login +{ + public const NAME = 'Sms'; + + public function __construct( + private EntityManager $entityManager, + private Util $util, + private Log $log + ) {} + + public function login(Result $result, Request $request): Result + { + $code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE); + + $user = $result->getUser(); + + if (!$user) { + throw new RuntimeException("No user."); + } + + if (!$code) { + $user = $result->getUser(); + + if (!$user) { + throw new RuntimeException("No user."); + } + + try { + $this->util->sendCode($user); + } catch (Forbidden $e) { + $this->log->error("Could not send 2FA code for user {$user->getUserName()}. " . $e->getMessage()); + + return Result::fail(FailReason::ERROR); + } + + return Result::secondStepRequired($user, $this->getResultData()); + } + + if ($this->verifyCode($user, $code)) { + return $result; + } + + return Result::fail(FailReason::CODE_NOT_VERIFIED); + } + + private function getResultData(): ResultData + { + return ResultData::createWithMessage('enterCodeSentBySms'); + } + + private function verifyCode(User $user, string $code): bool + { + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + return false; + } + + if (!$userData->get('auth2FA')) { + return false; + } + + if ($userData->get('auth2FAMethod') !== self::NAME) { + return false; + } + + return $this->util->verifyCode($user, $code); + } + + private function getUserDataRepository(): UserDataRepository + { + /** @var UserDataRepository */ + return $this->entityManager->getRepository(UserData::ENTITY_TYPE); + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Sms/SmsUserSetup.php b/application/Espo/Core/Authentication/TwoFactor/Sms/SmsUserSetup.php new file mode 100644 index 0000000..4400720 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Sms/SmsUserSetup.php @@ -0,0 +1,77 @@ +. + * + * 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\TwoFactor\Sms; + +use Espo\Core\Authentication\TwoFactor\Exceptions\NotConfigured; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Portal\Utils\Config; +use Espo\Entities\User; +use Espo\Core\Authentication\TwoFactor\UserSetup; + +use stdClass; + +/** + * @noinspection PhpUnused + */ +class SmsUserSetup implements UserSetup +{ + public function __construct( + private Util $util, + private Config $config + ) {} + + public function getData(User $user): stdClass + { + if (!$this->config->get('smsProvider')) { + throw new NotConfigured("No SMS provider."); + } + + return (object) [ + 'phoneNumberList' => $user->getPhoneNumberGroup()->getNumberList(), + ]; + } + + public function verifyData(User $user, stdClass $payloadData): bool + { + $code = $payloadData->code ?? null; + + if ($code === null) { + throw new BadRequest("No code."); + } + + $codeModified = str_replace(' ', '', trim($code)); + + if (!$codeModified) { + return false; + } + + return $this->util->verifyCode($user, $codeModified); + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Sms/Util.php b/application/Espo/Core/Authentication/TwoFactor/Sms/Util.php new file mode 100644 index 0000000..0d07051 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Sms/Util.php @@ -0,0 +1,332 @@ +. + * + * 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\TwoFactor\Sms; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Name\Field; +use Espo\Core\Utils\Config; +use Espo\Core\Sms\SmsSender; +use Espo\Core\Sms\SmsFactory; +use Espo\Core\Utils\Language; +use Espo\Core\Field\DateTime; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\Entities\User; +use Espo\Entities\Sms; +use Espo\Entities\TwoFactorCode; +use Espo\Entities\UserData; +use Espo\Repositories\UserData as UserDataRepository; + +use RuntimeException; + +use const STR_PAD_LEFT; + +class Util +{ + private const METHOD = SmsLogin::NAME; + + /** + * A lifetime of a code. + */ + private const CODE_LIFETIME_PERIOD = '10 minutes'; + /** + * A max number of attempts to try a single code. + */ + private const CODE_ATTEMPTS_COUNT = 5; + /** + * A length of a code. + */ + private const CODE_LENGTH = 6; + /** + * A max number of codes tried by a user in a period defined by `CODE_LIMIT_PERIOD`. + */ + private const CODE_LIMIT = 5; + /** + * A period for limiting trying to too many codes. + */ + private const CODE_LIMIT_PERIOD = '20 minutes'; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + private SmsSender $smsSender, + private Language $language, + private SmsFactory $smsFactory + ) {} + + /** + * @throws Forbidden + */ + public function storePhoneNumber(User $user, string $phoneNumber): void + { + $this->checkPhoneNumberIsUsers($user, $phoneNumber); + + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + throw new RuntimeException(); + } + + $userData->set('auth2FASmsPhoneNumber', $phoneNumber); + + $this->entityManager->saveEntity($userData); + } + + public function verifyCode(User $user, string $code): bool + { + $codeEntity = $this->findCodeEntity($user); + + if (!$codeEntity) { + return false; + } + + if ($codeEntity->getAttemptsLeft() <= 1) { + $this->decrementAttemptsLeft($codeEntity); + $this->inactivateExistingCodeRecords($user); + + return false; + } + + if ($codeEntity->getCode() !== $code) { + $this->decrementAttemptsLeft($codeEntity); + + return false; + } + + if (!$this->isCodeValidByLifetime($codeEntity)) { + $this->inactivateExistingCodeRecords($user); + + return false; + } + + $this->inactivateExistingCodeRecords($user); + + return true; + } + + /** + * @throws Forbidden + */ + public function sendCode(User $user, ?string $phoneNumber = null): void + { + if ($phoneNumber === null) { + $phoneNumber = $this->getPhoneNumber($user); + } + + $this->checkPhoneNumberIsUsers($user, $phoneNumber); + $this->checkCodeLimit($user); + + $code = $this->generateCode(); + + $this->inactivateExistingCodeRecords($user); + $this->createCodeRecord($user, $code); + + $sms = $this->createSms($code, $phoneNumber); + + $this->smsSender->send($sms); + } + + private function isCodeValidByLifetime(TwoFactorCode $codeEntity): bool + { + $period = $this->config->get('auth2FASmsCodeLifetimePeriod') ?? self::CODE_LIFETIME_PERIOD; + + $validUntil = $codeEntity->getCreatedAt()->modify($period); + + if (DateTime::createNow()->diff($validUntil)->invert) { + return false; + } + + return true; + } + + private function findCodeEntity(User $user): ?TwoFactorCode + { + /** @var ?TwoFactorCode */ + return $this->entityManager + ->getRDBRepository(TwoFactorCode::ENTITY_TYPE) + ->where([ + 'method' => self::METHOD, + 'userId' => $user->getId(), + 'isActive' => true, + ]) + ->findOne(); + } + + /** + * @throws Forbidden + */ + private function getPhoneNumber(User $user): string + { + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + throw new RuntimeException("UserData not found."); + } + + $phoneNumber = $userData->get('auth2FASmsPhoneNumber'); + + if ($phoneNumber) { + return $phoneNumber; + } + + if ($user->getPhoneNumberGroup()->getCount() === 0) { + throw new Forbidden("User does not have phone number."); + } + + /** @var string */ + return $user->getPhoneNumberGroup()->getPrimaryNumber(); + } + + /** + * @throws Forbidden + */ + private function checkPhoneNumberIsUsers(User $user, string $phoneNumber): void + { + $userNumberList = array_map( + function (string $item) { + return strtolower($item); + }, + $user->getPhoneNumberGroup()->getNumberList() + ); + + if (!in_array(strtolower($phoneNumber), $userNumberList)) { + throw new Forbidden("Phone number is not one of user's."); + } + } + + /** + * @throws Forbidden + */ + private function checkCodeLimit(User $user): void + { + $limit = $this->config->get('auth2FASmsCodeLimit') ?? self::CODE_LIMIT; + $period = $this->config->get('auth2FASmsCodeLimitPeriod') ?? self::CODE_LIMIT_PERIOD; + + $from = DateTime::createNow() + ->modify('-' . $period) + ->toString(); + + $count = $this->entityManager + ->getRDBRepository(TwoFactorCode::ENTITY_TYPE) + ->where( + Cond::and( + Cond::equal(Cond::column('method'), self::METHOD), + Cond::equal(Cond::column('userId'), $user->getId()), + Cond::greaterOrEqual(Cond::column(Field::CREATED_AT), $from), + Cond::lessOrEqual(Cond::column('attemptsLeft'), 0), + ) + ) + ->count(); + + if ($count >= $limit) { + throw new Forbidden("Max code count exceeded."); + } + } + + private function generateCode(): string + { + $codeLength = $this->config->get('auth2FASmsCodeLength') ?? self::CODE_LENGTH; + + $max = pow(10, $codeLength) - 1; + + /** @noinspection PhpUnhandledExceptionInspection */ + return str_pad( + (string) random_int(0, $max), + $codeLength, + '0', + STR_PAD_LEFT + ); + } + + private function createSms(string $code, string $phoneNumber): Sms + { + $fromNumber = $this->config->get('outboundSmsFromNumber'); + + $bodyTpl = $this->language->translateLabel('yourAuthenticationCode', 'messages', 'User'); + + $body = str_replace('{code}', $code, $bodyTpl); + + $sms = $this->smsFactory->create(); + + $sms->setFromNumber($fromNumber); + $sms->setBody($body); + $sms->addToNumber($phoneNumber); + + return $sms; + } + + private function inactivateExistingCodeRecords(User $user): void + { + $query = $this->entityManager + ->getQueryBuilder() + ->update() + ->in(TwoFactorCode::ENTITY_TYPE) + ->where([ + 'userId' => $user->getId(), + 'method' => self::METHOD, + ]) + ->set([ + 'isActive' => false, + ]) + ->build(); + + $this->entityManager + ->getQueryExecutor() + ->execute($query); + } + + private function createCodeRecord(User $user, string $code): void + { + $this->entityManager->createEntity(TwoFactorCode::ENTITY_TYPE, [ + 'code' => $code, + 'userId' => $user->getId(), + 'method' => self::METHOD, + 'attemptsLeft' => $this->getCodeAttemptsCount(), + ]); + } + + private function getUserDataRepository(): UserDataRepository + { + /** @var UserDataRepository */ + return $this->entityManager->getRepository(UserData::ENTITY_TYPE); + } + + private function decrementAttemptsLeft(TwoFactorCode $codeEntity): void + { + $codeEntity->decrementAttemptsLeft(); + + $this->entityManager->saveEntity($codeEntity); + } + + private function getCodeAttemptsCount(): int + { + return $this->config->get('auth2FASmsCodeAttemptsCount') ?? self::CODE_ATTEMPTS_COUNT; + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Totp/TotpLogin.php b/application/Espo/Core/Authentication/TwoFactor/Totp/TotpLogin.php new file mode 100644 index 0000000..a26a427 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Totp/TotpLogin.php @@ -0,0 +1,118 @@ +. + * + * 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\TwoFactor\Totp; + +use Espo\Core\Authentication\HeaderKey; +use Espo\ORM\EntityManager; +use Espo\Entities\User; +use Espo\Entities\UserData; +use Espo\Repositories\UserData as UserDataRepository; +use Espo\Core\Authentication\TwoFactor\Login; +use Espo\Core\Authentication\Result; +use Espo\Core\Authentication\Result\Data as ResultData; +use Espo\Core\Authentication\Result\FailReason; +use Espo\Core\Api\Request; + +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class TotpLogin implements Login +{ + public const string NAME = 'Totp'; + + private const int CODE_LENGTH = 6; + + public function __construct( + private EntityManager $entityManager, + private Util $totp + ) {} + + public function login(Result $result, Request $request): Result + { + $code = $request->getHeader(HeaderKey::AUTHORIZATION_CODE); + + $user = $result->getUser(); + + if (!$user) { + throw new RuntimeException("No user."); + } + + if (!$code) { + return Result::secondStepRequired($user, $this->getResultData()); + } + + if ($this->verifyCode($user, $code)) { + return $result; + } + + return Result::fail(FailReason::CODE_NOT_VERIFIED); + } + + private function getResultData(): ResultData + { + return ResultData::createWithMessage('enterTotpCode') + ->withDataItem(Result\Data::PARAM_CODE_LENGTH, self::CODE_LENGTH); + } + + private function verifyCode(User $user, string $code): bool + { + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + return false; + } + + if (!$userData->get('auth2FA')) { + return false; + } + + if ($userData->get('auth2FAMethod') !== self::NAME) { + return false; + } + + $secret = $userData->get('auth2FATotpSecret'); + + if (!$secret) { + return false; + } + + return $this->totp->verifyCode($secret, $code); + } + + private function getUserDataRepository(): UserDataRepository + { + /** @var UserDataRepository $repository */ + $repository = $this->entityManager->getRepository(UserData::ENTITY_TYPE); + + return $repository; + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Totp/TotpUserSetup.php b/application/Espo/Core/Authentication/TwoFactor/Totp/TotpUserSetup.php new file mode 100644 index 0000000..9ac9679 --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Totp/TotpUserSetup.php @@ -0,0 +1,116 @@ +. + * + * 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\TwoFactor\Totp; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Entities\UserData; +use Espo\Entities\User; +use Espo\Repositories\UserData as UserDataRepository; +use Espo\ORM\EntityManager; +use Espo\Core\Authentication\TwoFactor\UserSetup; +use Espo\Core\Utils\Config; + +use RuntimeException; +use SensitiveParameter; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class TotpUserSetup implements UserSetup +{ + public function __construct( + private Util $totp, + private Config $config, + private EntityManager $entityManager + ) {} + + public function getData(User $user): stdClass + { + $userName = $user->get('userName'); + + $secret = $this->totp->createSecret(); + + $label = rawurlencode($this->config->get('applicationName')) . ':' . rawurlencode($userName); + + $this->storeSecret($user, $secret); + + return (object) [ + 'auth2FATotpSecret' => $secret, + 'label' => $label, + ]; + } + + public function verifyData(User $user, stdClass $payloadData): bool + { + $code = $payloadData->code ?? null; + + if ($code === null) { + throw new BadRequest("No code."); + } + + $codeModified = str_replace(' ', '', trim($code)); + + if (!$codeModified) { + return false; + } + + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + throw new RuntimeException("User not found."); + } + + $secret = $userData->get('auth2FATotpSecret'); + + return $this->totp->verifyCode($secret, $codeModified); + } + + private function storeSecret(User $user, #[SensitiveParameter] string $secret): void + { + $userData = $this->getUserDataRepository()->getByUserId($user->getId()); + + if (!$userData) { + throw new RuntimeException(); + } + + $userData->set('auth2FATotpSecret', $secret); + + $this->entityManager->saveEntity($userData); + } + + private function getUserDataRepository(): UserDataRepository + { + /** @var UserDataRepository $repository */ + $repository = $this->entityManager->getRepository(UserData::ENTITY_TYPE); + + return $repository; + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/Totp/Util.php b/application/Espo/Core/Authentication/TwoFactor/Totp/Util.php new file mode 100644 index 0000000..7f34c9c --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/Totp/Util.php @@ -0,0 +1,55 @@ +. + * + * 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\TwoFactor\Totp; + +use RobThree\Auth\TwoFactorAuth; +use RobThree\Auth\TwoFactorAuthException; +use RuntimeException; + +class Util +{ + public function verifyCode(string $secret, string $code): bool + { + $impl = new TwoFactorAuth(); + + return $impl->verifyCode($secret, $code); + } + + public function createSecret(): string + { + $impl = new TwoFactorAuth(); + + try { + return $impl->createSecret(); + } catch (TwoFactorAuthException $e) { + throw new RuntimeException($e->getMessage()); + } + } +} diff --git a/application/Espo/Core/Authentication/TwoFactor/UserSetup.php b/application/Espo/Core/Authentication/TwoFactor/UserSetup.php new file mode 100644 index 0000000..94f555e --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/UserSetup.php @@ -0,0 +1,56 @@ +. + * + * 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\TwoFactor; + +use Espo\Core\Authentication\TwoFactor\Exceptions\NotConfigured; +use Espo\Core\Exceptions\BadRequest; +use Espo\Entities\User; + +use stdClass; + +/** + * 2FA setting-up for a user. + */ +interface UserSetup +{ + /** + * Get data needed for configuration for a user. Data will be passed to the front-end. + * + * @throws NotConfigured + */ + public function getData(User $user): stdClass; + + /** + * Verify input data before making 2FA enabled for a user. + * + * @throws BadRequest + */ + public function verifyData(User $user, stdClass $payloadData): bool; +} diff --git a/application/Espo/Core/Authentication/TwoFactor/UserSetupFactory.php b/application/Espo/Core/Authentication/TwoFactor/UserSetupFactory.php new file mode 100644 index 0000000..f4bae9c --- /dev/null +++ b/application/Espo/Core/Authentication/TwoFactor/UserSetupFactory.php @@ -0,0 +1,55 @@ +. + * + * 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\TwoFactor; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +use RuntimeException; + +class UserSetupFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata + ) {} + + public function create(string $method): UserSetup + { + /** @var ?class-string $className */ + $className = $this->metadata->get(['app', 'authentication2FAMethods', $method, 'userSetupClassName']); + + if (!$className) { + throw new RuntimeException("No user-setup class for '$method'."); + } + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/Authentication/Util/DelayUtil.php b/application/Espo/Core/Authentication/Util/DelayUtil.php new file mode 100644 index 0000000..19d8903 --- /dev/null +++ b/application/Espo/Core/Authentication/Util/DelayUtil.php @@ -0,0 +1,38 @@ +. + * + * 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\Util; + +class DelayUtil +{ + public function delay(int $milliseconds): void + { + sleep($milliseconds * 1000); + } +} diff --git a/application/Espo/Core/Authentication/Util/IpAddressUtil.php b/application/Espo/Core/Authentication/Util/IpAddressUtil.php new file mode 100644 index 0000000..5be56a8 --- /dev/null +++ b/application/Espo/Core/Authentication/Util/IpAddressUtil.php @@ -0,0 +1,59 @@ +. + * + * 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\Util; + +use CIDRmatch\CIDRmatch; + +class IpAddressUtil +{ + /** + * @param string $ipAddress An IP address. + * @param string[] $whitelist A whitelist. IPs or IP ranges in CIDR notation. + */ + public function isInWhitelist(string $ipAddress, array $whitelist): bool + { + $cidrMatch = new CIDRmatch(); + + foreach ($whitelist as $whiteIpAddress) { + if ($ipAddress === $whiteIpAddress) { + return true; + } + + if ( + str_contains($whiteIpAddress, '/') && + $cidrMatch->match($ipAddress, $whiteIpAddress) + ) { + return true; + } + } + + return false; + } +} diff --git a/application/Espo/Core/Authentication/Util/MethodProvider.php b/application/Espo/Core/Authentication/Util/MethodProvider.php new file mode 100644 index 0000000..4f0e4ff --- /dev/null +++ b/application/Espo/Core/Authentication/Util/MethodProvider.php @@ -0,0 +1,115 @@ +. + * + * 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\Util; + +use Espo\Core\ApplicationState; +use Espo\Core\Authentication\ConfigDataProvider; +use Espo\Core\Authentication\Logins\Espo; +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Core\Utils\Metadata; +use Espo\Entities\AuthenticationProvider; +use Espo\Entities\Portal; +use RuntimeException; + +/** + * An authentication method provider. + */ +class MethodProvider +{ + public function __construct( + private EntityManagerProxy $entityManager, + private ApplicationState $applicationState, + private ConfigDataProvider $configDataProvider, + private Metadata $metadata + ) {} + + /** + * Get an authentication method. + */ + public function get(): string + { + if ($this->applicationState->isPortal()) { + $method = $this->getForPortal($this->applicationState->getPortal()); + + if ($method) { + return $method; + } + + return $this->getDefaultForPortal(); + } + + return $this->configDataProvider->getDefaultAuthenticationMethod(); + } + + /** + * Get an authentication method for portals. The method that is applied via the authentication provider link. + * If no provider, then returns null. + */ + public function getForPortal(Portal $portal): ?string + { + $providerId = $portal->getAuthenticationProvider()?->getId(); + + if (!$providerId) { + return null; + } + + /** @var ?AuthenticationProvider $provider */ + $provider = $this->entityManager->getEntityById(AuthenticationProvider::ENTITY_TYPE, $providerId); + + if (!$provider) { + throw new RuntimeException("No authentication provider for portal."); + } + + $method = $provider->getMethod(); + + if (!$method) { + throw new RuntimeException("No method in authentication provider."); + } + + return $method; + } + + /** + * Get a default authentication method for portals. Should be used if a portal does not have + * an authentication provider. + */ + private function getDefaultForPortal(): string + { + $method = $this->configDataProvider->getDefaultAuthenticationMethod(); + + $allow = $this->metadata->get(['authenticationMethods', $method, 'portalDefault']); + + if (!$allow) { + return Espo::NAME; + } + + return $method; + } +} diff --git a/application/Espo/Core/Binding/Attributes/Qualify.php b/application/Espo/Core/Binding/Attributes/Qualify.php new file mode 100644 index 0000000..ac113a9 --- /dev/null +++ b/application/Espo/Core/Binding/Attributes/Qualify.php @@ -0,0 +1,45 @@ +. + * + * 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\Binding\Attributes; + +use Attribute; + +/** + * Assigns a qualifier to a constructor parameter. + * + * @since 10.1.0 + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +readonly class Qualify +{ + public function __construct( + public string $qualifier, + ) {} +} diff --git a/application/Espo/Core/Binding/Binder.php b/application/Espo/Core/Binding/Binder.php new file mode 100644 index 0000000..d7d203b --- /dev/null +++ b/application/Espo/Core/Binding/Binder.php @@ -0,0 +1,182 @@ +. + * + * 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\Binding; + +use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\QualifiedClassKey; +use LogicException; +use Closure; + +class Binder +{ + public function __construct(private BindingData $data) + {} + + /** + * Bind an interface to an implementation. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param class-string $implementationClassName An implementation class name. + */ + public function bindImplementation( + string|NamedClassKey|QualifiedClassKey $key, + string $implementationClassName, + ): self { + + $key = self::keyToString($key); + $this->validateBindingKey($key); + + $this->data->addGlobal($key, Binding::createFromImplementationClassName($implementationClassName)); + + return $this; + } + + /** + * Bind an interface to a specific service. + * + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param string $serviceName A service name. + */ + public function bindService(string|NamedClassKey|QualifiedClassKey $key, string $serviceName): self + { + $key = self::keyToString($key); + $this->validateBindingKey($key); + + $this->data->addGlobal($key, Binding::createFromServiceName($serviceName)); + + return $this; + } + + /** + * Bind an interface to a callback. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param Closure $callback A callback that will resolve a dependency. + * @todo Change to Closure(...): T Once https://github.com/phpstan/phpstan/issues/8214 is implemented. + */ + public function bindCallback(string|NamedClassKey|QualifiedClassKey $key, Closure $callback): self + { + $key = self::keyToString($key); + $this->validateBindingKey($key); + + $this->data->addGlobal($key, Binding::createFromCallback($callback)); + + return $this; + } + + /** + * Bind an interface to a specific instance. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param T $instance An instance. + */ + public function bindInstance(string|NamedClassKey|QualifiedClassKey $key, object $instance): self + { + $key = self::keyToString($key); + $this->validateBindingKey($key); + + $this->data->addGlobal($key, Binding::createFromValue($instance)); + + return $this; + } + + /** + * Bind an interface to a factory. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param class-string> $factoryClassName A factory class name. + */ + public function bindFactory(string|NamedClassKey|QualifiedClassKey $key, string $factoryClassName): self + { + $key = self::keyToString($key); + $this->validateBindingKey($key); + + $this->data->addGlobal($key, Binding::createFromFactoryClassName($factoryClassName)); + + return $this; + } + + /** + * Creates a contextual binder and pass it as an argument of a callback. + * + * @param class-string $className A context. + * @param Closure(ContextualBinder): void $callback A callback with a `ContextualBinder` argument. + */ + public function inContext(string $className, Closure $callback): self + { + $contextualBinder = new ContextualBinder($this->data, $className); + + $callback($contextualBinder); + + return $this; + } + + /** + * Creates a contextual binder. + * + * @param class-string $className A context. + */ + public function for(string $className): ContextualBinder + { + return new ContextualBinder($this->data, $className); + } + + /** + * @param string|NamedClassKey|QualifiedClassKey $key + */ + private static function keyToString(string|NamedClassKey|QualifiedClassKey $key): string + { + return is_string($key) ? $key : $key->toString(); + } + + private function validateBindingKey(string $key): void + { + if (!$key) { + throw new LogicException("Bad binding."); + } + + if ($key[0] === '$') { + throw new LogicException("Can't binding a parameter name w/o an interface globally."); + } + + if ($key[0] === '#') { + throw new LogicException("Can't binding a qualification name w/o an interface globally."); + } + } +} diff --git a/application/Espo/Core/Binding/Binding.php b/application/Espo/Core/Binding/Binding.php new file mode 100644 index 0000000..7e8175f --- /dev/null +++ b/application/Espo/Core/Binding/Binding.php @@ -0,0 +1,107 @@ +. + * + * 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\Binding; + +use LogicException; + +class Binding +{ + public const int IMPLEMENTATION_CLASS_NAME = 1; + public const int CONTAINER_SERVICE = 2; + public const int VALUE = 3; + public const int CALLBACK = 4; + public const int FACTORY_CLASS_NAME = 5; + + private int $type; + + private mixed $value; + + private function __construct(int $type, mixed $value) + { + $this->type = $type; + $this->value = $value; + } + + public function getType(): int + { + return $this->type; + } + + public function getValue(): mixed + { + return $this->value; + } + + /** + * @param class-string $implementationClassName + */ + public static function createFromImplementationClassName(string $implementationClassName): self + { + if (!$implementationClassName) { + throw new LogicException("Bad binding."); + } + + return new self(self::IMPLEMENTATION_CLASS_NAME, $implementationClassName); + } + + public static function createFromServiceName(string $serviceName): self + { + if (!$serviceName) { + throw new LogicException("Bad binding."); + } + + return new self(self::CONTAINER_SERVICE, $serviceName); + } + + /** + * @param mixed $value + */ + public static function createFromValue($value): self + { + return new self(self::VALUE, $value); + } + + public static function createFromCallback(callable $callback): self + { + return new self(self::CALLBACK, $callback); + } + + /** + * @param class-string> $factoryClassName + */ + public static function createFromFactoryClassName(string $factoryClassName): self + { + if (!$factoryClassName) { + throw new LogicException("Bad binding."); + } + + return new self(self::FACTORY_CLASS_NAME, $factoryClassName); + } +} diff --git a/application/Espo/Core/Binding/BindingContainer.php b/application/Espo/Core/Binding/BindingContainer.php new file mode 100644 index 0000000..f8be6bd --- /dev/null +++ b/application/Espo/Core/Binding/BindingContainer.php @@ -0,0 +1,222 @@ +. + * + * 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\Binding; + +use Espo\Core\Binding\Attributes\Qualify; +use ReflectionClass; +use ReflectionParameter; +use ReflectionNamedType; +use LogicException; + +/** + * Access point for bindings. + */ +class BindingContainer +{ + public function __construct(private BindingData $data) + {} + + /** + * Has binding by a reflection parameter. + * + * @param ?ReflectionClass $class + */ + public function hasByParam(?ReflectionClass $class, ReflectionParameter $param): bool + { + if ($this->getInternal($class, $param) === null) { + return false; + } + + return true; + } + + /** + * Get binding by a reflection parameter. + * + * @param ?ReflectionClass $class + */ + public function getByParam(?ReflectionClass $class, ReflectionParameter $param): Binding + { + if (!$this->hasByParam($class, $param)) { + throw new LogicException("Cannot get not existing binding."); + } + + return $this->getInternal($class, $param) ?? throw new LogicException(); + } + + /** + * Has global binding by an interface. + * + * @param class-string $interfaceName + */ + public function hasByInterface(string $interfaceName): bool + { + return $this->data->hasGlobal($interfaceName); + } + + /** + * Get global binding by an interface. + * + * @param class-string $interfaceName + */ + public function getByInterface(string $interfaceName): Binding + { + if (!$this->hasByInterface($interfaceName)) { + throw new LogicException("Binding for interface `$interfaceName` does not exist."); + } + + if (!interface_exists($interfaceName) && !class_exists($interfaceName)) { + throw new LogicException("Interface `$interfaceName` does not exist."); + } + + return $this->data->getGlobal($interfaceName); + } + + /** + * @param ?ReflectionClass $class + */ + private function getInternal(?ReflectionClass $class, ReflectionParameter $param): ?Binding + { + if ($class) { + $binding = $this->getInternalContextualNamed($class, $param); + + if ($binding) { + return $binding; + } + } + + $paramClassName = $this->getClassNameFromParameterType($param->getType()); + + if ($paramClassName === null) { + return null; + } + + $qualifiedName = $this->getQualifiedName($param); + + if ($qualifiedName) { + $keyQualified = $paramClassName . ' #' . $qualifiedName; + + $binding = $this->getInternalByClassNameKey($class?->getName(), $keyQualified); + + if ($binding) { + return $binding; + } + } + + $keyWithName = $paramClassName . ' $' . $param->getName(); + + $binding = $this->getInternalByClassNameKey($class?->getName(), $keyWithName); + + if ($binding) { + return $binding; + } + + $key = $paramClassName; + + $binding = $this->getInternalByClassNameKey($class?->getName(), $key); + + if ($binding) { + return $binding; + } + + return null; + } + + private function getQualifiedName(ReflectionParameter $param): ?string + { + $qualifierClass = $param->getAttributes(Qualify::class)[0] ?? null; + + if (!$qualifierClass) { + return null; + } + + $qualifier = $qualifierClass->newInstance(); + + return $qualifier->qualifier; + } + + /** + * @param ReflectionClass $class + */ + private function getInternalContextualNamed(ReflectionClass $class, ReflectionParameter $param): ?Binding + { + $key = '$' . $param->getName(); + + if (!$this->data->hasContext($class->getName(), $key)) { + return null; + } + + $type = $param->getType(); + + $binding = $this->data->getContext($class->getName(), $key); + + $notMatching = + $type instanceof ReflectionNamedType && + !$type->isBuiltin() && + $binding->getType() === Binding::VALUE && + is_scalar($binding->getValue()); + + if ($notMatching) { + return null; + } + + return $binding; + } + + /** + * @param ?class-string $className + */ + private function getInternalByClassNameKey(?string $className, string $key): ?Binding + { + if ($className && $this->data->hasContext($className, $key)) { + return $this->data->getContext($className, $key); + } + + if ($this->data->hasGlobal($key)) { + return $this->data->getGlobal($key); + } + + return null; + } + + private function getClassNameFromParameterType(mixed $type): ?string + { + $dependencyClassName = null; + + if ( + $type instanceof ReflectionNamedType && + !$type->isBuiltin() + ) { + $dependencyClassName = $type->getName(); + } + + return $dependencyClassName; + } +} diff --git a/application/Espo/Core/Binding/BindingContainerBuilder.php b/application/Espo/Core/Binding/BindingContainerBuilder.php new file mode 100644 index 0000000..a79c5a2 --- /dev/null +++ b/application/Espo/Core/Binding/BindingContainerBuilder.php @@ -0,0 +1,155 @@ +. + * + * 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\Binding; + +use Closure; +use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\QualifiedClassKey; + +class BindingContainerBuilder +{ + private BindingData $data; + private Binder $binder; + + public function __construct() + { + $this->data = new BindingData(); + $this->binder = new Binder($this->data); + } + + /** + * Bind an interface to an implementation. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param class-string $implementationClassName An implementation class name. + */ + public function bindImplementation(string|NamedClassKey|QualifiedClassKey $key, string $implementationClassName): self + { + $this->binder->bindImplementation($key, $implementationClassName); + + return $this; + } + + /** + * Bind an interface to a specific service. + * + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param string $serviceName A service name. + * @noinspection PhpUnused + */ + public function bindService(string|NamedClassKey|QualifiedClassKey $key, string $serviceName): self + { + $this->binder->bindService($key, $serviceName); + + return $this; + } + + /** + * Bind an interface to a callback. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param Closure $callback A callback that will resolve a dependency. + * @todo Change to Closure(...): T Once https://github.com/phpstan/phpstan/issues/8214 is implemented. + * @noinspection PhpUnused + */ + public function bindCallback(string|NamedClassKey|QualifiedClassKey $key, Closure $callback): self + { + $this->binder->bindCallback($key, $callback); + + return $this; + } + + /** + * Bind an interface to a specific instance. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param T $instance An instance. + */ + public function bindInstance(string|NamedClassKey|QualifiedClassKey $key, object $instance): self + { + $this->binder->bindInstance($key, $instance); + + return $this; + } + + /** + * Bind an interface to a factory. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param class-string> $factoryClassName A factory class name. + * @noinspection PhpUnused + */ + public function bindFactory(string|NamedClassKey|QualifiedClassKey $key, string $factoryClassName): self + { + $this->binder->bindFactory($key, $factoryClassName); + + return $this; + } + + /** + * Creates a contextual binder and pass it as an argument of a callback. + * + * @param class-string $className A context. + * @param Closure(ContextualBinder): void $callback A callback with a `ContextualBinder` argument. + */ + public function inContext(string $className, Closure $callback): self + { + $contextualBinder = new ContextualBinder($this->data, $className); + + $callback($contextualBinder); + + return $this; + } + + /** + * Build. + */ + public function build(): BindingContainer + { + return new BindingContainer($this->data); + } + + /** + * Create an instance. + */ + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Binding/BindingData.php b/application/Espo/Core/Binding/BindingData.php new file mode 100644 index 0000000..4965c05 --- /dev/null +++ b/application/Espo/Core/Binding/BindingData.php @@ -0,0 +1,136 @@ +. + * + * 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\Binding; + +use LogicException; +use stdClass; + +class BindingData +{ + private stdClass $global; + private stdClass $context; + + public function __construct() + { + $this->global = (object) []; + $this->context = (object) []; + } + + public function addContext(string $className, string $key, Binding $binding): void + { + if (!property_exists($this->context, $className)) { + $this->context->$className = (object) []; + } + + $this->context->$className->$key = $binding; + } + + public function addGlobal(string $key, Binding $binding): void + { + $this->global->$key = $binding; + } + + /** + * @param class-string $className + */ + public function hasContext(string $className, string $key): bool + { + if (!property_exists($this->context, $className)) { + return false; + } + + if (!property_exists($this->context->$className, $key)) { + return false; + } + + return true; + } + + /** + * @param class-string $className + */ + public function getContext(string $className, string $key): Binding + { + if (!$this->hasContext($className, $key)) { + throw new LogicException("No data."); + } + + return $this->context->$className->$key; + } + + public function hasGlobal(string $key): bool + { + if (!property_exists($this->global, $key)) { + return false; + } + + return true; + } + + public function getGlobal(string $key): Binding + { + if (!$this->hasGlobal($key)) { + throw new LogicException("No data."); + } + + return $this->global->$key; + } + + /** + * @return string[] + */ + public function getGlobalKeyList(): array + { + return array_keys( + get_object_vars($this->global) + ); + } + + /** + * @return class-string[] + */ + public function getContextList(): array + { + /** @var class-string[] */ + return array_keys( + get_object_vars($this->context) + ); + } + + /** + * @return string[] + */ + public function getContextKeyList(string $context): array + { + return array_keys( + get_object_vars($this->context->$context ?? (object) []) + ); + } +} diff --git a/application/Espo/Core/Binding/BindingLoader.php b/application/Espo/Core/Binding/BindingLoader.php new file mode 100644 index 0000000..7337842 --- /dev/null +++ b/application/Espo/Core/Binding/BindingLoader.php @@ -0,0 +1,35 @@ +. + * + * 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\Binding; + +interface BindingLoader +{ + public function load(): BindingData; +} diff --git a/application/Espo/Core/Binding/BindingProcessor.php b/application/Espo/Core/Binding/BindingProcessor.php new file mode 100644 index 0000000..8dd28bd --- /dev/null +++ b/application/Espo/Core/Binding/BindingProcessor.php @@ -0,0 +1,35 @@ +. + * + * 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\Binding; + +interface BindingProcessor +{ + public function process(Binder $binder): void; +} diff --git a/application/Espo/Core/Binding/ContextualBinder.php b/application/Espo/Core/Binding/ContextualBinder.php new file mode 100644 index 0000000..e3cfd26 --- /dev/null +++ b/application/Espo/Core/Binding/ContextualBinder.php @@ -0,0 +1,196 @@ +. + * + * 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\Binding; + +use Closure; +use Espo\Core\Binding\Key\NamedClassKey; +use Espo\Core\Binding\Key\NamedKey; +use Espo\Core\Binding\Key\QualifiedClassKey; +use LogicException; + +class ContextualBinder +{ + /** + * @param class-string $className + */ + public function __construct( + private BindingData $data, + private string $className, + ) {} + + /** + * Bind an interface to an implementation. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param class-string $implementationClassName An implementation class name. + */ + public function bindImplementation( + string|NamedClassKey|QualifiedClassKey $key, + string $implementationClassName, + ): self { + + $key = self::keyToString($key); + $this->validateBindingKeyNoName($key); + + $binding = Binding::createFromImplementationClassName($implementationClassName); + + $this->data->addContext($this->className, $key, $binding); + + return $this; + } + + /** + * Bind an interface to a specific service. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param string $serviceName A service name. + */ + public function bindService(string|NamedClassKey|QualifiedClassKey $key, string $serviceName): self + { + $key = self::keyToString($key); + $this->validateBindingKeyNoName($key); + + $this->data->addContext($this->className, $key, Binding::createFromServiceName($serviceName)); + + return $this; + } + + /** + * Bind an interface or parameter name to a specific value. + * + * @param string|NamedKey|NamedClassKey $key + * A parameter name (`$name`) or an interface with a parameter name. + * @param mixed $value A value of any type. + */ + public function bindValue(string|NamedKey|NamedClassKey $key, $value): self + { + $key = self::keyToString($key); + $this->validateBindingKeyParameterName($key); + + $this->data->addContext($this->className, $key, Binding::createFromValue($value)); + + return $this; + } + + /** + * Bind an interface to a specific instance. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param T $instance An instance. + */ + public function bindInstance(string|NamedClassKey|QualifiedClassKey $key, object $instance): self + { + $key = self::keyToString($key); + $this->validateBindingKeyNoName($key); + + $this->data->addContext($this->className, $key, Binding::createFromValue($instance)); + + return $this; + } + + /** + * Bind an interface or parameter name to a callback. + * + * @param class-string|NamedClassKey|NamedKey|QualifiedClassKey $key + * An interface, parameter name or both. + * @param Closure $callback A callback that will resolve a dependency. + * @todo Change to Closure(...): mixed Once https://github.com/phpstan/phpstan/issues/8214 is implemented. + */ + public function bindCallback(string|NamedClassKey|NamedKey|QualifiedClassKey $key, Closure $callback): self + { + $key = self::keyToString($key); + $this->validateBinding($key); + + $this->data->addContext($this->className, $key, Binding::createFromCallback($callback)); + + return $this; + } + + /** + * Bind an interface to a factory. + * + * @template T of object + * @param class-string|NamedClassKey|QualifiedClassKey $key + * An interface, an interface with a parameter name or an interface with a qualifier. + * @param class-string> $factoryClassName A factory class name. + */ + public function bindFactory(string|NamedClassKey|QualifiedClassKey $key, string $factoryClassName): self + { + $key = self::keyToString($key); + $this->validateBindingKeyNoName($key); + + $this->data->addContext($this->className, $key, Binding::createFromFactoryClassName($factoryClassName)); + + return $this; + } + + private function validateBinding(string $key): void + { + if (!$key) { + throw new LogicException("Bad binding."); + } + } + + private function validateBindingKeyNoName(string $key): void + { + $this->validateBinding($key); + + if ($key[0] === '$') { + throw new LogicException("Can't bind a parameter name w/o an interface."); + } + + if ($key[0] === '#') { + throw new LogicException("Can't bind a qualification name w/o an interface."); + } + } + + private function validateBindingKeyParameterName(string $key): void + { + $this->validateBinding($key); + + if (!str_contains($key, '$')) { + throw new LogicException("Can't bind w/o a parameter name."); + } + } + + /** + * @param string|NamedKey|NamedClassKey|QualifiedClassKey $key + */ + private static function keyToString(string|NamedKey|NamedClassKey|QualifiedClassKey $key): string + { + return is_string($key) ? $key : $key->toString(); + } +} diff --git a/application/Espo/Core/Binding/EspoBindingLoader.php b/application/Espo/Core/Binding/EspoBindingLoader.php new file mode 100644 index 0000000..049fd91 --- /dev/null +++ b/application/Espo/Core/Binding/EspoBindingLoader.php @@ -0,0 +1,91 @@ +. + * + * 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\Binding; + +use Espo\Core\Utils\Module; +use Espo\Binding; + +class EspoBindingLoader implements BindingLoader +{ + /** @var string[] */ + private array $moduleNameList; + + public function __construct( + Module $module, + private ?BindingProcessor $binding = null, + ) { + $this->moduleNameList = $module->getOrderedList(); + } + + public function load(): BindingData + { + $data = new BindingData(); + $binder = new Binder($data); + + (new Binding())->process($binder); + + foreach ($this->moduleNameList as $moduleName) { + $this->loadModule($binder, $moduleName); + } + + $this->loadCustom($binder); + + $this->binding?->process($binder); + + return $data; + } + + private function loadModule(Binder $binder, string $moduleName): void + { + $className = 'Espo\\Modules\\' . $moduleName . '\\Binding'; + + if (!class_exists($className)) { + return; + } + + /** @var class-string $className */ + + (new $className())->process($binder); + } + + private function loadCustom(Binder $binder): void + { + /** @var class-string|string $className */ + $className = 'Espo\\Custom\\Binding'; + + if (!class_exists($className)) { + return; + } + + /** @var class-string $className */ + + (new $className())->process($binder); + } +} diff --git a/application/Espo/Core/Binding/Factory.php b/application/Espo/Core/Binding/Factory.php new file mode 100644 index 0000000..eeb9243 --- /dev/null +++ b/application/Espo/Core/Binding/Factory.php @@ -0,0 +1,42 @@ +. + * + * 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\Binding; + +/** + * @template T of object + */ +interface Factory +{ + /** + * @return T + * @noinspection PhpDocSignatureInspection + */ + public function create(): object; +} diff --git a/application/Espo/Core/Binding/Key/NamedClassKey.php b/application/Espo/Core/Binding/Key/NamedClassKey.php new file mode 100644 index 0000000..26e35f6 --- /dev/null +++ b/application/Espo/Core/Binding/Key/NamedClassKey.php @@ -0,0 +1,62 @@ +. + * + * 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\Binding\Key; + +/** + * A key for a class-type-hinted constructor parameter with a parameter name. + * + * @template-covariant T of object + */ +class NamedClassKey +{ + /** + * @param class-string $className + */ + private function __construct(private string $className, private string $parameterName) + {} + + /** + * Create. + * + * @template TC of object + * @param class-string $className An interface. + * @param string $parameterName A constructor parameter name (w/o '$'). + * @return self + */ + public static function create(string $className, string $parameterName): self + { + return new self($className, $parameterName); + } + + public function toString(): string + { + return $this->className . ' $' . $this->parameterName; + } +} diff --git a/application/Espo/Core/Binding/Key/NamedKey.php b/application/Espo/Core/Binding/Key/NamedKey.php new file mode 100644 index 0000000..1d2f98b --- /dev/null +++ b/application/Espo/Core/Binding/Key/NamedKey.php @@ -0,0 +1,54 @@ +. + * + * 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\Binding\Key; + +/** + * A parameter-name-only key. + */ +class NamedKey +{ + private function __construct(private string $parameterName) + {} + + /** + * Create. + * + * @param string $parameterName A constructor parameter name (w/o '$'). + */ + public static function create(string $parameterName): self + { + return new self($parameterName); + } + + public function toString(): string + { + return '$' . $this->parameterName; + } +} diff --git a/application/Espo/Core/Binding/Key/QualifiedClassKey.php b/application/Espo/Core/Binding/Key/QualifiedClassKey.php new file mode 100644 index 0000000..9790b7f --- /dev/null +++ b/application/Espo/Core/Binding/Key/QualifiedClassKey.php @@ -0,0 +1,69 @@ +. + * + * 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\Binding\Key; + +/** + * A key for a class-type-hinted constructor parameter with a qualifier. + * Use it to bind to qualified constructor parameters. For example, one interface + * can be used with a qualifier and without it. In the latter case, + * the default binding it applied. While in the formed case, a special binding is applied. + * For example, a caching pool interface may be bound to two different services: + * core cache and application cache. + * + * @since 10.1.0 + * + * @template-covariant T of object + */ +class QualifiedClassKey +{ + /** + * @param class-string $className + */ + private function __construct(private string $className, private string $qualifier) + {} + + /** + * Create. + * + * @template TC of object + * @param class-string $className An interface. + * @param string $qualifier A qualification name. + * @return self + */ + public static function create(string $className, string $qualifier): self + { + return new self($className, $qualifier); + } + + public function toString(): string + { + return $this->className . ' #' . $this->qualifier; + } +} diff --git a/application/Espo/Core/Cleanup/Cleanup.php b/application/Espo/Core/Cleanup/Cleanup.php new file mode 100644 index 0000000..904bf3d --- /dev/null +++ b/application/Espo/Core/Cleanup/Cleanup.php @@ -0,0 +1,35 @@ +. + * + * 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\Cleanup; + +interface Cleanup +{ + public function process(): void; +} diff --git a/application/Espo/Core/Console/Command.php b/application/Espo/Core/Console/Command.php new file mode 100644 index 0000000..c1136c5 --- /dev/null +++ b/application/Espo/Core/Console/Command.php @@ -0,0 +1,40 @@ +. + * + * 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\Console; + +use Espo\Core\Console\Command\Params; + +/** + * A command. + */ +interface Command +{ + public function run(Params $params, IO $io): void; +} diff --git a/application/Espo/Core/Console/Command/Params.php b/application/Espo/Core/Console/Command/Params.php new file mode 100644 index 0000000..9b4eed5 --- /dev/null +++ b/application/Espo/Core/Console/Command/Params.php @@ -0,0 +1,143 @@ +. + * + * 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\Console\Command; + +use Espo\Core\Utils\Util; + +/** + * Command parameters. + * + * Immutable. + */ +class Params +{ + /** @var array */ + private $options; + /** @var string[] */ + private $flagList; + /** @var string[] */ + private $argumentList; + + /** + * @param array|null $options + * @param string[]|null $flagList + * @param string[]|null $argumentList + */ + public function __construct(?array $options, ?array $flagList, ?array $argumentList) + { + $this->options = $options ?? []; + $this->flagList = $flagList ?? []; + $this->argumentList = $argumentList ?? []; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @return string[] + */ + public function getFlagList(): array + { + return $this->flagList; + } + + /** + * @return string[] + */ + public function getArgumentList(): array + { + return $this->argumentList; + } + + /** + * Has an option. + */ + public function hasOption(string $name): bool + { + return array_key_exists($name, $this->options); + } + + /** + * Get an option. + */ + public function getOption(string $name): ?string + { + return $this->options[$name] ?? null; + } + + /** + * Has a flag. + */ + public function hasFlag(string $name): bool + { + return in_array($name, $this->flagList); + } + + /** + * Get an argument by index. + */ + public function getArgument(int $index): ?string + { + return $this->argumentList[$index] ?? null; + } + + /** + * @param array $args + */ + public static function fromArgs(array $args): self + { + $argumentList = []; + $options = []; + $flagList = []; + + foreach ($args as $i => $item) { + if (str_starts_with($item, '--') && strpos($item, '=') > 2) { + [$name, $value] = explode('=', substr($item, 2)); + + $name = Util::hyphenToCamelCase($name); + + $options[$name] = $value; + } else if (str_starts_with($item, '--')) { + $flagList[] = Util::hyphenToCamelCase(substr($item, 2)); + } else if (str_starts_with($item, '-')) { + $flagList[] = substr($item, 1); + } else if ($i > 0) { + $argumentList[] = $item; + } + } + + return new self($options, $flagList, $argumentList); + } +} diff --git a/application/Espo/Core/Console/CommandManager.php b/application/Espo/Core/Console/CommandManager.php new file mode 100644 index 0000000..9e878f5 --- /dev/null +++ b/application/Espo/Core/Console/CommandManager.php @@ -0,0 +1,212 @@ +. + * + * 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\Console; + +use Espo\Core\ApplicationUser; +use Espo\Core\Console\Exceptions\InvalidArgument; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Util; +use Espo\Core\Console\Exceptions\CommandNotSpecified; +use Espo\Core\Console\Exceptions\CommandNotFound; +use Espo\Core\Console\Command\Params; + +/** + * Processes console commands. + */ +class CommandManager +{ + private const DEFAULT_COMMAND = 'Help'; + private const DEFAULT_COMMAND_FLAG = 'help'; + + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private ApplicationUser $applicationUser + ) {} + + /** + * @param array $argv + * @return int<0, 255> Exit-status. + */ + public function run(array $argv): int + { + $command = $this->getCommandNameFromArgv($argv); + $params = $this->createParamsFromArgv($argv); + + if ( + $command === null && + ( + $params->hasFlag(self::DEFAULT_COMMAND_FLAG) || + count($params->getFlagList()) === 0 && + count($params->getOptions()) === 0 && + count($params->getArgumentList()) === 0 + ) + ) { + $command = self::DEFAULT_COMMAND; + } + + if ($command === null) { + throw new CommandNotSpecified("Command name is not specified."); + } + + $this->checkParams($command, $params); + + $io = new IO(); + + $this->setupUser($command); + + $commandObj = $this->createCommand($command); + + if (!$commandObj instanceof Command) { + // for backward compatibility + assert(method_exists($commandObj, 'run')); + + $commandObj->run($params->getOptions(), $params->getFlagList(), $params->getArgumentList()); + + return 0; + } + + $commandObj->run($params, $io); + + return $io->getExitStatus(); + } + + /** + * @param array $argv + */ + private function getCommandNameFromArgv(array $argv): ?string + { + $command = isset($argv[1]) ? trim($argv[1]) : null; + + if ($command === null && count($argv) < 2) { + return null; + } + + if (!$command || !ctype_alpha($command[0])) { + return null; + } + + return ucfirst(Util::hyphenToCamelCase($command)); + } + + private function createCommand(string $command): object + { + $className = $this->getClassName($command); + + return $this->injectableFactory->create($className); + } + + /** + * @return class-string + */ + private function getClassName(string $command): string + { + /** @var ?class-string $className */ + $className = + $this->metadata->get(['app', 'consoleCommands', lcfirst($command), 'className']); + + if ($className) { + return $className; + } + + $className = 'Espo\\Core\\Console\\Commands\\' . $command; + + if (!class_exists($className)) { + throw new CommandNotFound("Command '" . Util::camelCaseToHyphen($command) ."' does not exist."); + } + + /** @var class-string */ + return $className; + } + + /** + * @param array $argv + */ + private function createParamsFromArgv(array $argv): Params + { + return Params::fromArgs(array_slice($argv, 1)); + } + + private function setupUser(string $command): void + { + $noSystemUser = $this->metadata->get(['app', 'consoleCommands', lcfirst($command), 'noSystemUser']); + + if ($noSystemUser) { + return; + } + + $this->applicationUser->setupSystemUser(); + } + + private function checkParams(string $command, Params $params): void + { + $this->checkOptions($command, $params); + $this->checkFlags($command, $params); + } + + private function checkOptions(string $command, Params $params): void + { + $allowedOptions = $this->metadata->get(['app', 'consoleCommands', lcfirst($command), 'allowedOptions']); + + if (!is_array($allowedOptions)) { + return; + } + + $notAllowedOptions = array_diff(array_keys($params->getOptions()), $allowedOptions); + + if ($notAllowedOptions === []) { + return; + } + + $msg = sprintf("Not allowed options: %s.", implode(', ', $notAllowedOptions)); + + throw new InvalidArgument($msg); + } + + private function checkFlags(string $command, Params $params): void + { + $allowedFlags = $this->metadata->get(['app', 'consoleCommands', lcfirst($command), 'allowedFlags']); + + if (!is_array($allowedFlags)) { + return; + } + + $notAllowedFlags = array_diff($params->getFlagList(), $allowedFlags); + + if ($notAllowedFlags === []) { + return; + } + + $msg = sprintf("Not allowed flags: %s.", implode(', ', $notAllowedFlags)); + + throw new InvalidArgument($msg); + } +} diff --git a/application/Espo/Core/Console/Commands/AclCheck.php b/application/Espo/Core/Console/Commands/AclCheck.php new file mode 100644 index 0000000..235b6d0 --- /dev/null +++ b/application/Espo/Core/Console/Commands/AclCheck.php @@ -0,0 +1,173 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\Core\AclManager; +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Container; +use Espo\Core\Portal\Application as PortalApplication; +use Espo\Core\Acl\Table; + +/** + * Checks access for websocket topic subscription. Prints `true` if access allowed. + * + * @noinspection PhpUnused + */ +class AclCheck implements Command +{ + public function __construct(private Container $container) + {} + + public function run(Params $params, IO $io): void + { + $userId = $params->getOption('userId'); + $scope = $params->getOption('scope'); + $id = $params->getOption('id'); + /** @var Table::ACTION_*|null $action */ + $action = $params->getOption('action'); + + $io->setExitStatus(1); + + if (!$userId || !$scope) { + return; + } + + if ($params->hasOption('id') && !$id) { + return; + } + + $entityManager = $this->container->getByClass(EntityManager::class); + + $user = $entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + return; + } + + if ($user->isPortal()) { + $this->processPortal( + io: $io, + userId: $userId, + scope: $scope, + action: $action, + id: $id, + user: $user, + ); + } + + if (!$this->check($user, $scope, $id, $action, $this->container)) { + return; + } + + $io->setExitStatus(0); + $io->write('true'); + } + + /** + * @param Table::ACTION_*|null $action + * @noinspection PhpDocSignatureInspection + */ + private function check( + User $user, + string $scope, + ?string $id, + ?string $action, + Container $container + ): bool { + + if (!$id) { + $aclManager = $container->getByClass(AclManager::class); + + return $aclManager->check($user, $scope, $action); + } + + $entityManager = $container->getByClass(EntityManager::class); + + $entity = $entityManager->getEntityById($scope, $id); + + if (!$entity) { + return false; + } + + $aclManager = $container->getByClass(AclManager::class); + + return $aclManager->check($user, $entity, $action); + } + + /** + * @param Table::ACTION_*|null $action + * @noinspection PhpDocSignatureInspection + */ + private function processPortal( + IO $io, + string $userId, + string $scope, + ?string $action, + ?string $id, + User $user, + ): void { + + $portalIds = $user->getLinkMultipleIdList('portals'); + + foreach ($portalIds as $portalId) { + try { + $application = new PortalApplication($portalId); + } catch (Forbidden|NotFound) { + return; + } + + $containerPortal = $application->getContainer(); + $entityManager = $containerPortal->getByClass(EntityManager::class); + + $user = $entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + return; + } + + $result = $this->check($user, $scope, $id, $action, $containerPortal); + + if (!$result) { + continue; + } + + $io->setExitStatus(0); + $io->write('true'); + + return; + } + } +} diff --git a/application/Espo/Core/Console/Commands/AppInfo.php b/application/Espo/Core/Console/Commands/AppInfo.php new file mode 100644 index 0000000..7e285e9 --- /dev/null +++ b/application/Espo/Core/Console/Commands/AppInfo.php @@ -0,0 +1,98 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Util; +use Espo\Tools\ConsoleAppInfo\InfoProvider; + +/** + * @noinspection PhpUnused + */ +class AppInfo implements Command +{ + public function __construct(private InjectableFactory $injectableFactory, private FileManager $fileManager) + {} + + public function run(Params $params, IO $io): void + { + /** @var string[] $fileList */ + $fileList = $this->fileManager->getFileList('application/Espo/Classes/AppInfo'); + + $typeList = array_map( + function ($item): string { + return lcfirst(substr($item, 0, -4)); + }, + $fileList + ); + + foreach ($typeList as $type) { + if ($params->hasFlag($type)) { + $this->processType($io, $type, $params); + + return; + } + } + + if (count($params->getFlagList()) === 0) { + $io->writeLine(""); + $io->writeLine("Available flags:"); + $io->writeLine(""); + + foreach ($typeList as $type) { + $io->writeLine(' --' . Util::camelCaseToHyphen($type)); + } + + $io->writeLine(""); + + return; + } + + $io->writeLine("Not supported flag specified."); + $io->setExitStatus(1); + } + + protected function processType(IO $io, string $type, Params $params): void + { + /** @var class-string $className */ + $className = 'Espo\\Classes\\AppInfo\\' . ucfirst($type); + + $provider = $this->injectableFactory->create($className); + + $result = $provider->get($params); + + $io->write($result); + $io->writeLine(""); + } +} diff --git a/application/Espo/Core/Console/Commands/AuthTokenCheck.php b/application/Espo/Core/Console/Commands/AuthTokenCheck.php new file mode 100644 index 0000000..b9e2431 --- /dev/null +++ b/application/Espo/Core/Console/Commands/AuthTokenCheck.php @@ -0,0 +1,93 @@ +. + * + * 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\Console\Commands; + +use Espo\Entities\User; +use Espo\Core\Authentication\AuthToken\Manager as AuthTokenManager; +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +/** + * @noinspection PhpUnused + */ +class AuthTokenCheck implements Command +{ + public function __construct(private EntityManager $entityManager, private AuthTokenManager $authTokenManager) + {} + + public function run(Params $params, IO $io): void + { + $io->setExitStatus(1); + + $token = $params->getArgument(0); + $userId = $params->getArgument(1); + + if (!$token) { + return; + } + + $authToken = $this->authTokenManager->get($token); + + if (!$authToken) { + return; + } + + if (!$authToken->isActive()) { + return; + } + + if (!$authToken->getUserId()) { + return; + } + + if ($userId && $authToken->getUserId() !== $userId) { + return; + } + + $user = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->select(Attribute::ID) + ->where([ + Attribute::ID => $authToken->getUserId(), + User::ATTR_IS_ACTIVE => true, + ]) + ->findOne(); + + if (!$user) { + return; + } + + $io->write($user->getId()); + $io->setExitStatus(0); + } +} diff --git a/application/Espo/Core/Console/Commands/ClearCache.php b/application/Espo/Core/Console/Commands/ClearCache.php new file mode 100644 index 0000000..4899f78 --- /dev/null +++ b/application/Espo/Core/Console/Commands/ClearCache.php @@ -0,0 +1,55 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\DataManager; +use Espo\Core\Exceptions\Error; + +/** + * @noinspection PhpUnused + */ +class ClearCache implements Command +{ + public function __construct(private DataManager $dataManager) + {} + + /** + * @throws Error + */ + public function run(Params $params, IO $io): void + { + $this->dataManager->clearCache(); + + $io->writeLine("Cache has been cleared."); + } +} diff --git a/application/Espo/Core/Console/Commands/EntityUtil.php b/application/Espo/Core/Console/Commands/EntityUtil.php new file mode 100644 index 0000000..83496d7 --- /dev/null +++ b/application/Espo/Core/Console/Commands/EntityUtil.php @@ -0,0 +1,153 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; + +use Espo\Tools\EntityManager\Rename\Renamer; +use Espo\Tools\EntityManager\Rename\FailReason as RenameFailReason; + +/** + * @noinspection PhpUnused + */ +class EntityUtil implements Command +{ + public function __construct(private Renamer $renamer) + {} + + public function run(Params $params, IO $io): void + { + $subCommand = $params->getArgument(0); + + if (!$subCommand) { + $io->writeLine("No sub-command specified."); + $io->setExitStatus(1); + + return; + } + + if ($subCommand === 'rename') { + $this->runRename($params, $io); + } + } + + private function runRename(Params $params, IO $io): void + { + $entityType = $params->getOption('entityType'); + $newName = $params->getOption('newName'); + + if (!$entityType) { + $io->writeLine("No --entity-type option specified."); + } + + if (!$newName) { + $io->writeLine("No --new-name option specified."); + } + + if (!$entityType || !$newName) { + return; + } + + $result = $this->renamer->process($entityType, $newName, $io); + + $io->writeLine(""); + + if (!$result->isFail()) { + $io->writeLine("Finished."); + + return; + } + + $io->setExitStatus(1); + $io->write("Failed. "); + + $failReason = $result->getFailReason(); + + if ($failReason === RenameFailReason::NAME_BAD) { + $io->writeLine("Name is bad."); + + return; + } + + if ($failReason === RenameFailReason::NAME_NOT_ALLOWED) { + $io->writeLine("Name is not allowed."); + + return; + } + + if ($failReason === RenameFailReason::NAME_TOO_LONG) { + $io->writeLine("Name is too long."); + + return; + } + + if ($failReason === RenameFailReason::NAME_TOO_SHORT) { + $io->writeLine("Name is too short."); + + return; + } + + if ($failReason === RenameFailReason::NAME_USED) { + $io->writeLine("Name is already used."); + + return; + } + + if ($failReason === RenameFailReason::DOES_NOT_EXIST) { + $io->writeLine("Entity type `$entityType` does not exist."); + + return; + } + + if ($failReason === RenameFailReason::NOT_CUSTOM) { + $io->writeLine("Entity type `$entityType` is not custom, hence can't be renamed."); + + return; + } + + if ($failReason === RenameFailReason::ENV_NOT_SUPPORTED) { + $io->writeLine("Environment is not supported."); + + return; + } + + if ($failReason === RenameFailReason::TABLE_EXISTS) { + $io->writeLine("Table already exists."); + + return; + } + + if ($failReason === RenameFailReason::ERROR) { + $io->writeLine("Error occurred."); + } + } +} diff --git a/application/Espo/Core/Console/Commands/Extension.php b/application/Espo/Core/Console/Commands/Extension.php new file mode 100644 index 0000000..bc7fb52 --- /dev/null +++ b/application/Espo/Core/Console/Commands/Extension.php @@ -0,0 +1,285 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Exceptions\Error; +use Espo\Core\Name\Field; +use Espo\Entities\Extension as ExtensionEntity; +use Espo\ORM\EntityManager; +use Espo\Core\Upgrades\ExtensionManager; +use Espo\Core\Container; +use Espo\Core\Utils\File\Manager as FileManager; + +use Espo\ORM\Name\Attribute; +use Throwable; + +/** + * @noinspection PhpUnused + */ +class Extension implements Command +{ + public function __construct( + private Container $container, + private EntityManager $entityManager, + private FileManager $fileManager + ) {} + + /** + * @throws Error + */ + public function run(Params $params, IO $io): void + { + if ($params->hasFlag('l') || $params->hasFlag('list')) { + $this->printList($io); + + return; + } + + if ($params->hasFlag('u')) { + $name = $params->getOption('name'); + $id = $params->getOption('id'); + + if (!$name && !$id) { + $io->writeLine("Can't uninstall. Specify --name=\"Extension Name\"."); + $io->setExitStatus(1); + + return; + } + + $this->runUninstall($params, $io); + + return; + } + + $file = $params->getOption('file'); + + if (!$file) { + $io->writeLine(""); + $io->writeLine("Install extension:"); + $io->writeLine(""); + $io->writeLine(" bin/command extension --file=\"path/to/package.zip\""); + $io->writeLine(""); + + $io->writeLine("Uninstall extension:"); + $io->writeLine(""); + $io->writeLine(" bin/command extension -u --name=\"Extension Name\""); + $io->writeLine(""); + + $io->writeLine("List all extensions:"); + $io->writeLine(""); + $io->writeLine(" bin/command extension --list"); + $io->writeLine(""); + + return; + } + + $this->runInstall($file, $io); + } + + /** + * @throws Error + */ + private function runInstall(string $file, IO $io): void + { + $manager = $this->createExtensionManager(); + + if (!$this->fileManager->isFile($file)) { + $io->writeLine("File does not exist."); + $io->setExitStatus(1); + + return; + } + + $fileData = $this->fileManager->getContents($file); + + $fileDataEncoded = 'data:application/zip;base64,' . base64_encode($fileData); + + try { + $id = $manager->upload($fileDataEncoded); + } catch (Throwable $e) { + $io->writeLine($e->getMessage()); + $io->setExitStatus(1); + + return; + } + + $manifest = $manager->getManifestById($id); + + $name = $manifest['name'] ?? null; + $version = $manifest['version'] ?? null; + + if (!$name) { + $io->writeLine("Can't install. Bad manifest.json file."); + $io->setExitStatus(1); + + return; + } + + $io->write("Installing... Do not close the terminal. This may take a while..."); + + try { + $manager->install(['id' => $id]); + } catch (Throwable $e) { + $io->writeLine(""); + $io->writeLine($e->getMessage()); + $io->setExitStatus(1); + + return; + } + + $io->writeLine(""); + $io->writeLine("Extension '$name' v$version is installed.\nExtension ID: '$id'."); + } + + protected function runUninstall(Params $params, IO $io): void + { + $id = $params->getOption('id'); + $name = $params->getOption('name'); + $toKeep = $params->hasFlag('k'); + + if ($id) { + $record = $this->entityManager + ->getRDBRepository(ExtensionEntity::ENTITY_TYPE) + ->where([ + Attribute::ID => $id, + 'isInstalled' => true, + ]) + ->findOne(); + + if (!$record) { + $io->writeLine("Extension with ID '$id' is not installed."); + $io->setExitStatus(1); + + return; + } + + $name = $record->get(Field::NAME); + } else { + if (!$name) { + $io->writeLine("Can't uninstall. No --name or --id specified."); + $io->setExitStatus(1); + + return; + } + + $record = $this->entityManager + ->getRDBRepository(ExtensionEntity::ENTITY_TYPE) + ->where([ + 'name' => $name, + 'isInstalled' => true, + ]) + ->findOne(); + + if (!$record) { + $io->writeLine("Extension '$name' is not installed."); + $io->setExitStatus(1); + + return; + } + + $id = $record->getId(); + } + + $manager = $this->createExtensionManager(); + + $io->write("Uninstalling... Do not close the terminal. This may take a while..."); + + try { + $manager->uninstall(['id' => $id]); + } catch (Throwable $e) { + $io->writeLine(""); + $io->writeLine($e->getMessage()); + $io->setExitStatus(1); + + return; + } + + $io->writeLine(""); + + if ($toKeep) { + $io->writeLine("Extension '$name' is uninstalled."); + $io->setExitStatus(1); + + return; + } + + try { + $manager->delete(['id' => $id]); + } catch (Throwable $e) { + $io->writeLine($e->getMessage()); + $io->writeLine("Extension '$name' is uninstalled but could not be deleted."); + + return; + } + + $io->writeLine("Extension '$name' is uninstalled and deleted."); + } + + private function printList(IO $io): void + { + $collection = $this->entityManager + ->getRDBRepositoryByClass(ExtensionEntity::class) + ->find(); + + $count = count($collection); + + /** @noinspection PhpIfWithCommonPartsInspection */ + if ($count === 0) { + $io->writeLine(""); + $io->writeLine("No extensions."); + $io->writeLine(""); + + return; + } + + $io->writeLine(""); + $io->writeLine("Extensions:"); + $io->writeLine(""); + + foreach ($collection as $extension) { + $isInstalled = $extension->isInstalled(); + + $io->writeLine(' Name: ' . $extension->getName()); + $io->writeLine(' ID: ' . $extension->getId()); + $io->writeLine(' Version: ' . $extension->getVersion()); + $io->writeLine(' Installed: ' . ($isInstalled ? 'yes' : 'no')); + + $io->writeLine(""); + } + } + + private function createExtensionManager(): ExtensionManager + { + return new ExtensionManager($this->container); + } +} diff --git a/application/Espo/Core/Console/Commands/Help.php b/application/Espo/Core/Console/Commands/Help.php new file mode 100644 index 0000000..0e4d097 --- /dev/null +++ b/application/Espo/Core/Console/Commands/Help.php @@ -0,0 +1,83 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; + +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Util; + +/** + * @noinspection PhpUnused + */ +class Help implements Command +{ + public function __construct(private Metadata $metadata) + {} + + public function run(Params $params, IO $io): void + { + /** @var string[] $fullCommandList */ + $fullCommandList = array_keys($this->metadata->get(['app', 'consoleCommands']) ?? []); + + $commandList = array_filter( + $fullCommandList, + function ($item): bool { + return (bool) $this->metadata->get(['app', 'consoleCommands', $item, 'listed']); + } + ); + + sort($commandList); + + $io->writeLine(""); + $io->writeLine("Available commands:"); + $io->writeLine(""); + + foreach ($commandList as $item) { + $io->writeLine( + ' ' . Util::camelCaseToHyphen($item) + ); + } + + $io->writeLine(""); + + $io->writeLine("Usage:"); + $io->writeLine(""); + $io->writeLine(" bin/command [command-name] [some-argument] [--some-option=value] [--some-flag]"); + + $io->writeLine(""); + + $io->writeLine("Documentation: https://docs.espocrm.com/administration/commands/"); + + $io->writeLine(""); + } +} diff --git a/application/Espo/Core/Console/Commands/Migrate.php b/application/Espo/Core/Console/Commands/Migrate.php new file mode 100644 index 0000000..c0d53c5 --- /dev/null +++ b/application/Espo/Core/Console/Commands/Migrate.php @@ -0,0 +1,54 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Exceptions\Error; +use Espo\Core\Upgrades\Migration\Runner; + +/** + * @noinspection PhpUnused + */ +class Migrate implements Command +{ + public function __construct( + private Runner $runner + ) {} + + /** + * @throws Error + */ + public function run(Params $params, IO $io): void + { + $this->runner->run($io); + } +} diff --git a/application/Espo/Core/Console/Commands/MigrationVersionStep.php b/application/Espo/Core/Console/Commands/MigrationVersionStep.php new file mode 100644 index 0000000..b44e347 --- /dev/null +++ b/application/Espo/Core/Console/Commands/MigrationVersionStep.php @@ -0,0 +1,60 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Upgrades\Migration\AfterUpgradeRunner; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class MigrationVersionStep implements Command +{ + public function __construct( + private AfterUpgradeRunner $afterUpgradeRunner + ) {} + + public function run(Params $params, IO $io): void + { + $step = $params->getOption('step'); + + if (!$step) { + throw new RuntimeException("No step parameter."); + } + + $this->afterUpgradeRunner->run($step); + + $io->writeLine("Done."); + $io->setExitStatus(0); + } +} diff --git a/application/Espo/Core/Console/Commands/Rebuild.php b/application/Espo/Core/Console/Commands/Rebuild.php new file mode 100644 index 0000000..0c46380 --- /dev/null +++ b/application/Espo/Core/Console/Commands/Rebuild.php @@ -0,0 +1,74 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\DataManager; +use Espo\Core\Exceptions\Error; +use Espo\Core\Utils\Database\Schema\RebuildMode; + +/** + * @noinspection PhpUnused + */ +class Rebuild implements Command +{ + public function __construct(private DataManager $dataManager) + {} + + /** + * @throws Error + */ + public function run(Params $params, IO $io): void + { + $this->dataManager->rebuild(); + + if ($params->hasFlag('hard')) { + if (!$params->hasFlag('y')) { + $message = + "Are you sure you want to run a hard DB rebuild? It will drop unused columns, " . + "decrease exceeding column lengths. It may take some time to process.\nType [Y] to proceed."; + + $io->writeLine($message); + + $input = $io->readLine(); + + if (strtolower($input) !== 'y') { + return; + } + } + + $this->dataManager->rebuildDatabase(null, RebuildMode::HARD); + } + + $io->writeLine("Rebuild has been done."); + } +} diff --git a/application/Espo/Core/Console/Commands/RunJob.php b/application/Espo/Core/Console/Commands/RunJob.php new file mode 100644 index 0000000..65f9e27 --- /dev/null +++ b/application/Espo/Core/Console/Commands/RunJob.php @@ -0,0 +1,111 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Job\JobManager; +use Espo\Core\Job\Job\Status; +use Espo\Core\Utils\Util; +use Espo\ORM\EntityManager; +use Espo\Entities\Job; + +use Throwable; + +/** + * @noinspection PhpUnused + */ +class RunJob implements Command +{ + public function __construct(private JobManager $jobManager, private EntityManager $entityManager) + {} + + public function run(Params $params, IO $io): void + { + $options = $params->getOptions(); + $argumentList = $params->getArgumentList(); + + $jobName = $options['job'] ?? null; + $targetId = $options['targetId'] ?? null; + $targetType = $options['targetType'] ?? null; + + if (!$jobName && count($argumentList)) { + $jobName = $argumentList[0]; + } + + if (!$jobName) { + $io->writeLine(""); + $io->writeLine("A job name must be specified:"); + $io->writeLine(""); + + $io->writeLine(" bin/command run-job [JobName]"); + $io->writeLine(""); + + $io->writeLine("To print all available jobs, run:"); + $io->writeLine(""); + $io->writeLine(" bin/command app-info --jobs"); + $io->writeLine(""); + + return; + } + + $jobName = ucfirst(Util::hyphenToCamelCase($jobName)); + + $entityManager = $this->entityManager; + + /** @var Job $job */ + $job = $entityManager->createEntity(Job::ENTITY_TYPE, [ + 'name' => $jobName, + 'job' => $jobName, + 'targetType' => $targetType, + 'targetId' => $targetId, + 'attempts' => 0, + 'status' => Status::READY, + ]); + + try { + $this->jobManager->runJob($job); + } catch (Throwable $e) { + $message = "Error: Job '$jobName' failed to execute."; + + if ($e->getMessage()) { + $message .= ' ' . $e->getMessage(); + } + + $io->writeLine($message); + $io->setExitStatus(1); + + return; + } + + $io->writeLine("Job '$jobName' has been executed."); + } +} diff --git a/application/Espo/Core/Console/Commands/SetPassword.php b/application/Espo/Core/Console/Commands/SetPassword.php new file mode 100644 index 0000000..0760498 --- /dev/null +++ b/application/Espo/Core/Console/Commands/SetPassword.php @@ -0,0 +1,104 @@ +. + * + * 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\Console\Commands; + +use Espo\Entities\User; +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\PasswordHash; + +/** + * @noinspection PhpUnused + */ +class SetPassword implements Command +{ + public function __construct(private EntityManager $entityManager, private PasswordHash $passwordHash) + {} + + public function run(Params $params, IO $io): void + { + $userName = $params->getArgument(0); + + if (!$userName) { + $io->writeLine("Username must be specified as the first argument."); + $io->setExitStatus(1); + + return; + } + + $em = $this->entityManager; + + $user = $em->getRDBRepositoryByClass(User::class) + ->where(['userName' => $userName]) + ->findOne(); + + if (!$user) { + $io->writeLine("User '$userName' not found."); + $io->setExitStatus(1); + + return; + } + + $userType = $user->getType(); + + $allowedTypes = [ + User::TYPE_ADMIN, + User::TYPE_SUPER_ADMIN, + User::TYPE_PORTAL, + User::TYPE_REGULAR, + ]; + + if (!in_array($userType, $allowedTypes)) { + $io->writeLine("Can't set password for a user of the type '$userType'."); + $io->setExitStatus(1); + + return; + } + + $io->writeLine("Enter a new password:"); + + $password = $io->readSecretLine(); + + if (!$password) { + $io->writeLine("Password cannot be empty."); + $io->setExitStatus(1); + + return; + } + + $user->set(User::FIELD_PASSWORD, $this->passwordHash->hash($password)); + + $em->saveEntity($user); + + $io->writeLine("Password for user '$userName' has been changed."); + } +} diff --git a/application/Espo/Core/Console/Commands/UpdateAppTimestamp.php b/application/Espo/Core/Console/Commands/UpdateAppTimestamp.php new file mode 100644 index 0000000..b79d579 --- /dev/null +++ b/application/Espo/Core/Console/Commands/UpdateAppTimestamp.php @@ -0,0 +1,51 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\DataManager; + +/** + * @noinspection PhpUnused + */ +class UpdateAppTimestamp implements Command +{ + public function __construct(private DataManager $dataManager) + {} + + public function run(Params $params, IO $io): void + { + $this->dataManager->updateAppTimestamp(); + + $io->writeLine("App timestamp is updated."); + } +} diff --git a/application/Espo/Core/Console/Commands/Upgrade.php b/application/Espo/Core/Console/Commands/Upgrade.php new file mode 100644 index 0000000..317e466 --- /dev/null +++ b/application/Espo/Core/Console/Commands/Upgrade.php @@ -0,0 +1,556 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Application; +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Exceptions\Error; +use Espo\Core\Upgrades\UpgradeManager; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\Util; + +use Symfony\Component\Process\PhpExecutableFinder; + +use Exception; +use Throwable; +use stdClass; + +use UnexpectedValueException; +use const CURLOPT_FILE; +use const CURLOPT_RETURNTRANSFER; +use const CURLOPT_SSL_VERIFYPEER; +use const CURLOPT_TIMEOUT; +use const CURLOPT_URL; +use const FILTER_VALIDATE_URL; +use const STDOUT; + +/** + * @noinspection PhpUnused + */ +class Upgrade implements Command +{ + private ?UpgradeManager $upgradeManager = null; + + /** @var string[] */ + private $upgradeStepList = [ + 'copyBefore', + 'rebuild', + 'beforeUpgradeScript', + 'rebuild', + 'copy', + 'rebuild', + 'copyAfter', + 'rebuild', + 'afterUpgradeScript', + 'rebuild', + ]; + + /** @var array */ + private $upgradeStepLabels = [ + 'init' => 'Initialization', + 'copyBefore' => 'Copying before upgrade files', + 'rebuild' => 'Rebuilding', + 'beforeUpgradeScript' => 'Before upgrade script execution', + 'copy' => 'Copying files', + 'copyAfter' => 'Copying after upgrade files', + 'afterUpgradeScript' => 'After upgrade script execution', + 'finalize' => 'Finalization', + 'revert' => 'Reverting', + ]; + + public function __construct( + private FileManager $fileManager, + private Config $config, + private Log $log + ) {} + + /** + * @throws Error + */ + public function run(Params $params, IO $io): void + { + $options = $params->getOptions(); + $flagList = $params->getFlagList(); + $argumentList = $params->getArgumentList(); + + $upgradeParams = $this->normalizeParams($options, $flagList, $argumentList); + + $fromVersion = $this->config->get('version'); + $toVersion = $upgradeParams->toVersion ?? null; + + try { + $versionInfo = $this->getVersionInfo($toVersion); + } catch (UnexpectedValueException) { + fwrite(STDOUT, "Current '$fromVersion' version is unknown.\n"); + + return; + } + + $nextVersion = $versionInfo->nextVersion ?? null; + $lastVersion = $versionInfo->lastVersion ?? null; + + $packageFile = $this->getPackageFile($upgradeParams, $versionInfo); + + if (!$packageFile) { + return; + } + + if ($upgradeParams->localMode) { + $upgradeId = $this->upload($packageFile); + + $manifest = $this->getUpgradeManager()->getManifestById($upgradeId); + + $nextVersion = $manifest['version']; + } + + fwrite(STDOUT, "Current version is $fromVersion.\n"); + + if (!$upgradeParams->skipConfirmation) { + fwrite(STDOUT, "EspoCRM will be upgraded to version $nextVersion now. Enter [Y] to continue.\n"); + + if (!$this->confirm()) { + echo "Upgrade canceled.\n"; + + return; + } + } + + fwrite(STDOUT, "This may take a while. Do not close the terminal.\n"); + + if (filter_var($packageFile, FILTER_VALIDATE_URL)) { + fwrite(STDOUT, "Downloading..."); + + $packageFile = $this->downloadFile($packageFile); + + fwrite(STDOUT, "\n"); + + if (!$packageFile) { + fwrite(STDOUT, "Error: Unable to download upgrade package.\n"); + + $io->setExitStatus(1); + + return; + } + } + + $upgradeId = $upgradeId ?? $this->upload($packageFile); + + fwrite(STDOUT, "Upgrading..."); + + try { + $this->runUpgradeProcess($upgradeId, $upgradeParams); + } catch (Throwable $e) { + $this->displayStep('revert'); + $errorMessage = $e->getMessage(); + } + + fwrite(STDOUT, "\n"); + + if (!$upgradeParams->keepPackageFile) { + $this->fileManager->unlink($packageFile); + } + + if (isset($errorMessage)) { + $errorMessage = !empty($errorMessage) ? $errorMessage : "Error: An unexpected error occurred."; + + fwrite(STDOUT, $errorMessage . "\n"); + + $io->setExitStatus(1); + + return; + } + + $currentVersion = $this->getCurrentVersion(); + + fwrite(STDOUT, "Upgrade is complete. Current version is $currentVersion.\n"); + + if ($lastVersion && $lastVersion !== $currentVersion && $fromVersion !== $currentVersion) { + fwrite(STDOUT, "Newer version is available. Run command again to upgrade.\n"); + + return; + } + + if ($lastVersion && $lastVersion === $currentVersion) { + fwrite(STDOUT, "You have the latest version.\n"); + } + } + + /** + * Normalize params. Permitted options and flags and $arguments: + * -y - without confirmation + * -s - single process + * --file="EspoCRM-upgrade.zip" + * --step="beforeUpgradeScript" + * + * @param array $options + * @param string[] $flagList + * @param string[] $argumentList + * @return stdClass + * @noinspection PhpUnusedParameterInspection + */ + private function normalizeParams(array $options, array $flagList, array $argumentList): object + { + $params = (object) [ + 'localMode' => false, + 'skipConfirmation' => false, + 'singleProcess' => false, + 'keepPackageFile' => false, + ]; + + if (!empty($options['file'])) { + $params->localMode = true; + $params->file = $options['file']; + $params->keepPackageFile = true; + } + + if (in_array('y', $flagList)) { + $params->skipConfirmation = true; + } + + if (in_array('s', $flagList)) { + $params->singleProcess = true; + } + + if (in_array('patch', $flagList)) { + $currentVersion = $this->config->get('version'); + + if (preg_match('/^(.*)\.(.*)\..*$/', $currentVersion, $match)) { + $options['toVersion'] = $match[1] . '.' . $match[2]; + } + } + + if (!empty($options['step'])) { + $params->step = $options['step']; + } + + if (!empty($options['toVersion'])) { + $params->toVersion = $options['toVersion']; + } + + return $params; + } + + private function getPackageFile(stdClass $params, ?stdClass $versionInfo): ?string + { + $packageFile = $params->file ?? null; + + if (!$params->localMode) { + if (empty($versionInfo)) { + fwrite(STDOUT, "Error: Upgrade server is currently unavailable. Please try again later.\n"); + + return null; + } + + if (!isset($versionInfo->nextVersion)) { + fwrite(STDOUT, "There are no available upgrades.\n"); + + return null; + } + + if (!isset($versionInfo->nextPackage)) { + fwrite(STDOUT, "Error: Upgrade package is not found.\n"); + + return null; + } + + return $versionInfo->nextPackage; + } + + if (!$packageFile || !file_exists($packageFile)) { + fwrite(STDOUT, "Error: Upgrade package is not found.\n"); + + return null; + } + + return $packageFile; + } + + private function upload(string $filePath): string + { + try { + /** @var string $fileData */ + $fileData = file_get_contents($filePath); + $fileData = 'data:application/zip;base64,' . base64_encode($fileData); + + $upgradeId = $this->getUpgradeManager()->upload($fileData); + } catch (Exception $e) { + die("Error: " . $e->getMessage() . "\n"); + } + + return $upgradeId; + } + + /** + * @throws Error + */ + private function runUpgradeProcess(string $upgradeId, ?stdClass $params = null): void + { + $params = $params ?? (object) []; + + $useSingleProcess = property_exists($params, 'singleProcess') ? $params->singleProcess : false; + + $stepList = !empty($params->step) ? [$params->step] : $this->upgradeStepList; + + array_unshift($stepList, 'init'); + $stepList[] = 'finalize'; + + if (!$useSingleProcess && $this->isShellEnabled()) { + $this->runSteps($upgradeId, $stepList); + + return; + } + + $this->runStepsInSingleProcess($upgradeId, $stepList); + } + + /** + * @param string[] $stepList + * @throws Error + */ + private function runStepsInSingleProcess(string $upgradeId, array $stepList): void + { + $this->log->debug('Installation process ['.$upgradeId.']: Single process mode.'); + + try { + foreach ($stepList as $stepName) { + $this->displayStep($stepName); + + $upgradeManager = $this->getUpgradeManager(true); + + $upgradeManager->runInstallStep($stepName, ['id' => $upgradeId]); + } + } catch (Throwable $e) { + try { + $this->log->error('Upgrade Error: ' . $e->getMessage()); + } catch (Throwable) {} + + throw new Error($e->getMessage()); + } + } + + /** + * @param string[] $stepList + * @throws Error + */ + private function runSteps(string $upgradeId, array $stepList): void + { + $phpExecutablePath = $this->getPhpExecutablePath(); + + foreach ($stepList as $stepName) { + $this->displayStep($stepName); + + $command = $phpExecutablePath . " command.php upgrade-step --step=" . ucfirst($stepName) . + " --id=" . $upgradeId; + + /** @var string $shellResult */ + $shellResult = shell_exec($command); + + if ($shellResult !== 'true') { + try { + $this->log->error('Upgrade Error: ' . $shellResult); + } catch (Throwable) {} + + throw new Error($shellResult ?: 'Unknown error on shell_exec.'); + } + } + } + + private function displayStep(string $stepName): void + { + $stepLabel = $this->upgradeStepLabels[$stepName] ?? ""; + + fwrite(STDOUT, "\n $stepLabel..."); + } + + private function confirm(): bool + { + /** @var resource $fh */ + $fh = fopen('php://stdin', 'r'); + + $inputLine = trim(fgets($fh)); /** @phpstan-ignore-line */ + + fclose($fh); + + if (strtolower($inputLine) !== 'y'){ + return false; + } + + return true; + } + + private function getUpgradeManager(bool $reload = false): UpgradeManager + { + if (!$this->upgradeManager || $reload) { + $app = new Application(); + + $app->setupSystemUser(); + + $this->upgradeManager = new UpgradeManager($app->getContainer()); + } + + return $this->upgradeManager; + } + + private function getPhpExecutablePath(): string + { + $phpExecutablePath = $this->config->get('phpExecutablePath'); + + if (!$phpExecutablePath) { + $phpExecutablePath = (new PhpExecutableFinder)->find(); + } + + return $phpExecutablePath; + } + + private function getVersionInfo(?string $toVersion = null): ?stdClass + { + $url = 'https://s.espocrm.com/upgrade/next/'; + $url = $this->config->get('upgradeNextVersionUrl', $url); + $url .= '?fromVersion=' . $this->config->get('version'); + + if ($toVersion) { + $url .= '&toVersion=' . $toVersion; + } + + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_URL, $url); + + $result = curl_exec($ch); + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if ($httpCode === 422) { + throw new UnexpectedValueException(); + } + + if ($result === false) { + return null; + } + + try { + $data = json_decode($result); /** @phpstan-ignore-line */ + } catch (Exception) { /** @phpstan-ignore-line */ + echo "Could not parse info about next version.\n"; + + return null; + } + + if (!$data) { + echo "Could not get info about next version.\n"; + + return null; + } + + return $data; + } + + /** + * @param non-empty-string $url + */ + private function downloadFile(string $url): ?string + { + $localFilePath = 'data/upload/upgrades/' . Util::generateId() . '.zip'; + + $this->fileManager->putContents($localFilePath, ''); + + if (is_file($url)) { + copy($url, $localFilePath); + } else { + $fp = fopen($localFilePath, 'w'); + + if ($fp === false) { + echo "\nCould not open local file for writing.\n"; + + return null; + } + + $options = [ + CURLOPT_FILE => $fp, + CURLOPT_TIMEOUT => 3600, + CURLOPT_URL => $url, + ]; + + $ch = curl_init(); + + curl_setopt_array($ch, $options); + curl_exec($ch); + } + + if (!$this->fileManager->isFile($localFilePath)) { + echo "\nCould not download upgrade file.\n"; + + $this->fileManager->unlink($localFilePath); + + return null; + } + + $path = realpath($localFilePath); + + assert($path !== false); + + return $path; + } + + private function isShellEnabled(): bool + { + if ( + !function_exists('exec') || + !is_callable('shell_exec') /** @phpstan-ignore-line */ + ) { + return false; + } + + $result = shell_exec("echo test"); + + if (empty($result)) { + return false; + } + + return true; + } + + private function getCurrentVersion(): ?string + { + if (file_exists("data/state.php")) { + $configData = include "data/state.php"; /** @phpstan-ignore-line */ + } else { + return null; + } + + return $configData['version'] ?? null; + } +} diff --git a/application/Espo/Core/Console/Commands/UpgradeStep.php b/application/Espo/Core/Console/Commands/UpgradeStep.php new file mode 100644 index 0000000..6a564c9 --- /dev/null +++ b/application/Espo/Core/Console/Commands/UpgradeStep.php @@ -0,0 +1,88 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Application; +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Upgrades\UpgradeManager; + +use Exception; + +/** + * @noinspection PhpUnused + */ +class UpgradeStep implements Command +{ + public function __construct() {} + + public function run(Params $params, IO $io): void + { + $options = $params->getOptions(); + + if (empty($options['step'])) { + echo "Step is not specified.\n"; + + return; + } + + if (empty($options['id'])) { + echo "Upgrade ID is not specified.\n"; + + return; + } + + $stepName = $options['step']; + $upgradeId = $options['id']; + + $this->runUpgradeStep($stepName, ['id' => $upgradeId]); + + echo "true"; + } + + /** + * @param array $params + */ + private function runUpgradeStep(string $stepName, array $params): void + { + $app = new Application(); + + $app->setupSystemUser(); + + $upgradeManager = new UpgradeManager($app->getContainer()); + + try { + $upgradeManager->runInstallStep($stepName, $params); + } catch (Exception $e) { + die("Error: " . $e->getMessage()); + } + } +} diff --git a/application/Espo/Core/Console/Commands/Version.php b/application/Espo/Core/Console/Commands/Version.php new file mode 100644 index 0000000..40a5d12 --- /dev/null +++ b/application/Espo/Core/Console/Commands/Version.php @@ -0,0 +1,48 @@ +. + * + * 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\Console\Commands; + +use Espo\Core\Console\Command; +use Espo\Core\Console\Command\Params; +use Espo\Core\Console\IO; +use Espo\Core\Utils\Config; + +class Version implements Command +{ + public function __construct(private Config\SystemConfig $config) + {} + + public function run(Params $params, IO $io): void + { + $version = $this->config->getVersion(); + + $io->writeLine($version); + } +} diff --git a/application/Espo/Core/Console/Exceptions/ArgumentNotSpecified.php b/application/Espo/Core/Console/Exceptions/ArgumentNotSpecified.php new file mode 100644 index 0000000..2b22d91 --- /dev/null +++ b/application/Espo/Core/Console/Exceptions/ArgumentNotSpecified.php @@ -0,0 +1,36 @@ +. + * + * 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\Console\Exceptions; + +use RuntimeException; + +class ArgumentNotSpecified extends RuntimeException +{ +} diff --git a/application/Espo/Core/Console/Exceptions/CommandNotFound.php b/application/Espo/Core/Console/Exceptions/CommandNotFound.php new file mode 100644 index 0000000..2f6a197 --- /dev/null +++ b/application/Espo/Core/Console/Exceptions/CommandNotFound.php @@ -0,0 +1,37 @@ +. + * + * 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\Console\Exceptions; + +use RuntimeException; + +class CommandNotFound extends RuntimeException +{ + +} diff --git a/application/Espo/Core/Console/Exceptions/CommandNotSpecified.php b/application/Espo/Core/Console/Exceptions/CommandNotSpecified.php new file mode 100644 index 0000000..9cf5df4 --- /dev/null +++ b/application/Espo/Core/Console/Exceptions/CommandNotSpecified.php @@ -0,0 +1,36 @@ +. + * + * 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\Console\Exceptions; + +use RuntimeException; + +class CommandNotSpecified extends RuntimeException +{ +} diff --git a/application/Espo/Core/Console/Exceptions/InvalidArgument.php b/application/Espo/Core/Console/Exceptions/InvalidArgument.php new file mode 100644 index 0000000..f1571ca --- /dev/null +++ b/application/Espo/Core/Console/Exceptions/InvalidArgument.php @@ -0,0 +1,36 @@ +. + * + * 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\Console\Exceptions; + +use RuntimeException; + +class InvalidArgument extends RuntimeException +{ +} diff --git a/application/Espo/Core/Console/IO.php b/application/Espo/Core/Console/IO.php new file mode 100644 index 0000000..60207fb --- /dev/null +++ b/application/Espo/Core/Console/IO.php @@ -0,0 +1,157 @@ +. + * + * 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\Console; + +use RuntimeException; + +use const STDOUT; +use const STDERR; +use const PHP_EOL; + +/** + * Input/Output methods. + */ +class IO +{ + /** + * @var int<0, 255> + */ + private int $exitStatus = 0; + + /** + * Write a string to the output. + */ + public function write(string $string): void + { + fwrite(STDOUT, $string); + } + + /** + * Write a string followed by the current line terminator to the output. + */ + public function writeLine(string $string): void + { + fwrite(STDOUT, $string . PHP_EOL); + } + + /** + * Write a string to the error output. + * + * @since 10.0.0 + * @noinspection PhpUnused + */ + public function writeError(string $string): void + { + fwrite(STDERR, $string); + } + + /** + * Write a string followed by the current line terminator to the error output. + * + * @since 10.0.0 + */ + public function writeErrorLine(string $string): void + { + fwrite(STDERR, $string . PHP_EOL); + } + + /** + * Read a line from input. A string is trimmed. + */ + public function readLine(): string + { + return $this->readLineInternal(); + } + + /** + * Read a secret line from input. A string is trimmed. + */ + public function readSecretLine(): string + { + return $this->readLineInternal(true); + } + + private function readLineInternal(bool $secret = false): string + { + $resource = fopen('php://stdin', 'r'); + + if ($resource === false) { + throw new RuntimeException("Could not open stdin."); + } + + if ($secret && !self::isWindows()) { + shell_exec('stty -echo'); + } + + $readString = fgets($resource); + + if ($secret && !self::isWindows()) { + shell_exec('stty echo'); + } + + if ($readString === false) { + $readString = ''; + } + + $string = trim($readString); + + fclose($resource); + + return $string; + } + + private static function isWindows(): bool + { + return strcasecmp(substr(PHP_OS, 0, 3), 'WIN') === 0; + } + + /** + * Set exit-status. + * + * @param int<0, 255> $exitStatus + * - `0` - success; + * - `1` - error; + * - `127` - command not found; + */ + public function setExitStatus(int $exitStatus): void + { + $this->exitStatus = $exitStatus; + } + + /** + * Get exit-status. + * + * @return int<0, 255> + */ + public function getExitStatus(): int + { + return $this->exitStatus; + } +} diff --git a/application/Espo/Core/Container.php b/application/Espo/Core/Container.php new file mode 100644 index 0000000..96c7125 --- /dev/null +++ b/application/Espo/Core/Container.php @@ -0,0 +1,399 @@ +. + * + * 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; + +use Espo\Core\Binding\Binding; +use Espo\Core\Container\Exceptions\NotFoundException; +use Espo\Core\Container\Exceptions\NotSettableException; +use Espo\Core\Container\Loader; +use Espo\Core\Container\Container as ContainerInterface; +use Espo\Core\Container\Configuration; +use Espo\Core\Binding\BindingContainer; + +use Psr\Container\NotFoundExceptionInterface; + +use ReflectionClass; +use ReflectionNamedType; +use LogicException; +use RuntimeException; + +/** + * DI container for services. Lazy initialization is used. Services are instantiated only once. + * @see https://docs.espocrm.com/development/di/. + */ +class Container implements ContainerInterface +{ + private const ID_CONTAINER = 'container'; + private const ID_INJECTABLE_FACTORY = 'injectableFactory'; + + /** @var array */ + private array $data = []; + /** @var array> */ + private array $classCache = []; + /** @var array> */ + private array $loaderClassNames; + + private ?Configuration $configuration = null; + private InjectableFactory $injectableFactory; + + /** + * @param class-string $configurationClassName + * @param array> $loaderClassNames + * @param array $services + * @throws NotFoundExceptionInterface + */ + public function __construct( + string $configurationClassName, + private BindingContainer $bindingContainer, + array $loaderClassNames = [], + array $services = [] + ) { + $this->loaderClassNames = $loaderClassNames; + + foreach ($services as $name => $service) { + if (!is_string($name) || !is_object($service)) { + throw new RuntimeException("Container: Bad service passed."); + } + + $this->setForced($name, $service); + } + + /** @var InjectableFactory $injectableFactory */ + $injectableFactory = $this->get(self::ID_INJECTABLE_FACTORY); + $this->injectableFactory = $injectableFactory; + + $this->configuration = $this->injectableFactory->create($configurationClassName); + } + + /** + * @inheritDoc + */ + public function get(string $id): object + { + if (!$this->isSet($id)) { + $this->load($id); + + if (!$this->isSet($id)) { + throw new NotFoundException("Could not load '$id' service."); + } + } + + return $this->data[$id]; + } + + /** + * @inheritDoc + */ + public function has(string $id): bool + { + if ($this->isSet($id)) { + return true; + } + + if (array_key_exists($id, $this->loaderClassNames)) { + return true; + } + + $loadMethodName = 'load' . ucfirst($id); + + if (method_exists($this, $loadMethodName)) { + return true; + } + + if (!$this->configuration) { + return false; + } + + if ($this->configuration->getLoaderClassName($id)) { + return true; + } + + if ($this->configuration->getServiceClassName($id)) { + return true; + } + + return false; + } + + /** + * @inheritDoc + * @template T of object + * @param class-string $className A class name or interface name. + * @return T A service instance. + * @throws NotFoundExceptionInterface If not gettable. + */ + public function getByClass(string $className): object + { + $binding = $this->bindingContainer->getByInterface($className); + + if ($binding->getType() !== Binding::CONTAINER_SERVICE) { + throw new NotFoundException("No service bound to `$className`."); + } + + $id = $binding->getValue(); + + if (!is_string($id)) { + throw new LogicException(); + } + + /** @var T */ + return $this->get($id); + } + + private function isSet(string $id): bool + { + return isset($this->data[$id]); + } + + private function initClass(string $id): void + { + if ($this->isSet($id)) { + try { + $object = $this->get($id); + } catch (NotFoundExceptionInterface) { + throw new LogicException(); + } + + $this->classCache[$id] = new ReflectionClass($object); + + return; + } + + if ($id === self::ID_CONTAINER) { + /** @var ReflectionClass $object */ + $object = new ReflectionClass(Container::class); + + $this->classCache[$id] = $object; + + return; + } + + if ($id === self::ID_INJECTABLE_FACTORY) { + /** @var ReflectionClass $object */ + $object = new ReflectionClass(InjectableFactory::class); + + $this->classCache[$id] = $object; + + return; + } + + $loaderClassName = $this->getLoaderClassName($id); + + if ($loaderClassName) { + $this->initClassByLoader($id, $loaderClassName); + + return; + } + + assert($this->configuration !== null); + + $className = $this->configuration->getServiceClassName($id); + + if ($className === null) { + throw new RuntimeException("No class-name for service '$id'."); + } + + $this->classCache[$id] = new ReflectionClass($className); + } + + /** + * @param class-string $loaderClassName + * @throws RuntimeException + */ + private function initClassByLoader(string $id, string $loaderClassName): void + { + $loaderClass = new ReflectionClass($loaderClassName); + + $loadMethod = $loaderClass->getMethod('load'); + + if (!$loadMethod->hasReturnType()) { + throw new RuntimeException("Loader method for service '$id' does not have a return type."); + } + + $returnType = $loadMethod->getReturnType(); + + if (!$returnType instanceof ReflectionNamedType) { + throw new RuntimeException("Loader method for service '$id' does not have a named return type."); + } + + /** @var class-string $className */ + $className = $returnType->getName(); + + $this->classCache[$id] = new ReflectionClass($className); + } + + /** + * Get a class of a service. + * + * @return ReflectionClass + * @throws RuntimeException If not gettable. + */ + public function getClass(string $id): ReflectionClass + { + if (!$this->has($id)) { + throw new RuntimeException("Service '$id' does not exist."); + } + + if (!isset($this->classCache[$id])) { + $this->initClass($id); + } + + return $this->classCache[$id]; + } + + /** + * @inheritDoc + */ + public function set(string $id, object $object): void + { + assert($this->configuration !== null); + + if (!$this->configuration->isSettable($id)) { + throw new NotSettableException("Service '$id' is not settable."); + } + + if ($this->isSet($id)) { + throw new NotSettableException("Service '$id' is already set."); + } + + $this->setForced($id, $object); + } + + protected function setForced(string $id, object $object): void + { + $this->data[$id] = $object; + } + + private function getLoader(string $name): ?Loader + { + $loaderClassName = $this->getLoaderClassName($name); + + if (!$loaderClassName) { + return null; + } + + return $this->injectableFactory->create($loaderClassName); + } + + /** + * @return ?class-string + */ + private function getLoaderClassName(string $id): ?string + { + $loader = $this->loaderClassNames[$id] ?? null; + + if ($loader) { + return $loader; + } + + if ($this->configuration === null) { + throw new RuntimeException("Container configuration is not ready."); + } + + return $this->configuration->getLoaderClassName($id); + } + + /** + * @throws NotFoundExceptionInterface + */ + private function load(string $id): void + { + if ($id === self::ID_CONTAINER) { + $this->setForced(self::ID_CONTAINER, $this->loadContainer()); + + return; + } + + if ($id === self::ID_INJECTABLE_FACTORY) { + $this->setForced(self::ID_INJECTABLE_FACTORY, $this->loadInjectableFactory()); + + return; + } + + $loader = $this->getLoader($id); + + if ($loader) { + $this->data[$id] = $loader->load(); + + return; + } + + assert($this->configuration !== null); + + $className = $this->configuration->getServiceClassName($id); + + if (!$className || !class_exists($className)) { + throw new RuntimeException("Could not load '$id' service."); + } + + $dependencyList = $this->configuration->getServiceDependencyList($id); + + if (!is_null($dependencyList)) { + $dependencyObjectList = []; + + foreach ($dependencyList as $item) { + $dependencyObjectList[] = $this->get($item); + } + + $reflector = new ReflectionClass($className); + + $this->data[$id] = $reflector->newInstanceArgs($dependencyObjectList); + + return; + } + + $this->data[$id] = $this->injectableFactory->create($className); + } + + private function loadContainer(): Container + { + return $this; + } + + private function loadInjectableFactory(): InjectableFactory + { + return new InjectableFactory($this, $this->bindingContainer); + } + + /** + * @internal + * @param string[] $exceptions + */ + public function reset(array $exceptions): void + { + foreach (array_diff(array_keys($this->data), $exceptions) as $id) { + unset($this->data[$id]); + } + + foreach (array_diff(array_keys($this->classCache), $exceptions) as $id) { + unset($this->classCache[$id]); + } + } +} diff --git a/application/Espo/Core/Container/Configuration.php b/application/Espo/Core/Container/Configuration.php new file mode 100644 index 0000000..a1cfcb4 --- /dev/null +++ b/application/Espo/Core/Container/Configuration.php @@ -0,0 +1,50 @@ +. + * + * 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\Container; + +interface Configuration +{ + /** + * @return ?class-string + */ + public function getLoaderClassName(string $name): ?string; + + /** + * @return ?class-string + */ + public function getServiceClassName(string $name): ?string; + + /** + * @return ?string[] + */ + public function getServiceDependencyList(string $name): ?array; + + public function isSettable(string $name): bool; +} diff --git a/application/Espo/Core/Container/Container.php b/application/Espo/Core/Container/Container.php new file mode 100644 index 0000000..8ee76c4 --- /dev/null +++ b/application/Espo/Core/Container/Container.php @@ -0,0 +1,79 @@ +. + * + * 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\Container; + +use Espo\Core\Container\Exceptions\NotSettableException; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use ReflectionClass; + +/** + * DI container for services. Lazy initialization is used. Services are instantiated only once. + * @see https://docs.espocrm.com/development/di/. + */ +interface Container extends ContainerInterface +{ + /** + * Obtain a service object. + * + * @throws NotFoundExceptionInterface If not gettable. + */ + public function get(string $id): object; + + /** + * Check whether a service can be obtained. + */ + public function has(string $id): bool; + + /** + * Set a service object. Must be configured as settable. + * + * @throws NotSettableException Is not settable or already set. + */ + public function set(string $id, object $object): void; + + /** + * Get a class of a service. + * + * @return ReflectionClass + * @throws NotFoundExceptionInterface If not gettable. + */ + public function getClass(string $id): ReflectionClass; + + /** + * Get a service by a class name. A service should be bound to a class or interface. + * + * @template T of object + * @param class-string $className A class name or interface name. + * @return T A service instance. + * @throws NotFoundExceptionInterface If not gettable. + */ + public function getByClass(string $className): object; +} diff --git a/application/Espo/Core/Container/ContainerBuilder.php b/application/Espo/Core/Container/ContainerBuilder.php new file mode 100644 index 0000000..4be533e --- /dev/null +++ b/application/Espo/Core/Container/ContainerBuilder.php @@ -0,0 +1,220 @@ +. + * + * 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\Container; + +use Espo\Core\Application\ApplicationParams; +use Espo\Core\Container; +use Espo\Core\Container\Container as ContainerInterface; + +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingLoader; +use Espo\Core\Binding\EspoBindingLoader; + +use Espo\Core\Loaders\ApplicationState as ApplicationStateLoader; +use Espo\Core\Utils\Cache\FileCacheItemPool; +use Espo\Core\Utils\Cache\DataCacheServiceName; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Config\ConfigFileManager; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Module; + +use Espo\Core\Loaders\Log as LogLoader; +use Espo\Core\Loaders\DataManager as DataManagerLoader; +use Espo\Core\Loaders\Metadata as MetadataLoader; + +/** + * Builds a service container. + */ +class ContainerBuilder +{ + /** @var class-string */ + private string $containerClassName = Container::class; + /** @var class-string */ + private string $containerConfigurationClassName = ContainerConfiguration::class; + /** @var class-string */ + private string $configClassName = Config::class; + /** @var class-string */ + private string $fileManagerClassName = FileManager::class; + /** @var class-string */ + private string $moduleClassName = Module::class; + private ?BindingLoader $bindingLoader = null; + /** @var array */ + private $services = []; + /** @var array> */ + protected $loaderClassNames = [ + 'log' => LogLoader::class, + 'dataManager' => DataManagerLoader::class, + 'metadata' => MetadataLoader::class, + 'applicationState' => ApplicationStateLoader::class, + ]; + private ?ApplicationParams $params = null; + + public function withParams(?ApplicationParams $params): self + { + $this->params = $params; + + return $this; + } + + public function withBindingLoader(BindingLoader $bindingLoader): self + { + $this->bindingLoader = $bindingLoader; + + return $this; + } + + /** + * @param array $services + */ + public function withServices(array $services): self + { + foreach ($services as $key => $value) { + $this->services[$key] = $value; + } + + return $this; + } + + /** + * @param array> $classNames + * @noinspection PhpUnused + */ + public function withLoaderClassNames(array $classNames): self + { + foreach ($classNames as $key => $value) { + $this->loaderClassNames[$key] = $value; + } + + return $this; + } + + /** + * @param class-string $containerClassName + */ + public function withContainerClassName(string $containerClassName): self + { + $this->containerClassName = $containerClassName; + + return $this; + } + + /** + * @param class-string $containerConfigurationClassName + */ + public function withContainerConfigurationClassName(string $containerConfigurationClassName): self + { + $this->containerConfigurationClassName = $containerConfigurationClassName; + + return $this; + } + + /** + * @param class-string $configClassName + */ + public function withConfigClassName(string $configClassName): self + { + $this->configClassName = $configClassName; + + return $this; + } + + /** + * @param class-string $fileManagerClassName + * @noinspection PhpUnused + */ + public function withFileManagerClassName(string $fileManagerClassName): self + { + $this->fileManagerClassName = $fileManagerClassName; + + return $this; + } + + public function build(): ContainerInterface + { + $this->services['applicationParams'] = $this->params ?? new ApplicationParams(); + + /** @var Config $config */ + $config = $this->services['config'] ?? ( + new $this->configClassName( + new ConfigFileManager() + ) + ); + + /** @var FileManager $fileManager */ + $fileManager = $this->services['fileManager'] ?? ( + new $this->fileManagerClassName( + $config->get('defaultPermissions') + ) + ); + + $cacheItemPool = new FileCacheItemPool($fileManager); + + /** @var DataCache $dataCache */ + $dataCache = $this->services[DataCacheServiceName::SYSTEM] ?? ( + new DataCache($cacheItemPool) + ); + + $useCache = $config->get('useCache') ?? false; + + /** @var Module $module */ + $module = $this->services['module'] ?? ( + new $this->moduleClassName($fileManager, $dataCache, $useCache) + ); + + $systemConfig = new Config\SystemConfig($config); + + $this->services['config'] = $config; + $this->services['fileManager'] = $fileManager; + $this->services[DataCacheServiceName::SYSTEM] = $dataCache; + $this->services['module'] = $module; + $this->services['systemConfig'] = $systemConfig; + + if ($this->params?->services) { + $this->services = array_merge($this->services, $this->params->services); + } + + $bindingLoader = $this->bindingLoader ?? ( + new EspoBindingLoader( + module: $module, + binding: $this->params?->binding, + ) + ); + + $bindingContainer = new BindingContainer($bindingLoader->load()); + + return new $this->containerClassName( + $this->containerConfigurationClassName, + $bindingContainer, + $this->loaderClassNames, + $this->services + ); + } +} diff --git a/application/Espo/Core/Container/ContainerConfiguration.php b/application/Espo/Core/Container/ContainerConfiguration.php new file mode 100644 index 0000000..c843442 --- /dev/null +++ b/application/Espo/Core/Container/ContainerConfiguration.php @@ -0,0 +1,110 @@ +. + * + * 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\Container; + +use Espo\Core\Utils\Log; +use Espo\Core\Utils\Metadata; + +use ReflectionClass; +use Exception; + +class ContainerConfiguration implements Configuration +{ + /** + * Log must be loaded before anything. + * @noinspection PhpPropertyOnlyWrittenInspection + * @phpstan-ignore-next-line + */ + private Log $log; + + /** + * Must be protected. + */ + protected Metadata $metadata; + + public function __construct(Log $log, Metadata $metadata) + { + $this->log = $log; + $this->metadata = $metadata; + } + + /** + * @return ?class-string + */ + public function getLoaderClassName(string $name): ?string + { + $className = null; + + try { + $className = $this->metadata->get(['app', 'containerServices', $name, 'loaderClassName']); + } catch (Exception) {} + + if ($className && class_exists($className)) { + return $className; + } + + $className = 'Espo\Custom\Core\Loaders\\' . ucfirst($name); + + if (!class_exists($className)) { + $className = 'Espo\Core\Loaders\\' . ucfirst($name); + } + + if (class_exists($className)) { + $class = new ReflectionClass($className); + + if ($class->isInstantiable()) { + return $className; + } + } + + return null; + } + + /** + * @return ?class-string + */ + public function getServiceClassName(string $name): ?string + { + return $this->metadata->get(['app', 'containerServices', $name, 'className']) ?? null; + } + + /** + * @return ?string[] + */ + public function getServiceDependencyList(string $name): ?array + { + return $this->metadata->get(['app', 'containerServices', $name, 'dependencyList']) ?? null; + } + + public function isSettable(string $name): bool + { + return $this->metadata->get(['app', 'containerServices', $name, 'settable']) ?? false; + } +} diff --git a/application/Espo/Core/Container/Exceptions/NotFoundException.php b/application/Espo/Core/Container/Exceptions/NotFoundException.php new file mode 100644 index 0000000..7fa94e3 --- /dev/null +++ b/application/Espo/Core/Container/Exceptions/NotFoundException.php @@ -0,0 +1,39 @@ +. + * + * 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\Container\Exceptions; + +use Psr\Container\NotFoundExceptionInterface; +use RuntimeException; + +/** + * @internal + */ +class NotFoundException extends RuntimeException implements NotFoundExceptionInterface +{} diff --git a/application/Espo/Core/Container/Exceptions/NotSettableException.php b/application/Espo/Core/Container/Exceptions/NotSettableException.php new file mode 100644 index 0000000..dfb71cc --- /dev/null +++ b/application/Espo/Core/Container/Exceptions/NotSettableException.php @@ -0,0 +1,35 @@ +. + * + * 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\Container\Exceptions; + +use RuntimeException; + +class NotSettableException extends RuntimeException +{} diff --git a/application/Espo/Core/Container/Loader.php b/application/Espo/Core/Container/Loader.php new file mode 100644 index 0000000..0e9b089 --- /dev/null +++ b/application/Espo/Core/Container/Loader.php @@ -0,0 +1,38 @@ +. + * + * 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\Container; + +/** + * Loads a container service. + */ +interface Loader +{ + public function load(): object; +} diff --git a/application/Espo/Core/Controllers/Record.php b/application/Espo/Core/Controllers/Record.php new file mode 100644 index 0000000..dd4379e --- /dev/null +++ b/application/Espo/Core/Controllers/Record.php @@ -0,0 +1,242 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Api\Request; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Exceptions\NotFoundSilent; +use Espo\Core\Select\SearchParams; +use Espo\Core\Utils\Json; + +use stdClass; + +class Record extends RecordBase +{ + /** + * List related records. + * + * @throws BadRequest + * @throws NotFound + * @throws Forbidden + * @noinspection PhpUnused + */ + public function getActionListLinked(Request $request): stdClass + { + $id = $request->getRouteParam('id'); + $link = $request->getRouteParam('link'); + + if (!$id) { + throw new BadRequest("No ID."); + } + + if (!$link) { + throw new BadRequest("No link."); + } + + $searchParams = $this->fetchSearchParamsFromRequest($request); + + $result = $this->getRecordService()->findLinked($id, $link, $searchParams); + + return $result->toApiOutput(); + } + + /** + * Relate records. + * + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function postActionCreateLink(Request $request): bool + { + $id = $request->getRouteParam('id'); + $link = $request->getRouteParam('link'); + + $data = $request->getParsedBody(); + + if (!$id || !$link) { + throw new BadRequest(); + } + + if (!empty($data->massRelate)) { + $searchParams = $this->fetchMassLinkSearchParamsFromRequest($request); + + $result = $this->getRecordService()->massLink($id, $link, $searchParams); + + if ($result->getAffectedCount() === 0) { + return false; + } + + return true; + } + + $foreignIdList = []; + + if (isset($data->id)) { + $foreignIdList[] = $data->id; + } + + if (isset($data->ids) && is_array($data->ids)) { + foreach ($data->ids as $foreignId) { + $foreignIdList[] = $foreignId; + } + } + + $result = false; + + foreach ($foreignIdList as $foreignId) { + $this->getRecordService()->link($id, $link, $foreignId); + + $result = true; + } + + return $result; + } + + /** + * Un-relate records. + * + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function deleteActionRemoveLink(Request $request): bool + { + $id = $request->getRouteParam('id'); + $link = $request->getRouteParam('link'); + + $data = $request->getParsedBody(); + + if (!$id || !$link) { + throw new BadRequest(); + } + + $foreignIdList = []; + + if (isset($data->id)) { + $foreignIdList[] = $data->id; + } + + if (isset($data->ids) && is_array($data->ids)) { + foreach ($data->ids as $foreignId) { + $foreignIdList[] = $foreignId; + } + } + + $result = false; + + foreach ($foreignIdList as $foreignId) { + $this->getRecordService()->unlink($id, $link, $foreignId); + + $result = true; + } + + return $result; + } + + /** + * Follow a record. + * + * @throws BadRequest + * @throws NotFoundSilent + * @throws Forbidden + * @noinspection PhpUnused + */ + public function putActionFollow(Request $request): bool + { + $id = $request->getRouteParam('id'); + + if (!$id) { + throw new BadRequest("No ID."); + } + + $this->getRecordService()->follow($id); + + return true; + } + + /** + * Unfollow a record. + * + * @throws NotFoundSilent + * @throws BadRequest + * @noinspection PhpUnused + */ + public function deleteActionUnfollow(Request $request): bool + { + $id = $request->getRouteParam('id'); + + if (!$id) { + throw new BadRequest("No ID."); + } + + $this->getRecordService()->unfollow($id); + + return true; + } + + /** + * @throws BadRequest + */ + private function fetchMassLinkSearchParamsFromRequest(Request $request): SearchParams + { + $data = $request->getParsedBody(); + + $where = $data->where ?? null; + + if ($where !== null) { + $where = json_decode(Json::encode($where), true); + } + + $params = json_decode( + Json::encode( + $data->searchParams ?? $data->selectData ?? (object) [] + ), + true + ); + + if ($where !== null && !is_array($where)) { + throw new BadRequest("Bad 'where."); + } + + if ($where !== null) { + $params['where'] = array_merge( + $params['where'] ?? [], + $where + ); + } + + unset($params['select']); + + return SearchParams::fromRaw($params); + } +} diff --git a/application/Espo/Core/Controllers/RecordBase.php b/application/Espo/Core/Controllers/RecordBase.php new file mode 100644 index 0000000..f81404e --- /dev/null +++ b/application/Espo/Core/Controllers/RecordBase.php @@ -0,0 +1,318 @@ +. + * + * 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\Controllers; + +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\ForbiddenSilent; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Exceptions\NotFoundSilent; +use Espo\Core\InjectableFactory; +use Espo\Core\Record\ServiceContainer as RecordServiceContainer; +use Espo\Core\Record\SearchParamsFetcher; +use Espo\Core\Record\CreateParamsFetcher; +use Espo\Core\Record\ReadParamsFetcher; +use Espo\Core\Record\UpdateParamsFetcher; +use Espo\Core\Record\DeleteParamsFetcher; +use Espo\Core\Record\FindParamsFetcher; +use Espo\Core\Record\Service as RecordService; +use Espo\Core\Acl; +use Espo\Core\Utils\Config; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Select\SearchParams; +use Espo\Entities\User; +use Espo\ORM\Entity; +use stdClass; + +class RecordBase +{ + + /** + * @internal + */ + protected string $name; + + public static string $defaultAction = 'list'; + + /** + * @throws Forbidden + */ + public function __construct( + protected SearchParamsFetcher $searchParamsFetcher, + protected CreateParamsFetcher $createParamsFetcher, + protected ReadParamsFetcher $readParamsFetcher, + protected UpdateParamsFetcher $updateParamsFetcher, + protected DeleteParamsFetcher $deleteParamsFetcher, + protected RecordServiceContainer $recordServiceContainer, + protected FindParamsFetcher $findParamsFetcher, + protected Config $config, + protected User $user, + protected Acl $acl, + protected InjectableFactory $injectableFactory, + ) { + + if (empty($this->name)) { + $name = get_class($this); + + $matches = null; + + if (preg_match('@\\\\([\w]+)$@', $name, $matches)) { + $name = $matches[1]; + } + + $this->name = $name; + } + + if (!$this->checkAccess()) { + throw new Forbidden("No access to '$this->name'."); + } + } + + /** + * Check access to controller. + */ + protected function checkAccess(): bool + { + return true; + } + + protected function getEntityType(): string + { + return $this->name; + } + + /** + * @return RecordService + */ + protected function getRecordService(?string $entityType = null): RecordService + { + return $this->recordServiceContainer->get($entityType ?? $this->getEntityType()); + } + + /** + * Read a record. + * + * @throws NotFoundSilent + * @throws ForbiddenSilent + * @throws BadRequest + * @throws Forbidden + * @noinspection PhpUnusedParameterInspection + */ + public function getActionRead(Request $request, Response $response): stdClass + { + if (method_exists($this, 'actionRead')) { + // For backward compatibility. + return (object) $this->actionRead($request->getRouteParams(), $request->getParsedBody(), $request); + } + + $id = $request->getRouteParam('id'); + $params = $this->readParamsFetcher->fetch($request); + + if (!$id) { + throw new BadRequest("No ID."); + } + + $result = $this->getRecordService()->read($id, $params); + + return $result->getValueMap(); + } + + /** + * Create a record. + * + * @throws Forbidden + * @throws Conflict + * @throws BadRequest + */ + public function postActionCreate(Request $request, Response $response): stdClass + { + if (method_exists($this, 'actionCreate')) { + // For backward compatibility. + return (object) $this->actionCreate($request->getRouteParams(), $request->getParsedBody(), $request); + } + + $data = $request->getParsedBody(); + $params = $this->createParamsFetcher->fetch($request); + + $result = $this->getRecordService()->create($data, $params); + + return $result->getValueMap(); + } + + /** + * @throws BadRequest + * @throws NotFound + * @throws Forbidden + * @throws Conflict + * @noinspection PhpUnused + */ + public function patchActionUpdate(Request $request, Response $response): stdClass + { + return $this->putActionUpdate($request, $response); + } + + /** + * Update a record. + * + * @throws BadRequest + * @throws NotFound + * @throws Forbidden + * @throws Conflict + * @noinspection PhpUnusedParameterInspection + */ + public function putActionUpdate(Request $request, Response $response): stdClass + { + if (method_exists($this, 'actionUpdate')) { + // For backward compatibility. + return (object) $this->actionUpdate($request->getRouteParams(), $request->getParsedBody(), $request); + } + + $id = $request->getRouteParam('id'); + $data = $request->getParsedBody(); + + if (!$id) { + throw new BadRequest("No ID."); + } + + $params = $this->updateParamsFetcher->fetch($request); + + $result = $this->getRecordService()->update($id, $data, $params); + + if ($result->isLinkUpdated()) { + $response->setHeader('X-Record-Link-Updated', 'true'); + } + + return $result->getValueMap(); + } + + /** + * List records. + * + * @throws Forbidden + * @throws BadRequest + * @throws Error + */ + public function getActionList(Request $request, Response $response): stdClass + { + if (method_exists($this, 'actionList')) { + // For backward compatibility. + return (object) $this->actionList($request->getRouteParams(), $request->getParsedBody(), $request); + } + + $searchParams = $this->fetchSearchParamsFromRequest($request); + $findParams = $this->findParamsFetcher->fetch($request); + + $recordCollection = $this->getRecordService()->find($searchParams, $findParams); + + return $recordCollection->toApiOutput(); + } + + /** + * Delete a record. + * + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + * @throws Conflict + * @noinspection PhpUnusedParameterInspection + */ + public function deleteActionDelete(Request $request, Response $response): bool + { + if (method_exists($this, 'actionDelete')) { + // For backward compatibility. + return $this->actionDelete($request->getRouteParams(), $request->getParsedBody(), $request); + } + + $id = $request->getRouteParam('id'); + $params = $this->deleteParamsFetcher->fetch($request); + + if (!$id) { + throw new BadRequest("No ID."); + } + + $this->getRecordService()->delete($id, $params); + + return true; + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + protected function fetchSearchParamsFromRequest(Request $request): SearchParams + { + return $this->searchParamsFetcher->fetch($request); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + * @throws ForbiddenSilent + * @noinspection PhpUnused + */ + public function postActionGetDuplicateAttributes(Request $request): stdClass + { + $id = $request->getParsedBody()->id ?? null; + + if (!$id) { + throw new BadRequest(); + } + + return $this->getRecordService()->getDuplicateAttributes($id); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + * @throws Conflict + * @noinspection PhpUnused + */ + public function postActionRestoreDeleted(Request $request): bool + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $id = $request->getParsedBody()->id ?? null; + + if (!$id) { + throw new BadRequest(); + } + + $this->getRecordService()->restoreDeleted($id); + + return true; + } +} diff --git a/application/Espo/Core/Controllers/RecordTree.php b/application/Espo/Core/Controllers/RecordTree.php new file mode 100644 index 0000000..7c30370 --- /dev/null +++ b/application/Espo/Core/Controllers/RecordTree.php @@ -0,0 +1,196 @@ +. + * + * 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\Controllers; + +use Espo\Core\Acl\Table; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\ORM\Entity; +use Espo\Core\Templates\Entities\CategoryTree; +use Espo\Services\RecordTree as Service; +use Espo\Core\Api\Request; +use Espo\Tools\CategoryTree\Move\MoveParams; +use Espo\Tools\CategoryTree\MoveService; +use Espo\Tools\CategoryTree\Record\ReadTreeParams; +use RuntimeException; +use stdClass; + +class RecordTree extends Record +{ + /** + * Get a category tree. + * + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + * @noinspection PhpUnused + */ + public function getActionListTree(Request $request): stdClass + { + $selectParams = $this->fetchSearchParamsFromRequest($request); + + $parentId = $request->getQueryParam('parentId'); + $currentId = $request->getQueryParam('currentId'); + $maxDepth = $request->getQueryParam('maxDepth'); + $onlyNotEmpty = (bool) $request->getQueryParam('onlyNotEmpty'); + + if ($parentId && $currentId) { + throw new BadRequest("Cannot have both parentId and currentId set."); + } + + if ($maxDepth !== null) { + $maxDepth = (int) $maxDepth; + } + + $params = new ReadTreeParams( + where: $selectParams->getWhere(), + onlyNotEmpty: $onlyNotEmpty, + currentId: $currentId, + maxDepth: $maxDepth, + parentId: $parentId, + ); + + $service = $this->getRecordTreeService(); + + $collection = $service->getTree($params); + + if (!$collection) { + throw new RuntimeException(); + } + + $openPath = null; + + if ($params->currentId) { + $openPath = $service->getTreeItemPath($params->currentId); + } + + return (object) [ + 'list' => $collection->getValueMapList(), + 'path' => $service->getTreeItemPath($parentId), + 'data' => $service->getCategoryData($parentId), + 'openPath' => $openPath, + ]; + } + + /** + * @return string[] + * @throws Forbidden + * @throws BadRequest + * @noinspection PhpUnused + */ + public function getActionLastChildrenIdList(Request $request): array + { + if (!$this->acl->check($this->name, Table::ACTION_READ)) { + throw new Forbidden(); + } + + $parentId = $request->getQueryParam('parentId'); + + return $this->getRecordTreeService()->getLastChildrenIdList($parentId); + } + + /** + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + * @throws Error + * @noinspection PhpUnused + */ + public function postActionMove(Request $request): bool + { + if (!$this->acl->check($this->name, Table::ACTION_EDIT)) { + throw new Forbidden(); + } + + $id = $request->getParsedBody()->id ?? null; + $referenceId = $request->getParsedBody()->referenceId ?? null; + $type = $request->getParsedBody()->type ?? null; + + if (!is_string($id)) { + throw new BadRequest("Bad id."); + } + + if (!is_string($referenceId)) { + throw new BadRequest("Bad referenceId."); + } + + $typeInternal = match ($type) { + 'into' => MoveParams::TYPE_INTO, + 'before' => MoveParams::TYPE_BEFORE, + 'after' => MoveParams::TYPE_AFTER, + default => null, + }; + + if ($typeInternal === null) { + throw new BadRequest("Bad type."); + } + + $entity = $this->getRecordService()->getEntity($id); + + if (!$entity) { + throw new NotFound("Record not found."); + } + + if (!$entity instanceof CategoryTree) { + throw new RuntimeException("Non-tree entity."); + } + + if (!$this->acl->checkEntityEdit($entity)) { + throw new Forbidden("No edit access."); + } + + $params = new MoveParams( + type: $typeInternal, + referenceId: $referenceId, + ); + + $service = $this->injectableFactory->create(MoveService::class); + + $service->move($entity, $params); + + return true; + } + + /** + * @return Service + */ + protected function getRecordTreeService(): Service + { + $service = $this->getRecordService(); + + if (!$service instanceof Service) { + throw new RuntimeException(); + } + + return $service; + } +} diff --git a/application/Espo/Core/Currency/CalculatorUtil.php b/application/Espo/Core/Currency/CalculatorUtil.php new file mode 100644 index 0000000..68be741 --- /dev/null +++ b/application/Espo/Core/Currency/CalculatorUtil.php @@ -0,0 +1,148 @@ +. + * + * 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\Currency; + +use DivisionByZeroError; + +class CalculatorUtil +{ + private const int SCALE = 14; + + /** + * @param numeric-string $arg1 + * @param numeric-string $arg2 + * @return numeric-string + */ + public static function add(string $arg1, string $arg2): string + { + if (!function_exists('bcadd')) { + return self::floatToString((float) $arg1 + (float) $arg2); + } + + return bcadd($arg1, $arg2, self::SCALE); + } + + /** + * @param numeric-string $arg1 + * @param numeric-string $arg2 + * @return numeric-string + */ + public static function subtract(string $arg1, string $arg2): string + { + if (!function_exists('bcsub')) { + return self::floatToString((float) $arg1 - (float) $arg2); + } + + return bcsub($arg1, $arg2, self::SCALE); + } + + /** + * @param numeric-string $arg1 + * @param numeric-string $arg2 + * @return numeric-string + * + * @todo For the result, trim right zeros. Then, trim dot. + */ + public static function multiply(string $arg1, string $arg2): string + { + if (!function_exists('bcmul')) { + return self::floatToString((float) $arg1 * (float) $arg2); + } + + return bcmul($arg1, $arg2, self::SCALE); + } + + /** + * @param numeric-string $arg1 + * @param numeric-string $arg2 + * @return numeric-string + * + * @todo For the result, trim right zeros. Then, trim dot. + */ + public static function divide(string $arg1, string $arg2): string + { + if (!function_exists('bcdiv')) { + return self::floatToString((float) $arg1 / (float) $arg2); + } + + $result = bcdiv($arg1, $arg2, self::SCALE); + + if ($result === null) { /** @phpstan-ignore-line */ + throw new DivisionByZeroError(); + } + + return $result; + } + + /** + * @param numeric-string $arg + * @return numeric-string + */ + public static function round(string $arg, int $precision = 0): string + { + if (!function_exists('bcadd')) { + return self::floatToString( + round((float) $arg, $precision) + ); + } + + $addition = '0.' . str_repeat('0', $precision) . '5'; + + if ($arg[0] === '-') { + $addition = '-' . $addition; + } + + assert(is_numeric($addition)); + + return bcadd($arg, $addition, $precision); + } + + /** + * @param numeric-string $arg1 + * @param numeric-string $arg2 + */ + public static function compare(string $arg1, string $arg2): int + { + if (!function_exists('bccomp')) { + return (float) $arg1 <=> (float) $arg2; + } + + return bccomp($arg1, $arg2, self::SCALE); + } + + /** + * @return numeric-string + */ + private static function floatToString(float $amount): string + { + /** @var numeric-string */ + return rtrim(rtrim(sprintf('%.' . self::SCALE . 'f', $amount), '0'), '.'); + } +} diff --git a/application/Espo/Core/Currency/ConfigDataProvider.php b/application/Espo/Core/Currency/ConfigDataProvider.php new file mode 100644 index 0000000..b0eb87a --- /dev/null +++ b/application/Espo/Core/Currency/ConfigDataProvider.php @@ -0,0 +1,111 @@ +. + * + * 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\Currency; + +use Espo\Core\Utils\Config; +use RuntimeException; + +class ConfigDataProvider +{ + public function __construct( + private Config $config, + private InternalRatesProvider $internalRatesProvider, + ) {} + + /** + * Get decimal places. + * + * @since 8.3.0 + */ + public function getDecimalPlaces(): ?int + { + return $this->config->get('currencyDecimalPlaces'); + } + + /** + * Get a system default currency. + */ + public function getDefaultCurrency(): string + { + return $this->config->get('defaultCurrency'); + } + + /** + * Get a base currency, used for conversion. + */ + public function getBaseCurrency(): string + { + return $this->config->get('baseCurrency'); + } + + /** + * Get a list of available currencies. + * + * @return string[] + */ + public function getCurrencyList(): array + { + return $this->config->get('currencyList') ?? []; + } + + /** + * Whether a currency is available in the system. + */ + public function hasCurrency(string $currencyCode): bool + { + return in_array($currencyCode, $this->getCurrencyList()); + } + + /** + * Get a rate of a specific currency related to the base currency. + */ + public function getCurrencyRate(string $currencyCode): float + { + $rates = $this->internalRatesProvider->get($this->getBaseCurrency()); + + if (!$this->hasCurrency($currencyCode)) { + throw new RuntimeException("Can't get currency rate of '$currencyCode' currency."); + } + + return $rates[$currencyCode] ?? 1.0; + } + + /** + * Get rates. + */ + public function getCurrencyRates(): Rates + { + $rates = $this->internalRatesProvider->get($this->getBaseCurrency()); + + $rates[$this->getBaseCurrency()] = 1.0; + + return Rates::fromAssoc($rates, $this->getBaseCurrency()); + } +} diff --git a/application/Espo/Core/Currency/Converter.php b/application/Espo/Core/Currency/Converter.php new file mode 100644 index 0000000..3114b41 --- /dev/null +++ b/application/Espo/Core/Currency/Converter.php @@ -0,0 +1,114 @@ +. + * + * 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\Currency; + +use Espo\Core\Field\Currency; + +use RuntimeException; + +/** + * Converts currency values. + */ +class Converter +{ + private ConfigDataProvider $configDataProvider; + + public function __construct(ConfigDataProvider $configDataProvider) + { + $this->configDataProvider = $configDataProvider; + } + + /** + * Convert a currency value to a specific currency. + * + * @throws RuntimeException + */ + public function convert(Currency $value, string $targetCurrencyCode): Currency + { + if (!$this->configDataProvider->hasCurrency($targetCurrencyCode)) { + throw new RuntimeException("Can't convert currency to unknown currency '{$targetCurrencyCode}."); + } + + $rate = $this->configDataProvider->getCurrencyRate($value->getCode()); + $targetRate = $this->configDataProvider->getCurrencyRate($targetCurrencyCode); + + $convertedAmount = $this->convertAmount($value->getAmountAsString(), $rate, $targetRate); + + return new Currency($convertedAmount, $targetCurrencyCode); + } + + /** + * Convert a currency value to a specific currency with specific rates. + * Base currency should have rate equal to `1.0`. + * + * @throws RuntimeException + */ + public function convertWithRates(Currency $value, string $targetCurrencyCode, Rates $rates): Currency + { + $currencyCode = $value->getCode(); + + if (!$rates->hasRate($currencyCode)) { + throw new RuntimeException("No rate for the currency '{$currencyCode}."); + } + + if (!$rates->hasRate($targetCurrencyCode)) { + throw new RuntimeException("No rate for the currency '{$targetCurrencyCode}."); + } + + $rate = $rates->getRate($currencyCode); + $targetRate = $rates->getRate($targetCurrencyCode); + + $convertedAmount = $this->convertAmount($value->getAmountAsString(), $rate, $targetRate); + + return new Currency($convertedAmount, $targetCurrencyCode); + } + + /** + * Convert a currency value to the system default currency. + */ + public function convertToDefault(Currency $value): Currency + { + $targetCurrencyCode = $this->configDataProvider->getDefaultCurrency(); + + return $this->convert($value, $targetCurrencyCode); + } + + /** + * @param numeric-string $amount + * @return numeric-string + */ + private function convertAmount(string $amount, float $rate, float $targetRate): string + { + return CalculatorUtil::divide( + CalculatorUtil::multiply($amount, (string) $rate), + (string) $targetRate + ); + } +} diff --git a/application/Espo/Core/Currency/InternalRateEntryProvider.php b/application/Espo/Core/Currency/InternalRateEntryProvider.php new file mode 100644 index 0000000..5ed75ef --- /dev/null +++ b/application/Espo/Core/Currency/InternalRateEntryProvider.php @@ -0,0 +1,91 @@ +. + * + * 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\Currency; + +use Espo\Core\Field\Date; +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Entities\CurrencyRecord; +use Espo\Entities\CurrencyRecordRate; +use Espo\ORM\Query\Part\Order; +use Traversable; + +/** + * @internal + */ +class InternalRateEntryProvider +{ + public function __construct( + private EntityManagerProxy $entityManager, + ) {} + + /** + * @return CurrencyRecordRate[] + */ + public function getRateEntries(Date $date, string $base): array + { + $rates = []; + + foreach ($this->getActiveCurrencyRecords() as $record) { + $rate = $this->getRateEntryForRecord($record, $date, $base); + + if ($rate) { + $rates[] = $rate; + } + } + + return $rates; + } + + /** + * @return Traversable + */ + private function getActiveCurrencyRecords(): Traversable + { + return $this->entityManager + ->getRDBRepositoryByClass(CurrencyRecord::class) + ->where([ + CurrencyRecord::FIELD_STATUS => CurrencyRecord::STATUS_ACTIVE, + ]) + ->find(); + } + + public function getRateEntryForRecord(CurrencyRecord $record, Date $date, string $base): ?CurrencyRecordRate + { + return $this->entityManager + ->getRDBRepositoryByClass(CurrencyRecordRate::class) + ->where([ + CurrencyRecordRate::ATTR_RECORD_ID => $record->getId(), + CurrencyRecordRate::FIELD_BASE_CODE => $base, + CurrencyRecordRate::FIELD_DATE . '<=' => $date->toString(), + ]) + ->order(CurrencyRecordRate::FIELD_DATE, Order::DESC) + ->findOne(); + } +} diff --git a/application/Espo/Core/Currency/InternalRatesProvider.php b/application/Espo/Core/Currency/InternalRatesProvider.php new file mode 100644 index 0000000..65a812a --- /dev/null +++ b/application/Espo/Core/Currency/InternalRatesProvider.php @@ -0,0 +1,120 @@ +. + * + * 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\Currency; + +use Espo\Core\Field\Date; +use Espo\Core\Utils\Cache\DataCacheAccess; +use Espo\Core\Utils\DateTime; +use Espo\Core\Utils\Event\EventDispatcher; +use Espo\Tools\Currency\Events\CurrencyRateUpdate; +use LogicException; +use RuntimeException; +use stdClass; + +/** + * @internal + */ +class InternalRatesProvider +{ + private string $cacheKey = 'currencyRates'; + + private ?Date $today = null; + private ?string $base = null; + + /** + * @param DataCacheAccess $dataCacheAccess + */ + public function __construct( + private DateTime $dateTime, + private InternalRateEntryProvider $rateEntryProvider, + private DataCacheAccess $dataCacheAccess, + private EventDispatcher $eventDispatcher, + ) { + $this->dataCacheAccess->init( + key: $this->cacheKey, + loader: function () { + if (!$this->today || $this->base === null) { + throw new LogicException(); + } + + return $this->buildData($this->today, $this->base); + }, + validityChecker: function (stdClass $data): bool { + if (!$this->today) { + throw new LogicException(); + } + + $date = $data->date ?? null; + + return $date === $this->today->toString(); + }, + ); + + $this->eventDispatcher->subscribe(CurrencyRateUpdate::class, function () { + $this->dataCacheAccess->reset(); + }); + } + + /** + * @return array + */ + public function get(string $base): array + { + $today = $this->dateTime->getToday(); + + $this->today = $today; + $this->base = $base; + + $data = $this->dataCacheAccess->get(); + + if (!property_exists($data, 'rates')) { + throw new RuntimeException("Corrupted cache data in '$this->cacheKey'."); + } + + return get_object_vars($data->rates); + } + + /** + * @return stdClass&object{date: string, rates: stdClass} + */ + private function buildData(Date $today, string $base): stdClass + { + $rates = []; + + foreach ($this->rateEntryProvider->getRateEntries($today, $base) as $rate) { + $rates[$rate->getRecord()->getCode()] = (float) $rate->getRate(); + } + + return (object) [ + 'date' => $today->toString(), + 'rates' => (object) $rates, + ]; + } +} diff --git a/application/Espo/Core/Currency/PrecisionProvider.php b/application/Espo/Core/Currency/PrecisionProvider.php new file mode 100644 index 0000000..f9d1301 --- /dev/null +++ b/application/Espo/Core/Currency/PrecisionProvider.php @@ -0,0 +1,53 @@ +. + * + * 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\Currency; + +use Espo\Core\Utils\Metadata; + +/** + * @since 9.3.0 + */ +class PrecisionProvider +{ + private const int DEFAULT_PRECISION = 2; + + public function __construct( + private Metadata $metadata, + ) {} + + public function get(?string $code): int + { + if (!$code) { + return self::DEFAULT_PRECISION; + } + + return $this->metadata->get("app.currency.precisionMap.$code") ?? self::DEFAULT_PRECISION; + } +} diff --git a/application/Espo/Core/Currency/Rates.php b/application/Espo/Core/Currency/Rates.php new file mode 100644 index 0000000..8e2b891 --- /dev/null +++ b/application/Espo/Core/Currency/Rates.php @@ -0,0 +1,134 @@ +. + * + * 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\Currency; + +use RuntimeException; + +/** + * Currency rates. + */ +class Rates +{ + /** @var array */ + private array $data = []; + + private function __construct(private ?string $baseCode = null) + {} + + /** + * Create an instance. + * + * @param ?string $baseCode A base-currency code. + */ + public static function create(?string $baseCode = null): self + { + return new self($baseCode); + } + + /** + * Get a base-currency code. + * + * @throws RuntimeException If the base code is not set. + */ + public function getBase(): string + { + if ($this->baseCode === null) { + throw new RuntimeException("Base code is not set."); + } + + return $this->baseCode; + } + + /** + * Clone with a rate value for a specific currency. + */ + public function withRate(string $code, float $value): self + { + $obj = clone $this; + $obj->data[$code] = $value; + + return $obj; + } + + /** + * Whether a rate is set for a specific currency. + */ + public function hasRate(string $code): bool + { + return array_key_exists($code, $this->data); + } + + /** + * Get a rate value for a specific currency. + */ + public function getRate(string $code): float + { + if (!$this->hasRate($code)) { + throw new RuntimeException("No currency rate for '{$code}'."); + } + + return $this->data[$code]; + } + + /** + * To an associative array. + * + * @return array + */ + public function toAssoc(): array + { + return array_merge( + $this->data, + [$this->getBase() => 1.0] + ); + } + + /** + * Create from an associative array. + * + * @param array $data + */ + public static function fromAssoc(array $data, ?string $baseCode = null): self + { + $obj = new self($baseCode); + $obj->data = $data; + + return $obj; + } + + /** + * @deprecated Use `fromAssoc`. + * @param array $data + */ + public function fromArray(array $data, ?string $baseCode = null): self + { + return self::fromAssoc($data, $baseCode); + } +} diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php new file mode 100644 index 0000000..a409586 --- /dev/null +++ b/application/Espo/Core/DataManager.php @@ -0,0 +1,293 @@ +. + * + * 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; + +use Espo\Core\Binding\Attributes\Qualify; +use Espo\Core\Exceptions\Error; +use Espo\Core\Hook\Control; +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Core\Utils\Cache\DataCacheProvider; +use Espo\Core\Utils\Cache\Exceptions\PersistenceError; +use Espo\Core\Utils\Database\Helper as DatabaseHelper; +use Espo\Core\Utils\Database\Schema\RebuildMode; +use Espo\Core\Utils\Database\Schema\SchemaManagerProxy; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Util; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\ConfigWriter; +use Espo\Core\Utils\Metadata\OrmMetadataData; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\Module; +use Espo\Core\Rebuild\RebuildActionProcessor; +use Espo\Core\ORM\DatabaseParamsFactory; +use Espo\Core\Utils\Config\MissingDefaultParamsSaver as ConfigMissingDefaultParamsSaver; + +use Throwable; + +/** + * Clears cache, rebuilds the application. + */ +class DataManager +{ + private string $cachePath = 'data/cache'; + + public function __construct( + private EntityManagerProxy $entityManager, + private Config $config, + private ConfigWriter $configWriter, + private Metadata $metadata, + private OrmMetadataData $ormMetadataData, + private SchemaManagerProxy $schemaManager, + private Log $log, + private Module $module, + private RebuildActionProcessor $rebuildActionProcessor, + private ConfigMissingDefaultParamsSaver $configMissingDefaultParamsSaver, + private FileManager $fileManager, + private DatabaseParamsFactory $databaseParamsFactory, + private InjectableFactory $injectableFactory, + private Control $hookControl, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] + private DataCache $systemDataCache, + private DataCacheProvider $dataCacheProvider, + ) {} + + /** + * Rebuild the system with metadata, database and cache clearing. + * + * @param ?string[] $entityTypeList + * @throws Error + */ + public function rebuild(?array $entityTypeList = null): void + { + $this->clearCache(); + $this->disableHooks(); + $this->checkModules(); + $this->rebuildMetadata(); + $this->populateConfigParameters(); + $this->rebuildDatabase($entityTypeList); + $this->rebuildActionProcessor->process(); + $this->configMissingDefaultParamsSaver->process(); + $this->enableHooks(); + } + + /** + * Clear cache. + * + * @throws Error + * @todo Trigger an inter-process event. + */ + public function clearCache(): void + { + $this->module->clearCache(); + + $result = $this->fileManager->removeInDir($this->cachePath); + + try { + $this->systemDataCache->clearAll(); + } catch (PersistenceError $e) { + throw new Error("Could not clear system cache.", previous: $e); + } + + try { + $dataCache = $this->dataCacheProvider->get(); + } catch (Throwable $e) { + $this->log->error("Could not get application data cache.", ['exception' => $e]); + + $dataCache = null; + } + + if ($dataCache && $this->systemDataCache !== $dataCache) { + try { + $dataCache->clearAll(); + } catch (PersistenceError $e) { + throw new Error("Could not clear application cache.", previous: $e); + } + } + + if (!$result) { + throw new Error("Error while clearing cache."); + } + + $this->updateCacheTimestamp(); + } + + /** + * Rebuild database. + * + * @param ?string[] $entityTypeList + * @param RebuildMode::* $mode + * @throws Error + */ + public function rebuildDatabase(?array $entityTypeList = null, string $mode = RebuildMode::SOFT): void + { + if ($entityTypeList && $this->config->get('database.platform') === 'Postgresql') { + // Prevents sequences from being dropped. + // @todo Refactor. + $entityTypeList = null; + } + + $schemaManager = $this->schemaManager; + + try { + $result = $schemaManager->rebuild($entityTypeList, $mode); + } catch (Throwable $e) { + $result = false; + + $this->log->error( + "Failed to rebuild database schema. {$e->getMessage()}; {$e->getFile()}:{$e->getLine()}", + ['exception' => $e] + ); + } + + if (!$result) { + throw new Error("Error while rebuilding database. See log file for details."); + } + + $databaseType = strtolower($schemaManager->getDatabaseHelper()->getType()); + + if ( + !$this->config->get('actualDatabaseType') || + $this->config->get('actualDatabaseType') !== $databaseType + ) { + $this->configWriter->set('actualDatabaseType', $databaseType); + } + + $databaseVersion = $schemaManager->getDatabaseHelper()->getVersion(); + + if ( + !$this->config->get('actualDatabaseVersion') || + $this->config->get('actualDatabaseVersion') !== $databaseVersion + ) { + $this->configWriter->set('actualDatabaseVersion', $databaseVersion); + } + + $this->configWriter->updateCacheTimestamp(); + $this->configWriter->save(); + } + + /** + * Rebuild metadata. + */ + public function rebuildMetadata(): void + { + $this->metadata->init(); + $this->ormMetadataData->reload(); + $this->entityManager->getMetadata()->updateData(); + + $this->updateCacheTimestamp(); + } + + /** + * Update cache timestamp. + */ + public function updateCacheTimestamp(): void + { + $this->configWriter->updateCacheTimestamp(); + $this->configWriter->save(); + } + + /** + * Update app timestamp. + */ + public function updateAppTimestamp(): void + { + $this->configWriter->set('appTimestamp', time()); + $this->configWriter->save(); + } + + private function populateConfigParameters(): void + { + $this->setFullTextConfigParameters(); + $this->setCryptKeyConfigParameter(); + + if (!$this->config->get('appTimestamp')) { + $this->updateAppTimestamp(); + } + + $this->configWriter->save(); + } + + private function setFullTextConfigParameters(): void + { + $databaseParams = $this->databaseParamsFactory->create(); + + if ($databaseParams->getPlatform() !== 'Mysql') { + return; + } + + $helper = $this->injectableFactory->create(DatabaseHelper::class); + + $fullTextSearchMinLength = $helper->getParam('ft_min_word_len'); + + if ($fullTextSearchMinLength !== null) { + $fullTextSearchMinLength = (int) $fullTextSearchMinLength; + } + + $this->configWriter->set('fullTextSearchMinLength', $fullTextSearchMinLength); + } + + private function setCryptKeyConfigParameter(): void + { + if ($this->config->get('cryptKey')) { + return; + } + + $cryptKey = Util::generateSecretKey(); + + $this->configWriter->set('cryptKey', $cryptKey); + } + + private function disableHooks(): void + { + $this->hookControl->disable(); + } + + private function enableHooks(): void + { + $this->hookControl->enable(); + } + + /** + * @throws Error + */ + private function checkModules(): void + { + $moduleNameList = $this->module->getList(); + + if (count(array_unique($moduleNameList)) !== count($moduleNameList)) { + throw new Error( + "There is a same module in both `custom` and `internal` directories. " . + "Should be only in one location." + ); + } + } +} diff --git a/application/Espo/Core/Di/AclAware.php b/application/Espo/Core/Di/AclAware.php new file mode 100644 index 0000000..9d502f6 --- /dev/null +++ b/application/Espo/Core/Di/AclAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Acl; + +interface AclAware +{ + public function setAcl(Acl $acl): void; +} diff --git a/application/Espo/Core/Di/AclManagerAware.php b/application/Espo/Core/Di/AclManagerAware.php new file mode 100644 index 0000000..f1fd7fa --- /dev/null +++ b/application/Espo/Core/Di/AclManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\AclManager; + +interface AclManagerAware +{ + public function setAclManager(AclManager $aclManager): void; +} diff --git a/application/Espo/Core/Di/AclManagerSetter.php b/application/Espo/Core/Di/AclManagerSetter.php new file mode 100644 index 0000000..6d22efe --- /dev/null +++ b/application/Espo/Core/Di/AclManagerSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\AclManager; + +trait AclManagerSetter +{ + /** + * @var AclManager + */ + protected $aclManager; + + public function setAclManager(AclManager $aclManager): void + { + $this->aclManager = $aclManager; + } +} diff --git a/application/Espo/Core/Di/AclSetter.php b/application/Espo/Core/Di/AclSetter.php new file mode 100644 index 0000000..36ca14f --- /dev/null +++ b/application/Espo/Core/Di/AclSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Acl; + +trait AclSetter +{ + /** + * @var Acl + */ + protected $acl; + + public function setAcl(Acl $acl): void + { + $this->acl = $acl; + } +} diff --git a/application/Espo/Core/Di/ApplicationStateAware.php b/application/Espo/Core/Di/ApplicationStateAware.php new file mode 100644 index 0000000..5034f2e --- /dev/null +++ b/application/Espo/Core/Di/ApplicationStateAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\ApplicationState; + +interface ApplicationStateAware +{ + public function setApplicationState(ApplicationState $applicationState): void; +} diff --git a/application/Espo/Core/Di/ApplicationStateSetter.php b/application/Espo/Core/Di/ApplicationStateSetter.php new file mode 100644 index 0000000..d45140b --- /dev/null +++ b/application/Espo/Core/Di/ApplicationStateSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\ApplicationState; + +trait ApplicationStateSetter +{ + /** + * @var ApplicationState + */ + protected $applicationState; + + public function setApplicationState(ApplicationState $applicationState): void + { + $this->applicationState = $applicationState; + } +} diff --git a/application/Espo/Core/Di/AssignmentCheckerManagerAware.php b/application/Espo/Core/Di/AssignmentCheckerManagerAware.php new file mode 100644 index 0000000..8d9074e --- /dev/null +++ b/application/Espo/Core/Di/AssignmentCheckerManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Acl\AssignmentChecker\AssignmentCheckerManager; + +interface AssignmentCheckerManagerAware +{ + public function setAssignmentCheckerManager(AssignmentCheckerManager $assignmentCheckerManager): void; +} diff --git a/application/Espo/Core/Di/AssignmentCheckerManagerSetter.php b/application/Espo/Core/Di/AssignmentCheckerManagerSetter.php new file mode 100644 index 0000000..c57f8b4 --- /dev/null +++ b/application/Espo/Core/Di/AssignmentCheckerManagerSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Acl\AssignmentChecker\AssignmentCheckerManager; + +trait AssignmentCheckerManagerSetter +{ + /** + * @var AssignmentCheckerManager + */ + protected $assignmentCheckerManager; + + public function setAssignmentCheckerManager(AssignmentCheckerManager $assignmentCheckerManager): void + { + $this->assignmentCheckerManager = $assignmentCheckerManager; + } +} diff --git a/application/Espo/Core/Di/ConfigAware.php b/application/Espo/Core/Di/ConfigAware.php new file mode 100644 index 0000000..40a4092 --- /dev/null +++ b/application/Espo/Core/Di/ConfigAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Config; + +interface ConfigAware +{ + public function setConfig(Config $config): void; +} diff --git a/application/Espo/Core/Di/ConfigSetter.php b/application/Espo/Core/Di/ConfigSetter.php new file mode 100644 index 0000000..c008e58 --- /dev/null +++ b/application/Espo/Core/Di/ConfigSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Config; + +trait ConfigSetter +{ + /** + * @var Config + */ + protected $config; + + public function setConfig(Config $config): void + { + $this->config = $config; + } +} diff --git a/application/Espo/Core/Di/CryptAware.php b/application/Espo/Core/Di/CryptAware.php new file mode 100644 index 0000000..f8ad336 --- /dev/null +++ b/application/Espo/Core/Di/CryptAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Crypt; + +interface CryptAware +{ + public function setCrypt(Crypt $crypt): void; +} diff --git a/application/Espo/Core/Di/CryptSetter.php b/application/Espo/Core/Di/CryptSetter.php new file mode 100644 index 0000000..f07cdca --- /dev/null +++ b/application/Espo/Core/Di/CryptSetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Crypt; + +/** + * @phpstan-ignore-next-line + */ +trait CryptSetter +{ + /** + * @var Crypt + */ + protected $crypt; + + public function setCrypt(Crypt $crypt): void + { + $this->crypt = $crypt; + } +} diff --git a/application/Espo/Core/Di/DataCacheAware.php b/application/Espo/Core/Di/DataCacheAware.php new file mode 100644 index 0000000..e1854a3 --- /dev/null +++ b/application/Espo/Core/Di/DataCacheAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\DataCache; + +interface DataCacheAware +{ + public function setDataCache(DataCache $dataCache): void; +} diff --git a/application/Espo/Core/Di/DataCacheSetter.php b/application/Espo/Core/Di/DataCacheSetter.php new file mode 100644 index 0000000..857b025 --- /dev/null +++ b/application/Espo/Core/Di/DataCacheSetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\DataCache; + +/** + * @phpstan-ignore-next-line trait.unused + */ +trait DataCacheSetter +{ + /** + * @var DataCache + */ + protected $dataCache; + + public function setDataCache(DataCache $dataCache): void + { + $this->dataCache = $dataCache; + } +} diff --git a/application/Espo/Core/Di/DataManagerAware.php b/application/Espo/Core/Di/DataManagerAware.php new file mode 100644 index 0000000..65a5d35 --- /dev/null +++ b/application/Espo/Core/Di/DataManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\DataManager; + +interface DataManagerAware +{ + public function setDataManager(DataManager $dataManager): void; +} diff --git a/application/Espo/Core/Di/DataManagerSetter.php b/application/Espo/Core/Di/DataManagerSetter.php new file mode 100644 index 0000000..06f18ca --- /dev/null +++ b/application/Espo/Core/Di/DataManagerSetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Core\DataManager; + +/** + * @phpstan-ignore-next-line + */ +trait DataManagerSetter +{ + /** + * @var DataManager + */ + protected $dataManager; + + public function setDataManager(DataManager $dataManager): void + { + $this->dataManager = $dataManager; + } +} diff --git a/application/Espo/Core/Di/DateTimeAware.php b/application/Espo/Core/Di/DateTimeAware.php new file mode 100644 index 0000000..33a0910 --- /dev/null +++ b/application/Espo/Core/Di/DateTimeAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\DateTime; + +interface DateTimeAware +{ + public function setDateTime(DateTime $dateTime): void; +} diff --git a/application/Espo/Core/Di/DateTimeSetter.php b/application/Espo/Core/Di/DateTimeSetter.php new file mode 100644 index 0000000..eefd339 --- /dev/null +++ b/application/Espo/Core/Di/DateTimeSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\DateTime; + +trait DateTimeSetter +{ + /** + * @var DateTime + */ + protected $dateTime; + + public function setDateTime(DateTime $dateTime): void + { + $this->dateTime = $dateTime; + } +} diff --git a/application/Espo/Core/Di/DefaultLanguageAware.php b/application/Espo/Core/Di/DefaultLanguageAware.php new file mode 100644 index 0000000..99e3116 --- /dev/null +++ b/application/Espo/Core/Di/DefaultLanguageAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Language; + +interface DefaultLanguageAware +{ + public function setDefaultLanguage(Language $defaultLanguage): void; +} diff --git a/application/Espo/Core/Di/DefaultLanguageSetter.php b/application/Espo/Core/Di/DefaultLanguageSetter.php new file mode 100644 index 0000000..cec83ed --- /dev/null +++ b/application/Espo/Core/Di/DefaultLanguageSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Language; + +trait DefaultLanguageSetter +{ + /** + * @var Language + */ + protected $defaultLanguage; + + public function setDefaultLanguage(Language $defaultLanguage): void + { + $this->defaultLanguage = $defaultLanguage; + } +} diff --git a/application/Espo/Core/Di/EmailFilterManagerAware.php b/application/Espo/Core/Di/EmailFilterManagerAware.php new file mode 100644 index 0000000..281d71f --- /dev/null +++ b/application/Espo/Core/Di/EmailFilterManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\EmailFilterManager; + +interface EmailFilterManagerAware +{ + public function setEmailFilterManager(EmailFilterManager $emailFilterManager): void; +} diff --git a/application/Espo/Core/Di/EmailFilterManagerSetter.php b/application/Espo/Core/Di/EmailFilterManagerSetter.php new file mode 100644 index 0000000..cd9b39d --- /dev/null +++ b/application/Espo/Core/Di/EmailFilterManagerSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\EmailFilterManager; + +trait EmailFilterManagerSetter +{ + /** + * @var EmailFilterManager + */ + protected $emailFilterManager; + + public function setEmailFilterManager(EmailFilterManager $emailFilterManager): void + { + $this->emailFilterManager = $emailFilterManager; + } +} diff --git a/application/Espo/Core/Di/EmailSenderAware.php b/application/Espo/Core/Di/EmailSenderAware.php new file mode 100644 index 0000000..1bcfd8c --- /dev/null +++ b/application/Espo/Core/Di/EmailSenderAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Mail\EmailSender as EmailSender; + +interface EmailSenderAware +{ + public function setEmailSender(EmailSender $emailSender): void; +} diff --git a/application/Espo/Core/Di/EmailSenderSetter.php b/application/Espo/Core/Di/EmailSenderSetter.php new file mode 100644 index 0000000..63a6cbf --- /dev/null +++ b/application/Espo/Core/Di/EmailSenderSetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Core\Mail\EmailSender as EmailSender; + +/** + * @phpstan-ignore-next-line + */ +trait EmailSenderSetter +{ + /** + * @var EmailSender + */ + protected $emailSender; + + public function setEmailSender(EmailSender $emailSender): void + { + $this->emailSender = $emailSender; + } +} diff --git a/application/Espo/Core/Di/EntityManagerAware.php b/application/Espo/Core/Di/EntityManagerAware.php new file mode 100644 index 0000000..f1c09fa --- /dev/null +++ b/application/Espo/Core/Di/EntityManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\ORM\EntityManager; + +interface EntityManagerAware +{ + public function setEntityManager(EntityManager $entityManager): void; +} diff --git a/application/Espo/Core/Di/EntityManagerSetter.php b/application/Espo/Core/Di/EntityManagerSetter.php new file mode 100644 index 0000000..8ba4ce1 --- /dev/null +++ b/application/Espo/Core/Di/EntityManagerSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\ORM\EntityManager; + +trait EntityManagerSetter +{ + /** + * @var EntityManager + */ + protected $entityManager; + + public function setEntityManager(EntityManager $entityManager): void + { + $this->entityManager = $entityManager; + } +} diff --git a/application/Espo/Core/Di/FieldUtilAware.php b/application/Espo/Core/Di/FieldUtilAware.php new file mode 100644 index 0000000..18736b4 --- /dev/null +++ b/application/Espo/Core/Di/FieldUtilAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\FieldUtil; + +interface FieldUtilAware +{ + public function setFieldUtil(FieldUtil $fieldUtil): void; +} diff --git a/application/Espo/Core/Di/FieldUtilSetter.php b/application/Espo/Core/Di/FieldUtilSetter.php new file mode 100644 index 0000000..ff1b8ae --- /dev/null +++ b/application/Espo/Core/Di/FieldUtilSetter.php @@ -0,0 +1,54 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\FieldUtil; + +trait FieldUtilSetter +{ + /** + * @var FieldUtil + */ + protected $fieldUtil; + + /** + * @var FieldUtil + */ + protected $fieldManagerUtil; + + public function setFieldUtil(FieldUtil $fieldUtil): void + { + $this->fieldUtil = $fieldUtil; + + // for backward compatibility + // @todo Remove. + $this->fieldManagerUtil = $fieldUtil; + } +} diff --git a/application/Espo/Core/Di/FieldValidationManagerAware.php b/application/Espo/Core/Di/FieldValidationManagerAware.php new file mode 100644 index 0000000..0215181 --- /dev/null +++ b/application/Espo/Core/Di/FieldValidationManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\FieldValidation\FieldValidationManager; + +interface FieldValidationManagerAware +{ + public function setFieldValidationManager(FieldValidationManager $fieldValidationManager): void; +} diff --git a/application/Espo/Core/Di/FieldValidationManagerSetter.php b/application/Espo/Core/Di/FieldValidationManagerSetter.php new file mode 100644 index 0000000..a4a43af --- /dev/null +++ b/application/Espo/Core/Di/FieldValidationManagerSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\FieldValidation\FieldValidationManager; + +trait FieldValidationManagerSetter +{ + /** + * @var FieldValidationManager + */ + protected $fieldValidationManager; + + public function setFieldValidationManager(FieldValidationManager $fieldValidationManager): void + { + $this->fieldValidationManager = $fieldValidationManager; + } +} diff --git a/application/Espo/Core/Di/FileManagerAware.php b/application/Espo/Core/Di/FileManagerAware.php new file mode 100644 index 0000000..5168120 --- /dev/null +++ b/application/Espo/Core/Di/FileManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\File\Manager as FileManager; + +interface FileManagerAware +{ + public function setFileManager(FileManager $fileManager): void; +} diff --git a/application/Espo/Core/Di/FileManagerSetter.php b/application/Espo/Core/Di/FileManagerSetter.php new file mode 100644 index 0000000..77c27d6 --- /dev/null +++ b/application/Espo/Core/Di/FileManagerSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\File\Manager as FileManager; + +trait FileManagerSetter +{ + /** + * @var FileManager + */ + protected $fileManager; + + public function setFileManager(FileManager $fileManager): void + { + $this->fileManager = $fileManager; + } +} diff --git a/application/Espo/Core/Di/FileStorageManagerAware.php b/application/Espo/Core/Di/FileStorageManagerAware.php new file mode 100644 index 0000000..be0bd82 --- /dev/null +++ b/application/Espo/Core/Di/FileStorageManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\FileStorage\Manager as FileStorageManager; + +interface FileStorageManagerAware +{ + public function setFileStorageManager(FileStorageManager $fileStorageManager): void; +} diff --git a/application/Espo/Core/Di/FileStorageManagerSetter.php b/application/Espo/Core/Di/FileStorageManagerSetter.php new file mode 100644 index 0000000..825ad31 --- /dev/null +++ b/application/Espo/Core/Di/FileStorageManagerSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\FileStorage\Manager as FileStorageManager; + +trait FileStorageManagerSetter +{ + /** + * @var FileStorageManager + */ + protected $fileStorageManager; + + public function setFileStorageManager(FileStorageManager $fileStorageManager): void + { + $this->fileStorageManager = $fileStorageManager; + } +} diff --git a/application/Espo/Core/Di/HookManagerAware.php b/application/Espo/Core/Di/HookManagerAware.php new file mode 100644 index 0000000..7fc68c3 --- /dev/null +++ b/application/Espo/Core/Di/HookManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\HookManager; + +interface HookManagerAware +{ + public function setHookManager(HookManager $hookManager): void; +} diff --git a/application/Espo/Core/Di/HookManagerSetter.php b/application/Espo/Core/Di/HookManagerSetter.php new file mode 100644 index 0000000..7691514 --- /dev/null +++ b/application/Espo/Core/Di/HookManagerSetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Core\HookManager; + +/** + * @phpstan-ignore-next-line method.unused + */ +trait HookManagerSetter +{ + /** + * @var HookManager + */ + protected $hookManager; + + public function setHookManager(HookManager $hookManager): void + { + $this->hookManager = $hookManager; + } +} diff --git a/application/Espo/Core/Di/HtmlizerFactoryAware.php b/application/Espo/Core/Di/HtmlizerFactoryAware.php new file mode 100644 index 0000000..dcef32e --- /dev/null +++ b/application/Espo/Core/Di/HtmlizerFactoryAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Htmlizer\HtmlizerFactory as HtmlizerFactory; + +interface HtmlizerFactoryAware +{ + public function setHtmlizerFactory(HtmlizerFactory $htmlizerFactory): void; +} diff --git a/application/Espo/Core/Di/HtmlizerFactorySetter.php b/application/Espo/Core/Di/HtmlizerFactorySetter.php new file mode 100644 index 0000000..3a5f0e3 --- /dev/null +++ b/application/Espo/Core/Di/HtmlizerFactorySetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Core\Htmlizer\HtmlizerFactory as HtmlizerFactory; + +/** + * @phpstan-ignore-next-line + */ +trait HtmlizerFactorySetter +{ + /** + * @var HtmlizerFactory + */ + protected $htmlizerFactory; + + public function setHtmlizerFactory(HtmlizerFactory $htmlizerFactory): void + { + $this->htmlizerFactory = $htmlizerFactory; + } +} diff --git a/application/Espo/Core/Di/InjectableFactoryAware.php b/application/Espo/Core/Di/InjectableFactoryAware.php new file mode 100644 index 0000000..b3b1dd0 --- /dev/null +++ b/application/Espo/Core/Di/InjectableFactoryAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\InjectableFactory; + +interface InjectableFactoryAware +{ + public function setInjectableFactory(InjectableFactory $injectableFactory): void; +} diff --git a/application/Espo/Core/Di/InjectableFactorySetter.php b/application/Espo/Core/Di/InjectableFactorySetter.php new file mode 100644 index 0000000..f1fb568 --- /dev/null +++ b/application/Espo/Core/Di/InjectableFactorySetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\InjectableFactory; + +trait InjectableFactorySetter +{ + /** + * @var InjectableFactory + */ + protected $injectableFactory; + + public function setInjectableFactory(InjectableFactory $injectableFactory): void + { + $this->injectableFactory = $injectableFactory; + } +} diff --git a/application/Espo/Core/Di/LanguageAware.php b/application/Espo/Core/Di/LanguageAware.php new file mode 100644 index 0000000..9f62620 --- /dev/null +++ b/application/Espo/Core/Di/LanguageAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Language; + +interface LanguageAware +{ + public function setLanguage(Language $language): void; +} diff --git a/application/Espo/Core/Di/LanguageSetter.php b/application/Espo/Core/Di/LanguageSetter.php new file mode 100644 index 0000000..e2d32ae --- /dev/null +++ b/application/Espo/Core/Di/LanguageSetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Language; + +/** + * @phpstan-ignore-next-line + */ +trait LanguageSetter +{ + /** + * @var Language + */ + protected $language; + + public function setLanguage(Language $language): void + { + $this->language = $language; + } +} diff --git a/application/Espo/Core/Di/LogAware.php b/application/Espo/Core/Di/LogAware.php new file mode 100644 index 0000000..7c42d42 --- /dev/null +++ b/application/Espo/Core/Di/LogAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Log; + +interface LogAware +{ + public function setLog(Log $log): void; +} diff --git a/application/Espo/Core/Di/LogSetter.php b/application/Espo/Core/Di/LogSetter.php new file mode 100644 index 0000000..5d5fb87 --- /dev/null +++ b/application/Espo/Core/Di/LogSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Log; + +trait LogSetter +{ + /** + * @var Log + */ + protected $log; + + public function setLog(Log $log): void + { + $this->log = $log; + } +} diff --git a/application/Espo/Core/Di/MetadataAware.php b/application/Espo/Core/Di/MetadataAware.php new file mode 100644 index 0000000..501eadd --- /dev/null +++ b/application/Espo/Core/Di/MetadataAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Metadata; + +interface MetadataAware +{ + public function setMetadata(Metadata $metadata): void; +} diff --git a/application/Espo/Core/Di/MetadataSetter.php b/application/Espo/Core/Di/MetadataSetter.php new file mode 100644 index 0000000..05de47a --- /dev/null +++ b/application/Espo/Core/Di/MetadataSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\Metadata; + +trait MetadataSetter +{ + /** + * @var Metadata + */ + protected $metadata; + + public function setMetadata(Metadata $metadata): void + { + $this->metadata = $metadata; + } +} diff --git a/application/Espo/Core/Di/NumberAware.php b/application/Espo/Core/Di/NumberAware.php new file mode 100644 index 0000000..80de0d7 --- /dev/null +++ b/application/Espo/Core/Di/NumberAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\NumberUtil; + +interface NumberAware +{ + public function setNumber(NumberUtil $number): void; +} diff --git a/application/Espo/Core/Di/NumberSetter.php b/application/Espo/Core/Di/NumberSetter.php new file mode 100644 index 0000000..6939d00 --- /dev/null +++ b/application/Espo/Core/Di/NumberSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\NumberUtil; + +trait NumberSetter +{ + /** + * @var NumberUtil + */ + protected $number; + + public function setNumber(NumberUtil $number): void + { + $this->number = $number; + } +} diff --git a/application/Espo/Core/Di/PreferencesAware.php b/application/Espo/Core/Di/PreferencesAware.php new file mode 100644 index 0000000..dfaa515 --- /dev/null +++ b/application/Espo/Core/Di/PreferencesAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Entities\Preferences; + +interface PreferencesAware +{ + public function setPreferences(Preferences $preferences): void; +} diff --git a/application/Espo/Core/Di/PreferencesSetter.php b/application/Espo/Core/Di/PreferencesSetter.php new file mode 100644 index 0000000..bbd7463 --- /dev/null +++ b/application/Espo/Core/Di/PreferencesSetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Entities\Preferences; + +/** + * @phpstan-ignore-next-line + */ +trait PreferencesSetter +{ + /** + * @var Preferences + */ + protected $preferences; + + public function setPreferences(Preferences $preferences): void + { + $this->preferences = $preferences; + } +} diff --git a/application/Espo/Core/Di/RecordServiceContainerAware.php b/application/Espo/Core/Di/RecordServiceContainerAware.php new file mode 100644 index 0000000..535b9ad --- /dev/null +++ b/application/Espo/Core/Di/RecordServiceContainerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Record\ServiceContainer as RecordServiceContainer; + +interface RecordServiceContainerAware +{ + public function setRecordServiceContainer(RecordServiceContainer $recordServiceContainer): void; +} diff --git a/application/Espo/Core/Di/RecordServiceContainerSetter.php b/application/Espo/Core/Di/RecordServiceContainerSetter.php new file mode 100644 index 0000000..4cd2f40 --- /dev/null +++ b/application/Espo/Core/Di/RecordServiceContainerSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\Record\ServiceContainer as RecordServiceContainer; + +trait RecordServiceContainerSetter +{ + /** + * @var RecordServiceContainer + */ + protected $recordServiceContainer; + + public function setRecordServiceContainer(RecordServiceContainer $recordServiceContainer): void + { + $this->recordServiceContainer = $recordServiceContainer; + } +} diff --git a/application/Espo/Core/Di/ServiceFactoryAware.php b/application/Espo/Core/Di/ServiceFactoryAware.php new file mode 100644 index 0000000..f31f284 --- /dev/null +++ b/application/Espo/Core/Di/ServiceFactoryAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\ServiceFactory; + +interface ServiceFactoryAware +{ + public function setServiceFactory(ServiceFactory $serviceFactory): void; +} diff --git a/application/Espo/Core/Di/ServiceFactorySetter.php b/application/Espo/Core/Di/ServiceFactorySetter.php new file mode 100644 index 0000000..ef78b03 --- /dev/null +++ b/application/Espo/Core/Di/ServiceFactorySetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Core\ServiceFactory; + +trait ServiceFactorySetter +{ + /** + * @var ServiceFactory + */ + protected $serviceFactory; + + public function setServiceFactory(ServiceFactory $serviceFactory): void + { + $this->serviceFactory = $serviceFactory; + } +} diff --git a/application/Espo/Core/Di/TemplateFileManagerAware.php b/application/Espo/Core/Di/TemplateFileManagerAware.php new file mode 100644 index 0000000..2607573 --- /dev/null +++ b/application/Espo/Core/Di/TemplateFileManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\TemplateFileManager; + +interface TemplateFileManagerAware +{ + public function setTemplateFileManager(TemplateFileManager $templateFileManager): void; +} diff --git a/application/Espo/Core/Di/TemplateFileManagerSetter.php b/application/Espo/Core/Di/TemplateFileManagerSetter.php new file mode 100644 index 0000000..ad6a966 --- /dev/null +++ b/application/Espo/Core/Di/TemplateFileManagerSetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Core\Utils\TemplateFileManager; + +/** + * @phpstan-ignore-next-line + */ +trait TemplateFileManagerSetter +{ + /** + * @var TemplateFileManager + */ + protected $templateFileManager; + + public function setTemplateFileManager(TemplateFileManager $templateFileManager): void + { + $this->templateFileManager = $templateFileManager; + } +} diff --git a/application/Espo/Core/Di/UserAware.php b/application/Espo/Core/Di/UserAware.php new file mode 100644 index 0000000..1afa5e1 --- /dev/null +++ b/application/Espo/Core/Di/UserAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Entities\User; + +interface UserAware +{ + public function setUser(User $user): void; +} diff --git a/application/Espo/Core/Di/UserSetter.php b/application/Espo/Core/Di/UserSetter.php new file mode 100644 index 0000000..97f7925 --- /dev/null +++ b/application/Espo/Core/Di/UserSetter.php @@ -0,0 +1,45 @@ +. + * + * 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\Di; + +use Espo\Entities\User; + +trait UserSetter +{ + /** + * @var User + */ + protected $user; + + public function setUser(User $user): void + { + $this->user = $user; + } +} diff --git a/application/Espo/Core/Di/WebSocketSubmissionAware.php b/application/Espo/Core/Di/WebSocketSubmissionAware.php new file mode 100644 index 0000000..00ee5bc --- /dev/null +++ b/application/Espo/Core/Di/WebSocketSubmissionAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\WebSocket\Submission; + +interface WebSocketSubmissionAware +{ + public function setWebSocketSubmission(Submission $webSocketSubmission): void; +} diff --git a/application/Espo/Core/Di/WebSocketSubmissionSetter.php b/application/Espo/Core/Di/WebSocketSubmissionSetter.php new file mode 100644 index 0000000..0d9f188 --- /dev/null +++ b/application/Espo/Core/Di/WebSocketSubmissionSetter.php @@ -0,0 +1,48 @@ +. + * + * 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\Di; + +use Espo\Core\WebSocket\Submission; + +/** + * @phpstan-ignore-next-line + */ +trait WebSocketSubmissionSetter +{ + /** + * @var Submission + */ + protected $webSocketSubmission; + + public function setWebSocketSubmission(Submission $webSocketSubmission): void + { + $this->webSocketSubmission = $webSocketSubmission; + } +} diff --git a/application/Espo/Core/Di/WebhookManagerAware.php b/application/Espo/Core/Di/WebhookManagerAware.php new file mode 100644 index 0000000..77e8d08 --- /dev/null +++ b/application/Espo/Core/Di/WebhookManagerAware.php @@ -0,0 +1,37 @@ +. + * + * 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\Di; + +use Espo\Core\Webhook\Manager; + +interface WebhookManagerAware +{ + public function setWebhookManager(Manager $webhookManager): void; +} diff --git a/application/Espo/Core/Di/WebhookManagerSetter.php b/application/Espo/Core/Di/WebhookManagerSetter.php new file mode 100644 index 0000000..d9d3773 --- /dev/null +++ b/application/Espo/Core/Di/WebhookManagerSetter.php @@ -0,0 +1,46 @@ +. + * + * 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\Di; + +use Espo\Core\Webhook\Manager; + +/** + * @phpstan-ignore-next-line + */ +trait WebhookManagerSetter +{ + /** @var Manager */ + protected $webhookManager; + + public function setWebhookManager(Manager $webhookManager): void + { + $this->webhookManager = $webhookManager; + } +} diff --git a/application/Espo/Core/Duplicate/Finder.php b/application/Espo/Core/Duplicate/Finder.php new file mode 100644 index 0000000..9d2c4c3 --- /dev/null +++ b/application/Espo/Core/Duplicate/Finder.php @@ -0,0 +1,192 @@ +. + * + * 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\Duplicate; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Select\SelectBuilderFactory; + +use Espo\ORM\Collection; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\WhereItem; +use RuntimeException; + +class Finder +{ + private const LIMIT = 5; + + /** @var array> */ + private array $whereBuilderMap = []; + + public function __construct( + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private WhereBuilderFactory $whereBuilderFactory + ) {} + + /** + * Check whether an entity has a duplicate. + */ + public function check(Entity $entity): bool + { + $where = $this->getWhere($entity); + + if (!$where) { + return false; + } + + return $this->checkByWhere($entity, $where); + } + + /** + * Find entity duplicates. + * + * @return ?Collection + */ + public function find(Entity $entity): ?Collection + { + $where = $this->getWhere($entity); + + if (!$where) { + return null; + } + + return $this->findByWhere($entity, $where); + } + + /** + * The method is public for backward compatibility. + */ + public function checkByWhere(Entity $entity, WhereItem $where): bool + { + $entityType = $entity->getEntityType(); + + if ($entity->hasId()) { + $where = Cond::and( + $where, + Cond::notEqual( + Cond::column(Attribute::ID), + $entity->getId() + ) + ); + } + + $duplicate = $this->entityManager + ->getRDBRepository($entityType) + ->where($where) + ->select(Attribute::ID) + ->findOne(); + + return (bool) $duplicate; + } + + /** + * The method is public for backward compatibility. + * + * @return ?Collection + */ + public function findByWhere(Entity $entity, WhereItem $where): ?Collection + { + $entityType = $entity->getEntityType(); + + if ($entity->hasId()) { + $where = Cond::and( + $where, + Cond::notEqual( + Cond::column(Attribute::ID), + $entity->getId() + ) + ); + } + + try { + $baseQueryBuilder = $this->selectBuilderFactory + ->create() + ->from($entityType) + ->withStrictAccessControl() + ->buildQueryBuilder() + ->select([Attribute::ID]) + ->limit(0, self::LIMIT); + } catch (Forbidden|BadRequest $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + + $repository = $this->entityManager->getRDBRepository($entityType); + + $query = $baseQueryBuilder + ->where($where) + ->build(); + + $rdbBuilder = $repository->clone($query); + + if (!$rdbBuilder->findOne()) { + return null; + } + + $ids = array_map( + fn(Entity $e) => $e->getId(), + iterator_to_array($rdbBuilder->find()) + ); + + return $repository + ->clone($baseQueryBuilder->build()) + ->select(['*']) + ->where([Attribute::ID => $ids]) + ->find(); + } + + private function getWhere(Entity $entity): ?WhereItem + { + $entityType = $entity->getEntityType(); + + if (!array_key_exists($entityType, $this->whereBuilderMap)) { + $this->whereBuilderMap[$entityType] = $this->loadWhereBuilder($entityType); + } + + $builder = $this->whereBuilderMap[$entityType]; + + return $builder?->build($entity); + } + + /** + * @return ?WhereBuilder + */ + private function loadWhereBuilder(string $entityType): ?WhereBuilder + { + if (!$this->whereBuilderFactory->has($entityType)) { + return null; + } + + return $this->whereBuilderFactory->create($entityType); + } +} diff --git a/application/Espo/Core/Duplicate/WhereBuilder.php b/application/Espo/Core/Duplicate/WhereBuilder.php new file mode 100644 index 0000000..ec817d4 --- /dev/null +++ b/application/Espo/Core/Duplicate/WhereBuilder.php @@ -0,0 +1,44 @@ +. + * + * 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\Duplicate; + +use Espo\ORM\Entity; +use Espo\ORM\Query\Part\WhereItem; + +/** + * @template TEntity of Entity + */ +interface WhereBuilder +{ + /** + * @param TEntity $entity + */ + public function build(Entity $entity): ?WhereItem; +} diff --git a/application/Espo/Core/Duplicate/WhereBuilderFactory.php b/application/Espo/Core/Duplicate/WhereBuilderFactory.php new file mode 100644 index 0000000..d92ff12 --- /dev/null +++ b/application/Espo/Core/Duplicate/WhereBuilderFactory.php @@ -0,0 +1,71 @@ +. + * + * 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\Duplicate; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +use RuntimeException; + +class WhereBuilderFactory +{ + public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata) + {} + + public function has(string $entityType): bool + { + return (bool) $this->getClassName($entityType); + } + + /** + * @return WhereBuilder<\Espo\ORM\Entity> + * @throws RuntimeException + */ + public function create(string $entityType): WhereBuilder + { + $className = $this->getClassName($entityType); + + if (!$className) { + throw new RuntimeException("No duplicate-where-builder for '{$entityType}'."); + } + + return $this->injectableFactory->create($className); + } + + /** + * @return ?class-string> + */ + private function getClassName(string $entityType): ?string + { + /** @var ?class-string> */ + return $this->metadata + ->get(['recordDefs', $entityType, 'duplicateWhereBuilderClassName']); + } +} diff --git a/application/Espo/Core/Entities/CategoryTreeItem.php b/application/Espo/Core/Entities/CategoryTreeItem.php new file mode 100644 index 0000000..48c6328 --- /dev/null +++ b/application/Espo/Core/Entities/CategoryTreeItem.php @@ -0,0 +1,35 @@ +. + * + * 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\Entities; + +use Espo\Core\ORM\Entity; + +class CategoryTreeItem extends Entity +{} diff --git a/application/Espo/Core/Entities/Person.php b/application/Espo/Core/Entities/Person.php new file mode 100644 index 0000000..b1d7ad1 --- /dev/null +++ b/application/Espo/Core/Entities/Person.php @@ -0,0 +1,200 @@ +. + * + * 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\Entities; + +use Espo\Core\Field\Address; +use Espo\Core\Field\EmailAddressGroup; +use Espo\Core\Field\PhoneNumberGroup; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Entity; +use Espo\Core\ORM\Helper; + +use Espo\ORM\EntityManager; +use Espo\ORM\Relation\Relations; +use Espo\ORM\Value\ValueAccessorFactory; + +class Person extends Entity +{ + private Helper $helper; + + public function __construct( + string $entityType, + array $defs, + EntityManager $entityManager, + Helper $helper, + ?ValueAccessorFactory $valueAccessorFactory = null, + ?Relations $relations = null, + ) { + parent::__construct( + $entityType, + $defs, + $entityManager, + $valueAccessorFactory, + $relations + ); + + $this->helper = $helper; + } + + /** + * @param string $value + * @return void + */ + protected function _setLastName($value) + { + $this->setInContainer('lastName', $value); + + if (!$this->helper->hasAllPersonNameAttributes($this, 'name')) { + return; + } + + $name = $this->helper->formatPersonName($this, 'name'); + + $this->setInContainer(Field::NAME, $name); + } + + /** + * @param string $value + * @return void + */ + protected function _setFirstName($value) + { + $this->setInContainer('firstName', $value); + + if (!$this->helper->hasAllPersonNameAttributes($this, 'name')) { + return; + } + + $name = $this->helper->formatPersonName($this, 'name'); + + $this->setInContainer(Field::NAME, $name); + } + + /** + * @param string $value + * @return void + */ + protected function _setMiddleName($value) + { + $this->setInContainer('middleName', $value); + + if (!$this->helper->hasAllPersonNameAttributes($this, 'name')) { + return; + } + + $name = $this->helper->formatPersonName($this, 'name'); + + $this->setInContainer(Field::NAME, $name); + } + + /** + * Get a primary email address. + */ + public function getEmailAddress(): ?string + { + return $this->get('emailAddress'); + } + + /** + * Get a primary phone number. + */ + public function getPhoneNumber(): ?string + { + return $this->get('phoneNumber'); + } + + public function getEmailAddressGroup(): EmailAddressGroup + { + /** @var EmailAddressGroup */ + return $this->getValueObject('emailAddress'); + } + + public function getPhoneNumberGroup(): PhoneNumberGroup + { + /** @var PhoneNumberGroup */ + return $this->getValueObject('phoneNumber'); + } + + public function getName(): ?string + { + return $this->get(Field::NAME); + } + + public function getFirstName(): ?string + { + return $this->get('firstName'); + } + + public function getLastName(): ?string + { + return $this->get('lastName'); + } + + public function getMiddleName(): ?string + { + return $this->get('middleName'); + } + + public function setFirstName(?string $firstName): static + { + return $this->set('firstName', $firstName); + } + + public function setLastName(?string $lastName): static + { + return $this->set('lastName', $lastName); + } + + public function setMiddleName(?string $middleName): static + { + return $this->set('middleName', $middleName); + } + + public function setEmailAddressGroup(EmailAddressGroup $group): static + { + return $this->setValueObject('emailAddress', $group); + } + + public function setPhoneNumberGroup(PhoneNumberGroup $group): static + { + return $this->setValueObject('phoneNumber', $group); + } + + public function getAddress(): Address + { + /** @var Address */ + return $this->getValueObject('address'); + } + + public function setAddress(Address $address): static + { + return $this->setValueObject('address', $address); + } +} diff --git a/application/Espo/Core/EntryPoint/EntryPoint.php b/application/Espo/Core/EntryPoint/EntryPoint.php new file mode 100644 index 0000000..842ab67 --- /dev/null +++ b/application/Espo/Core/EntryPoint/EntryPoint.php @@ -0,0 +1,52 @@ +. + * + * 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\EntryPoint; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; + +/** + * An entry point to the application by `?entryPoint={entityPointName}` URI. + * Only GET requests are handled. + */ +interface EntryPoint +{ + /** + * @throws Error + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + */ + public function run(Request $request, Response $response): void; +} diff --git a/application/Espo/Core/EntryPoint/EntryPointManager.php b/application/Espo/Core/EntryPoint/EntryPointManager.php new file mode 100644 index 0000000..71d5463 --- /dev/null +++ b/application/Espo/Core/EntryPoint/EntryPointManager.php @@ -0,0 +1,134 @@ +. + * + * 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\EntryPoint; + +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Exceptions\NotFoundSilent; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\ClassFinder; +use Espo\Core\Utils\Metadata; + +/** + * Runs entry points. + */ +class EntryPointManager +{ + public function __construct( + private InjectableFactory $injectableFactory, + private ClassFinder $classFinder, + private Metadata $metadata, + ) {} + + /** + * @throws NotFound + */ + public function checkAuthRequired(string $name): bool + { + $metaParams = $this->getMetaParams($name); + + if ($metaParams->noAuth) { + return false; + } + + $className = $this->getClassName($name); + + if (!$className) { + throw new NotFoundSilent("Entry point '$name' not found."); + } + + $noAuth = false; + + if (isset($className::$noAuth)) { + $noAuth = $className::$noAuth; + } + + if ($noAuth) { + return false; + } + + // for backward compatibility + return $className::$authRequired ?? true; + } + + /** + * @throws NotFound + */ + public function run(string $name, Request $request, Response $response): void + { + $className = $this->getClassName($name); + + if (!$className) { + throw new NotFoundSilent("Entry point '$name' not found."); + } + + $entryPoint = $this->injectableFactory->create($className); + + $entryPoint->run($request, $response); + } + + /** + * @return ?class-string + */ + private function getClassName(string $name): ?string + { + $metaParams = $this->getMetaParams($name); + + if ($metaParams->className) { + return $metaParams->className; + } + + /** @var ?class-string */ + return $this->classFinder->find('EntryPoints', ucfirst($name)); + } + + public function getMetaParams(string $name): MetaParams + { + /** + * @var array{ + * className?: ?class-string, + * notExposed?: bool, + * noAuth?: bool, + * allowedMethods?: ?string[], + * consumes?: ?string[] + * } $params + */ + $params = $this->metadata->get("app.entryPoints.$name") ?? []; + + return new MetaParams( + className: $params['className'] ?? null, + notExposed: $params['notExposed'] ?? false, + noAuth: $params['noAuth'] ?? false, + allowedMethods: $params['allowedMethods'] ?? null, + consumes: $params['consumes'] ?? null, + ); + } +} diff --git a/application/Espo/Core/EntryPoint/MetaParams.php b/application/Espo/Core/EntryPoint/MetaParams.php new file mode 100644 index 0000000..e4b25f9 --- /dev/null +++ b/application/Espo/Core/EntryPoint/MetaParams.php @@ -0,0 +1,49 @@ +. + * + * 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\EntryPoint; + +/** + * @since 10.1.0 + */ +readonly class MetaParams +{ + /** + * @param ?class-string $className + * @param ?string[] $allowedMethods + * @param ?string[] $consumes + */ + public function __construct( + public ?string $className = null, + public bool $notExposed = false, + public bool $noAuth = false, + public ?array $allowedMethods = null, + public ?array $consumes = null, + ) {} +} diff --git a/application/Espo/Core/EntryPoint/OutputEmitter.php b/application/Espo/Core/EntryPoint/OutputEmitter.php new file mode 100644 index 0000000..8ced2c6 --- /dev/null +++ b/application/Espo/Core/EntryPoint/OutputEmitter.php @@ -0,0 +1,44 @@ +. + * + * 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\EntryPoint; + +use Espo\Core\Api\ResponseWrapper; +use Slim\ResponseEmitter; + +/** + * @internal + */ +class OutputEmitter +{ + public function emit(ResponseWrapper $response): void + { + (new ResponseEmitter())->emit($response->toPsr7()); + } +} diff --git a/application/Espo/Core/EntryPoint/Params.php b/application/Espo/Core/EntryPoint/Params.php new file mode 100644 index 0000000..2e640d7 --- /dev/null +++ b/application/Espo/Core/EntryPoint/Params.php @@ -0,0 +1,41 @@ +. + * + * 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\EntryPoint; + +/** + * @since 10.1.0 + */ +readonly class Params +{ + public function __construct( + public ?string $name = null, + public bool $final = false, + ) {} +} diff --git a/application/Espo/Core/EntryPoint/RequestFactory.php b/application/Espo/Core/EntryPoint/RequestFactory.php new file mode 100644 index 0000000..07aa9ac --- /dev/null +++ b/application/Espo/Core/EntryPoint/RequestFactory.php @@ -0,0 +1,48 @@ +. + * + * 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\EntryPoint; + +use Espo\Core\Api\RequestWrapper; +use Espo\Core\Utils\Route; +use Slim\Factory\ServerRequestCreatorFactory; + +/** + * @internal + */ +class RequestFactory +{ + public function create(): RequestWrapper + { + return new RequestWrapper( + ServerRequestCreatorFactory::create()->createServerRequestFromGlobals(), + Route::detectBasePath() + ); + } +} diff --git a/application/Espo/Core/EntryPoint/ResponseFactory.php b/application/Espo/Core/EntryPoint/ResponseFactory.php new file mode 100644 index 0000000..1b45672 --- /dev/null +++ b/application/Espo/Core/EntryPoint/ResponseFactory.php @@ -0,0 +1,44 @@ +. + * + * 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\EntryPoint; + +use Espo\Core\Api\ResponseWrapper; +use Slim\Psr7\Response; + +/** + * @internal + */ +class ResponseFactory +{ + public function create(): ResponseWrapper + { + return new ResponseWrapper(new Response()); + } +} diff --git a/application/Espo/Core/EntryPoint/Starter.php b/application/Espo/Core/EntryPoint/Starter.php new file mode 100644 index 0000000..3cfbb3e --- /dev/null +++ b/application/Espo/Core/EntryPoint/Starter.php @@ -0,0 +1,270 @@ +. + * + * 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\EntryPoint; + +use Espo\Core\Api\Method; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Application\Runner\Params as RunnerParams; +use Espo\Core\ApplicationUser; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Portal\Application as PortalApplication; +use Espo\Core\Authentication\AuthenticationFactory; +use Espo\Core\Authentication\AuthToken\Manager as AuthTokenManager; +use Espo\Core\Api\ErrorOutput; +use Espo\Core\Api\RequestWrapper; +use Espo\Core\Api\ResponseWrapper; +use Espo\Core\Api\AuthBuilderFactory; +use Espo\Core\Portal\Utils\Url; +use Espo\Core\Utils\ClientManager; +use Espo\Core\ApplicationRunners\EntryPoint as EntryPointRunner; + +use Exception; + +/** + * Starts an entry point. + */ +class Starter +{ + public function __construct( + private AuthenticationFactory $authenticationFactory, + private EntryPointManager $entryPointManager, + private ClientManager $clientManager, + private ApplicationUser $applicationUser, + private AuthTokenManager $authTokenManager, + private AuthBuilderFactory $authBuilderFactory, + private RequestFactory $requestFactory, + private ResponseFactory $responseFactory, + private ErrorOutput $errorOutput, + private OutputEmitter $outputEmitter, + ) {} + + /** + * @throws Forbidden + * @throws NotFound + */ + public function start(Params $params = new Params()): void + { + $request = $this->requestFactory->create(); + $response = $this->responseFactory->create(); + + try { + $name = $this->getName($params, $request); + + $this->checkStart($name, $params, $request); + } catch (BadRequest|Forbidden $e) { + $this->errorOutput->processWithBodyPrinting($request, $response, $e); + $this->outputEmitter->emit($response); + + return; + } + + /** + * @todo + * Consider supporting portal detection when it's run through the `portals/` directory. + * E.g. ChangePassword is not run through the portal unless the ID is set in ENV. + */ + $portalId = Url::getPortalIdFromEnv(); + + if ($portalId && !$params->final) { + $this->runThroughPortal($portalId, $name); + + return; + } + + try { + $authRequired = $this->entryPointManager->checkAuthRequired($name); + } catch (NotFound $e) { + $this->errorOutput->processWithBodyPrinting($request, $response, $e); + $this->outputEmitter->emit($response); + + return; + } + + if ($authRequired && !$params->final) { + $portalId = $this->detectPortalId($request); + + if ($portalId) { + $this->runThroughPortal($portalId, $name); + + return; + } + } + + $this->processRequest( + entryPoint: $name, + request: $request, + response: $response, + authRequired: $authRequired, + ); + + $this->outputEmitter->emit($response); + } + + private function processRequest( + string $entryPoint, + RequestWrapper $request, + ResponseWrapper $response, + bool $authRequired, + ): void { + + try { + $this->processRequestInternal( + entryPoint: $entryPoint, + request: $request, + response: $response, + authRequired: $authRequired, + ); + } catch (Exception $exception) { + print_r($exception->getMessage()); + $this->errorOutput->processWithBodyPrinting($request, $response, $exception); + } + } + + /** + * @throws NotFound + * @throws BadRequest + */ + private function processRequestInternal( + string $entryPoint, + RequestWrapper $request, + ResponseWrapper $response, + bool $authRequired, + ): void { + + $authentication = $this->authenticationFactory->create(); + + $apiAuth = $this->authBuilderFactory->create() + ->setAuthentication($authentication) + ->setAuthRequired($authRequired) + ->forEntryPoint() + ->build(); + + $authResult = $apiAuth->process($request, $response); + + if (!$authResult->isResolved()) { + return; + } + + if ($authResult->isResolvedUseNoAuth()) { + $this->applicationUser->setupSystemUser(); + } + + ob_start(); + + $this->entryPointManager->run($entryPoint, $request, $response); + + $contents = ob_get_clean(); + + if ($contents) { + $response->writeBody($contents); + } + } + + private function detectPortalId(RequestWrapper $request): ?string + { + if ($request->hasQueryParam('portalId')) { + return $request->getQueryParam('portalId'); + } + + $token = $request->getCookieParam('auth-token'); + + if (!$token) { + return null; + } + + $authToken = $this->authTokenManager->get($token); + + return $authToken?->getPortalId(); + } + + /** + * @throws Forbidden + * @throws NotFound + */ + private function runThroughPortal(string $portalId, string $entryPoint): void + { + $app = new PortalApplication($portalId); + + $clientManager = $app->getContainer()->getByClass(ClientManager::class); + + $clientManager->setBasePath($this->clientManager->getBasePath()); + $clientManager->setApiUrl('api/v1/portal-access/' . $portalId); + $clientManager->setApplicationId($portalId); + + $params = RunnerParams::fromArray([ + EntryPointRunner::PARAM_ENTRY_POINT => $entryPoint, + EntryPointRunner::PARAM_FINAL => true, + ]); + + $app->run(EntryPointRunner::class, $params); + } + + /** + * @throws BadRequest + */ + private function getName(Params $params, RequestWrapper $request): string + { + $name = $params->name ?? $request->getQueryParam('entryPoint'); + + if (!$name) { + throw new BadRequest("No 'entryPoint' param."); + } + + return $name; + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function checkStart(string $name, Params $params, RequestWrapper $request): void + { + $method = $request->getMethod(); + + $metaParams = $this->entryPointManager->getMetaParams($name); + + if ($metaParams->notExposed && $params->name === null) { + throw new Forbidden("The entry point is not exposed."); + } + + if ($metaParams->allowedMethods !== null) { + if (!in_array(strtolower($method), $metaParams->allowedMethods)) { + throw new BadRequest("Method '$method' is not allowed."); + } + } else if ($method !== Method::GET) { + throw new BadRequest("Only GET requests allowed for the entry point."); + } + + if ($metaParams->consumes !== null && !in_array($request->getContentType(), $metaParams->consumes)) { + throw new BadRequest("Not allowed Content-Type."); + } + } +} diff --git a/application/Espo/Core/EntryPoint/Traits/NoAuth.php b/application/Espo/Core/EntryPoint/Traits/NoAuth.php new file mode 100644 index 0000000..2de0789 --- /dev/null +++ b/application/Espo/Core/EntryPoint/Traits/NoAuth.php @@ -0,0 +1,42 @@ +. + * + * 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\EntryPoint\Traits; + +/** + * Auth is not required for entry point. + * To be used with EntryPoint interface. + */ +trait NoAuth +{ + /** + * @var bool + */ + public static $noAuth = true; +} diff --git a/application/Espo/Core/Exceptions/BadRequest.php b/application/Espo/Core/Exceptions/BadRequest.php new file mode 100644 index 0000000..1957e9d --- /dev/null +++ b/application/Espo/Core/Exceptions/BadRequest.php @@ -0,0 +1,77 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Utils\Log; +use Throwable; +use Exception; + +/** + * A bad request exception. Main purpose is for the 400 Bad Request HTTP error. + * If uncaught within an API request, the message will be printed to the X-Status-Reason header. + */ +class BadRequest extends Exception implements HasBody, HasLogLevel +{ + /** @var int */ + protected $code = 400; + protected ?string $body = null; + + final public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + /** + * Create with a body (supposed to be sent to the frontend). + * Body object is supported since v8.1. + */ + public static function createWithBody(string $reason, string|Body $body): self + { + if ($body instanceof Body) { + $body = $body->encode(); + } + + $exception = new static($reason); + $exception->body = $body; + + return $exception; + } + + public function getBody(): ?string + { + return $this->body; + } + + public function getLogLevel(): string + { + return Log::LEVEL_WARNING; + } +} diff --git a/application/Espo/Core/Exceptions/Conflict.php b/application/Espo/Core/Exceptions/Conflict.php new file mode 100644 index 0000000..dd1ae58 --- /dev/null +++ b/application/Espo/Core/Exceptions/Conflict.php @@ -0,0 +1,71 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Exceptions\Error\Body; +use Exception; +use Throwable; + +/** + * A conflict exception. Main purpose is for the 409 Conflict HTTP error. + * If uncaught within an API request, the message will be printed to the X-Status-Reason header. + */ +class Conflict extends Exception implements HasBody +{ + /** @var int */ + protected $code = 409; + private ?string $body = null; + + final public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + /** + * Create with a body (supposed to be sent to the frontend). + * Body object is supported since v8.1. + */ + public static function createWithBody(string $reason, string|Body $body): static + { + if ($body instanceof Body) { + $body = $body->encode(); + } + + $exception = new static($reason); + $exception->body = $body; + + return $exception; + } + + public function getBody(): ?string + { + return $this->body; + } +} diff --git a/application/Espo/Core/Exceptions/ConflictSilent.php b/application/Espo/Core/Exceptions/ConflictSilent.php new file mode 100644 index 0000000..33ba337 --- /dev/null +++ b/application/Espo/Core/Exceptions/ConflictSilent.php @@ -0,0 +1,40 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Utils\Log; + +class ConflictSilent extends Conflict implements HasLogLevel +{ + public function getLogLevel(): string + { + return Log::LEVEL_NOTICE; + } +} diff --git a/application/Espo/Core/Exceptions/Error.php b/application/Espo/Core/Exceptions/Error.php new file mode 100644 index 0000000..b78ced7 --- /dev/null +++ b/application/Espo/Core/Exceptions/Error.php @@ -0,0 +1,68 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Exceptions\Error\Body; +use Throwable; + +/** + * An exception for 500 Internal Server Error. + * If uncaught within an API request, the message will be printed to the X-Status-Reason header. + */ +class Error extends InternalServerError implements HasBody +{ + private ?string $body = null; + + final public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + /** + * Create with a body (supposed to be sent to the frontend). + * Body object is supported since v8.1. + */ + public static function createWithBody(string $message, string|Body $body): self + { + if ($body instanceof Body) { + $body = $body->encode(); + } + + $exception = new static($message); + $exception->body = $body; + + return $exception; + } + + public function getBody(): ?string + { + return $this->body; + } +} diff --git a/application/Espo/Core/Exceptions/Error/Body.php b/application/Espo/Core/Exceptions/Error/Body.php new file mode 100644 index 0000000..4883157 --- /dev/null +++ b/application/Espo/Core/Exceptions/Error/Body.php @@ -0,0 +1,95 @@ +. + * + * 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\Exceptions\Error; + +use Espo\Core\Utils\Json; + +/** + * A wrapper for error message data for the frontend. Supposed to be passed encoded to `createWithBody` + * methods of exceptions. + */ +class Body +{ + private ?string $messageTranslationLabel = null; + private ?string $messageTranslationScope = null; + /** @var ?array */ + private ?array $messageTranslationData = null; + private ?string $message = null; + + public static function create(): self + { + return new self(); + } + + /** + * A translatable message to display in frontend. Labels should be in the `messages` category. + * + * @param ?array $data + */ + public function withMessageTranslation(string $label, ?string $scope = null, ?array $data = null): self + { + $obj = clone $this; + + $obj->messageTranslationLabel = $label; + $obj->messageTranslationScope = $scope; + $obj->messageTranslationData = $data; + + return $obj; + } + + public function withMessage(string $message): self + { + $obj = clone $this; + $obj->message = $message; + + return $obj; + } + + public function encode(): string + { + $data = (object) []; + + if ($this->messageTranslationLabel) { + $messageTranslationData = (object) ($this->messageTranslationData ?? []); + + $data->messageTranslation = (object) [ + 'label' => $this->messageTranslationLabel, + 'scope' => $this->messageTranslationScope, + 'data' => $messageTranslationData, + ]; + } + + if ($this->message) { + $data->message = $this->message; + } + + return Json::encode($data); + } +} diff --git a/application/Espo/Core/Exceptions/ErrorSilent.php b/application/Espo/Core/Exceptions/ErrorSilent.php new file mode 100644 index 0000000..8aa17a9 --- /dev/null +++ b/application/Espo/Core/Exceptions/ErrorSilent.php @@ -0,0 +1,40 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Utils\Log; + +class ErrorSilent extends Error implements HasLogLevel +{ + public function getLogLevel(): string + { + return Log::LEVEL_NOTICE; + } +} diff --git a/application/Espo/Core/Exceptions/Forbidden.php b/application/Espo/Core/Exceptions/Forbidden.php new file mode 100644 index 0000000..c6498b2 --- /dev/null +++ b/application/Espo/Core/Exceptions/Forbidden.php @@ -0,0 +1,77 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Utils\Log; +use Throwable; +use Exception; + +/** + * A forbidden exception. Main purpose is for the 403 Forbidden HTTP error. + * If uncaught within an API request, the message will be printed to the X-Status-Reason header. + */ +class Forbidden extends Exception implements HasBody, HasLogLevel +{ + /** @var int */ + protected $code = 403; + private ?string $body = null; + + final public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + /** + * Create with a body (supposed to be sent to the frontend). + * Body object is supported since v8.1. + */ + public static function createWithBody(string $message, string|Body $body): self + { + if ($body instanceof Body) { + $body = $body->encode(); + } + + $exception = new static($message); + $exception->body = $body; + + return $exception; + } + + public function getBody(): ?string + { + return $this->body; + } + + public function getLogLevel(): string + { + return Log::LEVEL_WARNING; + } +} diff --git a/application/Espo/Core/Exceptions/ForbiddenSilent.php b/application/Espo/Core/Exceptions/ForbiddenSilent.php new file mode 100644 index 0000000..7156d55 --- /dev/null +++ b/application/Espo/Core/Exceptions/ForbiddenSilent.php @@ -0,0 +1,40 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Utils\Log; + +class ForbiddenSilent extends Forbidden implements HasLogLevel +{ + public function getLogLevel(): string + { + return Log::LEVEL_NOTICE; + } +} diff --git a/application/Espo/Core/Exceptions/HasBody.php b/application/Espo/Core/Exceptions/HasBody.php new file mode 100644 index 0000000..7e0a45d --- /dev/null +++ b/application/Espo/Core/Exceptions/HasBody.php @@ -0,0 +1,41 @@ +. + * + * 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\Exceptions; + +/** + * Has a body supposed to be sent to the frontend. + */ +interface HasBody +{ + /** + * Get a body (supposed to be sent to the frontend). + */ + public function getBody(): ?string; +} diff --git a/application/Espo/Core/Exceptions/HasLogLevel.php b/application/Espo/Core/Exceptions/HasLogLevel.php new file mode 100644 index 0000000..83805f1 --- /dev/null +++ b/application/Espo/Core/Exceptions/HasLogLevel.php @@ -0,0 +1,41 @@ +. + * + * 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\Exceptions; + +/** + * Has a specific log level (for the logger). + */ +interface HasLogLevel +{ + /** + * Get a log level. + */ + public function getLogLevel(): string; +} diff --git a/application/Espo/Core/Exceptions/HasLogMessage.php b/application/Espo/Core/Exceptions/HasLogMessage.php new file mode 100644 index 0000000..b3ff5ad --- /dev/null +++ b/application/Espo/Core/Exceptions/HasLogMessage.php @@ -0,0 +1,41 @@ +. + * + * 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\Exceptions; + +/** + * Has a log message (for the logger). + */ +interface HasLogMessage +{ + /** + * Get a log message. + */ + public function getLogMessage(): string; +} diff --git a/application/Espo/Core/Exceptions/InternalServerError.php b/application/Espo/Core/Exceptions/InternalServerError.php new file mode 100644 index 0000000..4e1172b --- /dev/null +++ b/application/Espo/Core/Exceptions/InternalServerError.php @@ -0,0 +1,36 @@ +. + * + * 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\Exceptions; + +class InternalServerError extends \Exception +{ + /** @var int */ + protected $code = 500; +} diff --git a/application/Espo/Core/Exceptions/NotFound.php b/application/Espo/Core/Exceptions/NotFound.php new file mode 100644 index 0000000..b4e671b --- /dev/null +++ b/application/Espo/Core/Exceptions/NotFound.php @@ -0,0 +1,48 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Utils\Log; +use Exception; + +/** + * A not-found exception. Main purpose is for the 404 Not Found HTTP error. + * If uncaught within an API request, the message will be printed to the X-Status-Reason header. + */ +class NotFound extends Exception implements HasLogLevel +{ + /** @var int */ + protected $code = 404; + + public function getLogLevel(): string + { + return Log::LEVEL_WARNING; + } +} diff --git a/application/Espo/Core/Exceptions/NotFoundSilent.php b/application/Espo/Core/Exceptions/NotFoundSilent.php new file mode 100644 index 0000000..4f7d4c7 --- /dev/null +++ b/application/Espo/Core/Exceptions/NotFoundSilent.php @@ -0,0 +1,40 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Utils\Log; + +class NotFoundSilent extends NotFound implements HasLogLevel +{ + public function getLogLevel(): string + { + return Log::LEVEL_NOTICE; + } +} diff --git a/application/Espo/Core/Exceptions/ServiceUnavailable.php b/application/Espo/Core/Exceptions/ServiceUnavailable.php new file mode 100644 index 0000000..4ad1f48 --- /dev/null +++ b/application/Espo/Core/Exceptions/ServiceUnavailable.php @@ -0,0 +1,66 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Exceptions\Error\Body; +use Throwable; + +class ServiceUnavailable extends \Exception implements HasBody +{ + /** @var int */ + protected $code = 503; + private ?string $body = null; + + final public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + /** + * Create with a body (supposed to be sent to the frontend). + * Body object is supported since v8.1. + */ + public static function createWithBody(string $message, string|Body $body): self + { + if ($body instanceof Body) { + $body = $body->encode(); + } + + $exception = new static($message); + $exception->body = $body; + + return $exception; + } + + public function getBody(): ?string + { + return $this->body; + } +} diff --git a/application/Espo/Core/Exceptions/Unauthorized.php b/application/Espo/Core/Exceptions/Unauthorized.php new file mode 100644 index 0000000..cef5149 --- /dev/null +++ b/application/Espo/Core/Exceptions/Unauthorized.php @@ -0,0 +1,38 @@ +. + * + * 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\Exceptions; + +use Exception; + +class Unauthorized extends Exception +{ + /** @var int */ + protected $code = 401; +} diff --git a/application/Espo/Core/ExternalAccount/ClientManager.php b/application/Espo/Core/ExternalAccount/ClientManager.php new file mode 100644 index 0000000..6ccbae8 --- /dev/null +++ b/application/Espo/Core/ExternalAccount/ClientManager.php @@ -0,0 +1,459 @@ +. + * + * 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\ExternalAccount; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Field\DateTime; +use Espo\Core\Utils\Language; +use Espo\Entities\Integration; +use Espo\Entities\ExternalAccount; +use Espo\Entities\Notification; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\Core\ExternalAccount\Clients\IClient; +use Espo\Core\ExternalAccount\OAuth2\Client as OAuth2Client; +use Espo\Core\InjectableFactory; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Name\Attribute; +use RuntimeException; + +class ClientManager +{ + private const REFRESH_TOKEN_ATTEMPTS_LIMIT = 20; + private const REFRESH_TOKEN_ATTEMPTS_PERIOD = '1 day'; + + /** @var array & array{externalAccountEntity: ExternalAccount})> */ + protected $clientMap = []; + + public function __construct( + protected EntityManager $entityManager, + protected Metadata $metadata, + protected Config $config, + protected ?InjectableFactory $injectableFactory = null, + private ?Language $language = null + ) {} + + /** + * @param array{ + * accessToken: ?string, + * tokenType: ?string, + * expiresAt?: ?string, + * refreshToken?: ?string, + * } $data + * @throws Error + */ + public function storeAccessToken(object $client, array $data): void + { + try { + $account = $this->getClientRecord($client); + } catch (Error) { + // @todo Revise. + return; + } + + $account->setAccessToken($data['accessToken']); + $account->setTokenType($data['tokenType']); + $account->setExpiresAt($data['expiresAt'] ?? null); + $account->setRefreshTokenAttempts(null); + + if ($data['refreshToken'] ?? null) { + $account->setRefreshToken($data['refreshToken']); + } + + /** @var ?ExternalAccount $account */ + $account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $account->getId()); + + if (!$account) { + throw new Error("External Account: Account removed."); + } + + if (!$account->isEnabled()) { + throw new Error("External Account: Account disabled."); + } + + $account->setAccessToken($data['accessToken']); + $account->setTokenType($data['tokenType']); + $account->setExpiresAt($data['expiresAt'] ?? null); + $account->setRefreshTokenAttempts(null); + + if ($data['refreshToken'] ?? null) { + $account->setRefreshToken($data['refreshToken'] ?? null); + } + + $this->entityManager->saveEntity($account, [ + 'isTokenRenewal' => true, + SaveOption::SKIP_HOOKS => true, + ]); + } + + /** + * @throws Error + */ + public function create(string $integration, string $userId): ?object + { + $authMethod = $this->metadata->get("integrations.$integration.authMethod"); + + if (ucfirst($authMethod) === 'OAuth2') { + return $this->createOAuth2($integration, $userId); + } + + $methodName = 'create' . ucfirst($authMethod); + + if (method_exists($this, $methodName)) { + return $this->$methodName($integration, $userId); + } + + if (!$this->injectableFactory) { + throw new RuntimeException("No injectableFactory."); + } + + /** @var ?Integration $integrationEntity */ + $integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration); + + /** @var ?ExternalAccount $account */ + $account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId"); + + if (!$account) { + throw new Error("External Account $integration not found for $userId."); + } + + if (!$integrationEntity || !$integrationEntity->isEnabled() || !$account->isEnabled()) { + return null; + } + + /** @var class-string $className */ + $className = $this->metadata->get("integrations.$integration.clientClassName"); + + $client = $this->injectableFactory->create($className); + + if (!method_exists($client, 'setup')) { + throw new RuntimeException("$className does not have `setup` method."); + } + + $client->setup( + $userId, + $integrationEntity, + $account, + $this + ); + + $this->addToClientMap($client, $integrationEntity, $account, $userId); + + return $client; + } + + /** + * @throws Error + */ + protected function createOAuth2(string $integration, string $userId): ?object + { + /** @var ?Integration $integrationEntity */ + $integrationEntity = $this->entityManager->getEntityById(Integration::ENTITY_TYPE, $integration); + + /** @var ?ExternalAccount $account */ + $account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, "{$integration}__$userId"); + + /** @var class-string $className */ + $className = $this->metadata->get("integrations.$integration.clientClassName"); + $redirectUri = $this->config->get('siteUrl') . '?entryPoint=oauthCallback'; + $redirectUriPath = $this->metadata->get(['integrations', $integration, 'params', 'redirectUriPath']); + + if ($redirectUriPath) { + $redirectUri = rtrim($this->config->get('siteUrl'), '/') . '/' . $redirectUriPath; + } + + if (!$account) { + throw new Error("External Account $integration not found for '$userId'."); + } + + if ( + !$integrationEntity || + !$integrationEntity->isEnabled() || + !$account->isEnabled() + ) { + return null; + } + + $oauth2Client = new OAuth2Client(); + + $params = [ + 'endpoint' => $this->metadata->get("integrations.$integration.params.endpoint"), + 'tokenEndpoint' => $this->metadata->get("integrations.$integration.params.tokenEndpoint"), + 'clientId' => $integrationEntity->get('clientId'), + 'clientSecret' => $integrationEntity->get('clientSecret'), + 'redirectUri' => $redirectUri, + 'accessToken' => $account->getAccessToken(), + 'refreshToken' => $account->getRefreshToken(), + 'tokenType' => $account->getTokenType(), + 'expiresAt' => $account->getExpiresAt() ? $account->getExpiresAt()->toString() : null, + ]; + + $authType = $this->metadata->get("integrations.$integration.authType"); + $tokenType = $this->metadata->get("integrations.$integration.tokenType"); + + if ($authType === 'Uri') { + $oauth2Client->setAuthType(OAuth2Client::AUTH_TYPE_URI); + } else if ($authType === 'Basic') { + $oauth2Client->setAuthType(OAuth2Client::AUTH_TYPE_AUTHORIZATION_BASIC); + } else if ($authType === 'Form') { + $oauth2Client->setAuthType(OAuth2Client::AUTH_TYPE_FORM); + } + + if ($tokenType === 'Bearer') { + $oauth2Client->setTokenType(OAuth2Client::TOKEN_TYPE_BEARER); + } else if ($authType === 'Uri') { + $oauth2Client->setTokenType(OAuth2Client::TOKEN_TYPE_URI); + } else if ($authType === 'OAuth') { + $oauth2Client->setTokenType(OAuth2Client::TOKEN_TYPE_OAUTH); + } + + foreach (get_object_vars($integrationEntity->getValueMap()) as $k => $v) { + if (array_key_exists($k, $params)) { + continue; + } + + if ($integrationEntity->hasAttribute($k)) { + continue; + } + + $params[$k] = $v; + } + + if ($this->injectableFactory) { + $client = $this->injectableFactory->createWith($className, [ + 'client' => $oauth2Client, + 'params' => $params, + 'manager' => $this, + ]); + } else { + // For backward compatibility. + $client = new $className($oauth2Client, $params, $this); + } + + $this->addToClientMap($client, $integrationEntity, $account, $userId); + + return $client; + } + + /** + * @param object $client + * @return void + */ + protected function addToClientMap( + $client, + Integration $integration, + ExternalAccount $account, + string $userId + ) { + $this->clientMap[spl_object_hash($client)] = [ + 'client' => $client, + 'userId' => $userId, + 'integration' => $integration->getId(), + 'integrationEntity' => $integration, + 'externalAccountEntity' => $account, + ]; + } + + /** + * @param object $client + * @throws Error + */ + protected function getClientRecord($client): ExternalAccount + { + $data = $this->clientMap[spl_object_hash($client)] ?? null; + + if (!$data) { + throw new Error("External Account: Client not found in hash."); + } + + if (!isset($data['externalAccountEntity'])) { + throw new Error("External Account: Account not found in hash."); + } + + return $data['externalAccountEntity']; + } + + /** + * @param object $client + * @throws Error + */ + public function isClientLocked($client): bool + { + $accountSet = $this->getClientRecord($client); + + $account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId()); + + return $account->isLocked(); + } + + /** + * @throws Error + */ + public function lockClient(object $client): void + { + $accountSet = $this->getClientRecord($client); + + $account = $this->fetchAccountOnlyWithIsLocked($accountSet->getId()); + $account->setIsLocked(true); + + $this->entityManager->saveEntity($account, [ + SaveOption::SKIP_HOOKS => true, + SaveOption::SILENT => true, + ]); + } + + /** + * @throws Error + */ + public function unlockClient(object $client): void + { + $accountSet = $this->getClientRecord($client); + + $accountSet = $this->fetchAccountOnlyWithIsLocked($accountSet->getId()); + $accountSet->setIsLocked(false); + + $this->entityManager->saveEntity($accountSet, [ + SaveOption::SKIP_HOOKS => true, + SaveOption::SILENT => true, + ]); + } + + /** + * @throws Error + */ + public function controlRefreshTokenAttempts(object $client): void + { + $accountSet = $this->getClientRecord($client); + + $account = $this->entityManager + ->getRDBRepositoryByClass(ExternalAccount::class) + ->getById($accountSet->getId()); + + if (!$account) { + return; + } + + $attempts = $account->getRefreshTokenAttempts(); + + $account->setRefreshTokenAttempts($attempts + 1); + + if ( + $attempts >= self::REFRESH_TOKEN_ATTEMPTS_LIMIT && + $account->getExpiresAt() && + $account->getExpiresAt() + ->modify('+' . self::REFRESH_TOKEN_ATTEMPTS_PERIOD) + ->isLessThan(DateTime::createNow()) + ) { + $account->setIsEnabled(false); + $account->unsetData(); + } + + $this->entityManager->saveEntity($account, [ + SaveOption::SKIP_HOOKS => true, + SaveOption::SILENT => true, + ]); + + if (!$account->isEnabled()) { + $this->createDisableNotification($account); + } + } + + /** + * @param IClient $client + * @throws Error + */ + public function reFetchClient($client): void + { + $accountSet = $this->getClientRecord($client); + + $id = $accountSet->getId(); + + $account = $this->entityManager->getEntityById(ExternalAccount::ENTITY_TYPE, $id); + + if (!$account) { + throw new Error("External Account: Client $id not found in DB."); + } + + $data = $account->getValueMap(); + + $accountSet->set($data); + + $client->setParams(get_object_vars($data)); + } + + /** + * @throws Error + */ + private function fetchAccountOnlyWithIsLocked(string $id): ExternalAccount + { + $account = $this->entityManager + ->getRDBRepository(ExternalAccount::ENTITY_TYPE) + ->select([Attribute::ID, 'isLocked']) + ->where([Attribute::ID => $id]) + ->findOne(); + + if (!$account) { + throw new Error("External Account: Client '$id' not found in DB."); + } + + return $account; + } + + private function createDisableNotification(ExternalAccount $account): void + { + if (!str_contains($account->getId(), '__')) { + return; + } + + [$integration, $userId] = explode('__', $account->getId()); + + if (!$this->entityManager->getEntityById(User::ENTITY_TYPE, $userId)) { + return; + } + + if (!$this->language) { + return; + } + + $message = $this->language->translateLabel('externalAccountNoConnectDisabled', 'messages', 'ExternalAccount'); + $message = str_replace('{integration}', $integration, $message); + + $notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew(); + + $notification + ->setType(Notification::TYPE_MESSAGE) + ->setMessage($message) + ->setUserId($userId); + + $this->entityManager->saveEntity($notification); + } +} diff --git a/application/Espo/Core/ExternalAccount/Clients/Google.php b/application/Espo/Core/ExternalAccount/Clients/Google.php new file mode 100644 index 0000000..009d3d4 --- /dev/null +++ b/application/Espo/Core/ExternalAccount/Clients/Google.php @@ -0,0 +1,38 @@ +. + * + * 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\ExternalAccount\Clients; + +class Google extends OAuth2Abstract +{ + protected function getPingUrl() + { + return 'https://www.googleapis.com/calendar/v3/users/me/calendarList'; + } +} diff --git a/application/Espo/Core/ExternalAccount/Clients/IClient.php b/application/Espo/Core/ExternalAccount/Clients/IClient.php new file mode 100644 index 0000000..d4c39f6 --- /dev/null +++ b/application/Espo/Core/ExternalAccount/Clients/IClient.php @@ -0,0 +1,57 @@ +. + * + * 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\ExternalAccount\Clients; + +interface IClient +{ + /** + * @param string $name + * @return mixed + */ + public function getParam($name); + + /** + * @param string $name + * @param mixed $value + * @return mixed + */ + public function setParam($name, $value); + + /** + * @param array $params + * @return mixed + */ + public function setParams(array $params); + + /** + * @return bool + */ + public function ping(); +} diff --git a/application/Espo/Core/ExternalAccount/Clients/OAuth2Abstract.php b/application/Espo/Core/ExternalAccount/Clients/OAuth2Abstract.php new file mode 100644 index 0000000..473f2b2 --- /dev/null +++ b/application/Espo/Core/ExternalAccount/Clients/OAuth2Abstract.php @@ -0,0 +1,562 @@ +. + * + * 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\ExternalAccount\Clients; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Core\Utils\Json; +use Espo\Core\ExternalAccount\ClientManager; +use Espo\Core\ExternalAccount\OAuth2\Client; +use Espo\Core\Utils\Log; + +use Exception; +use DateTime; +use LogicException; + +abstract class OAuth2Abstract implements IClient +{ + /** @var Client */ + protected $client = null; + /** @var ?ClientManager */ + protected $manager = null; + /** @var Log */ + protected $log; + + /** @var string[] */ + protected $paramList = [ + 'endpoint', + 'tokenEndpoint', + 'clientId', + 'clientSecret', + 'tokenType', + 'accessToken', + 'refreshToken', + 'redirectUri', + 'expiresAt', + ]; + + /** @var ?string */ + protected $endpoint = null; + /** + * @noinspection PhpUnused + * @var ?string + */ + protected $tokenEndpoint = null; + /** + * @noinspection PhpUnused + * @var ?string + */ + protected $redirectUri = null; + /** @var ?string */ + protected $clientId = null; + /** @var ?string */ + protected $clientSecret = null; + /** + * @noinspection PhpUnused + * @var ?string + */ + protected $tokenType = null; + /** @var ?string */ + protected $accessToken = null; + /** @var ?string */ + protected $refreshToken = null; + /** @var ?string */ + protected $expiresAt = null; + + const ACCESS_TOKEN_EXPIRATION_MARGIN = '20 seconds'; + const LOCK_TIMEOUT = 5; + const LOCK_CHECK_STEP = 0.5; + + /** + * @param array $params + */ + public function __construct( + Client $client, + array $params = [], + ?ClientManager $manager = null, + ?Log $log = null + ) { + $this->client = $client; + $this->manager = $manager; + $this->log = $log ?? $GLOBALS['log']; + + $this->setParams($params); + } + + /** + * @param string $name + * @return mixed + */ + public function getParam($name) + { + if (in_array($name, $this->paramList)) { + return $this->$name; + } + + return null; + } + + /** + * @param string $name + * @param mixed $value + * @return void + */ + public function setParam($name, $value) + { + if (in_array($name, $this->paramList)) { + $methodName = 'set' . ucfirst($name); + + if (method_exists($this->client, $methodName)) { + $this->client->$methodName($value); + } + + $this->$name = $value; + } + } + + /** + * @param array $params + * @return void + */ + public function setParams(array $params) + { + foreach ($this->paramList as $name) { + if (array_key_exists($name, $params)) { + $this->setParam($name, $params[$name]); + } + } + } + + /** + * @param array{ + * accessToken: ?string, + * tokenType: ?string, + * expiresAt?: ?string, + * refreshToken?: ?string + * } $data + * @return void + * @throws Error + */ + protected function afterTokenRefreshed(array $data): void + { + $this->manager?->storeAccessToken($this, $data); + } + + /** + * @param array $result + * @return array{ + * accessToken: ?string, + * tokenType: ?string, + * refreshToken: ?string, + * expiresAt: ?string, + * } + */ + protected function getAccessTokenDataFromResponseResult($result): array + { + $data = []; + + $data['accessToken'] = $result['access_token'] ?? null; + $data['tokenType'] = $result['token_type'] ?? null; + $data['expiresAt'] = null; + + if (isset($result['refresh_token']) && $result['refresh_token'] !== $this->refreshToken) { + $data['refreshToken'] = $result['refresh_token']; + } + + if (isset($result['expires_in']) && is_numeric($result['expires_in'])) { + $data['expiresAt'] = (new DateTime()) + ->modify('+' . $result['expires_in'] . ' seconds') + ->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } + + /** + * @var array{ + * accessToken: ?string, + * tokenType: ?string, + * refreshToken: ?string, + * expiresAt: ?string, + * } + */ + return $data; + } + + /** + * @return ?array{ + * accessToken: ?string, + * tokenType: ?string, + * expiresAt: ?string, + * refreshToken: ?string, + * } + */ + public function getAccessTokenFromAuthorizationCode(string $code) + { + $response = $this->client->getAccessToken( + $this->getParam('tokenEndpoint'), + Client::GRANT_TYPE_AUTHORIZATION_CODE, + [ + 'code' => $code, + 'redirect_uri' => $this->getParam('redirectUri'), + ] + ); + + if ($response['code'] != 200) { + $this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response)); + + return null; + } + + if (empty($response['result'])) { + $this->log->debug("OAuth getAccessTokenFromAuthorizationCode; Response: " . Json::encode($response)); + + return null; + } + + /** @var array $result */ + $result = $response['result']; + + $data = $this->getAccessTokenDataFromResponseResult($result); + + $data['refreshToken'] = $result['refresh_token'] ?? null; + + /** + * @var array{ + * accessToken: ?string, + * tokenType: ?string, + * expiresAt: ?string, + * refreshToken: ?string, + * } + */ + return $data; + } + + /** + * @return non-empty-string + */ + protected function getPingUrl() + { + throw new LogicException("Ping is not implemented."); + } + + /** + * @return bool + */ + public function ping() + { + if (empty($this->accessToken) || empty($this->clientId) || empty($this->clientSecret)) { + return false; + } + + $url = $this->getPingUrl(); + + try { + $this->request($url); + + return true; + } catch (Exception) { + return false; + } + } + + /** + * @return void + * @throws Error + */ + public function handleAccessTokenActuality() + { + if (!$this->getParam('expiresAt')) { + return; + } + + try { + $dt = new DateTime($this->getParam('expiresAt')); + } catch (Exception) { + $this->log->debug("Oauth: Bad expires-at parameter stored for client $this->clientId."); + + return; + } + + $dt->modify('-' . $this::ACCESS_TOKEN_EXPIRATION_MARGIN); + + if ($dt->format('U') > (new DateTime())->format('U')) { + return; + } + + $this->log->debug("Oauth: Refreshing expired token for client $this->clientId."); + + if (!$this->isLocked()) { + $this->refreshToken(); + + return; + } + + $until = microtime(true) + $this::LOCK_TIMEOUT; + + while (true) { + usleep($this::LOCK_CHECK_STEP * 1000000); + + if (!$this->isLocked()) { + $this->log->debug("Oauth: Waited until unlocked for client $this->clientId."); + + $this->reFetch(); + + return; + } + + if (microtime(true) > $until) { + $this->log->debug("Oauth: Waited until unlocked but timed out for client $this->clientId."); + + $this->unlock(); + + break; + } + } + + $this->refreshToken(); + } + + /** + * @throws Error + * @phpstan-impure + */ + protected function isLocked(): bool + { + if (!$this->manager) { + return false; + } + + return $this->manager->isClientLocked($this); + } + + /** + * @throws Error + */ + protected function lock(): void + { + if (!$this->manager) { + return; + } + + $this->manager->lockClient($this); + } + + /** + * @throws Error + */ + protected function unlock(): void + { + if (!$this->manager) { + return; + } + + $this->manager->unlockClient($this); + } + + /** + * @throws Error + */ + private function controlRefreshTokenAttempts(): void + { + if (!$this->manager) { + return; + } + + $this->manager->controlRefreshTokenAttempts($this); + } + + /** + * @throws Error + */ + protected function reFetch(): void + { + if (!$this->manager) { + return; + } + + $this->manager->reFetchClient($this); + } + + /** + * @param non-empty-string $url + * @param array|string|null $params + * @param non-empty-string $httpMethod + * @param ?string $contentType + * @param bool $allowRenew + * @return mixed + * @throws Error + */ + public function request( + $url, + $params = null, + $httpMethod = Client::HTTP_METHOD_GET, + $contentType = null, + $allowRenew = true + ) { + + $this->handleAccessTokenActuality(); + + $httpHeaders = []; + + if (!empty($contentType)) { + $httpHeaders['Content-Type'] = $contentType; + + switch ($contentType) { + case Client::CONTENT_TYPE_APPLICATION_JSON: + case Client::CONTENT_TYPE_MULTIPART_FORM_DATA: + if (is_string($params)) { + $httpHeaders['Content-Length'] = (string) strlen($params); + } + + break; + } + } + + try { + $response = $this->client->request($url, $params, $httpMethod, $httpHeaders); + } catch (Exception $e) { + throw new Error($e->getMessage(), 0, $e); + } + + $code = null; + + if (!empty($response['code'])) { + $code = $response['code']; + } + + $result = $response['result']; + + if ($code >= 200 && $code < 300) { + return $result; + } + + $handledData = $this->handleErrorResponse($response); + + if ($allowRenew && is_array($handledData)) { + if ($handledData['action'] === 'refreshToken') { + if ($this->refreshToken()) { + return $this->request($url, $params, $httpMethod, $contentType, false); + } + } else if ($handledData['action'] === 'renew') { + return $this->request($url, $params, $httpMethod, $contentType, false); + } + } + + $reasonPart = ''; + + if ( + is_array($result) && + isset($result['error']['message']) + ) { + $reasonPart = '; Reason: ' . $result['error']['message']; + } + + $this->log->debug("OAuth response: " . Json::encode($response)); + + throw new Error("Oauth: Error after requesting $httpMethod $url$reasonPart.", (int) $code); + } + + /** + * @return bool + * @throws Error + */ + protected function refreshToken() + { + if (empty($this->refreshToken)) { + throw new Error( + "Oauth: Could not refresh token for client $this->clientId, because refreshToken is empty."); + } + + $this->lock(); + + assert(is_string($this->refreshToken)); + + try { + $response = $this->client->getAccessToken( + $this->getParam('tokenEndpoint'), + Client::GRANT_TYPE_REFRESH_TOKEN, + ['refresh_token' => $this->refreshToken] + ); + } catch (Exception $e) { + $this->unlock(); + $this->controlRefreshTokenAttempts(); + + throw new Error("Oauth: Error while refreshing token: " . $e->getMessage()); + } + + if ($response['code'] == 200) { + if (is_array($response['result']) && !empty($response['result']['access_token'])) { + $data = $this->getAccessTokenDataFromResponseResult($response['result']); + + $this->setParams($data); + $this->afterTokenRefreshed($data); + $this->unlock(); + + return true; + } + } + + $this->unlock(); + $this->controlRefreshTokenAttempts(); + + $this->log->error("Oauth: Refreshing token failed for client $this->clientId: " . json_encode($response)); + + return false; + } + + /** + * @param array $response + * @return ?array{ + * action: string, + * } + */ + protected function handleErrorResponse($response) + { + if ($response['code'] == 401 && !empty($response['result'])) { + if (str_contains($response['header'], 'error=invalid_token')) { + return ['action' => 'refreshToken']; + } + + return ['action' => 'renew']; + } + + if ($response['code'] == 400) { + $result = $response['result'] ?? null; + + if (is_array($result)) { + $error = $result['error'] ?? null; + + if ($error === 'invalid_token') { + return ['action' => 'refreshToken']; + } + } + } + + return null; + } +} diff --git a/application/Espo/Core/ExternalAccount/OAuth2/Client.php b/application/Espo/Core/ExternalAccount/OAuth2/Client.php new file mode 100644 index 0000000..2b54c60 --- /dev/null +++ b/application/Espo/Core/ExternalAccount/OAuth2/Client.php @@ -0,0 +1,452 @@ +. + * + * 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\ExternalAccount\OAuth2; + +use Exception; +use RuntimeException; +use LogicException; + +class Client +{ + const AUTH_TYPE_URI = 0; + const AUTH_TYPE_AUTHORIZATION_BASIC = 1; + const AUTH_TYPE_FORM = 2; + + const TOKEN_TYPE_URI = 'Uri'; + const TOKEN_TYPE_BEARER = 'Bearer'; + const TOKEN_TYPE_OAUTH = 'OAuth'; + + /** + * @noinspection PhpUnused + */ + const CONTENT_TYPE_APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded'; + const CONTENT_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data'; + const CONTENT_TYPE_APPLICATION_JSON = 'application/json'; + + const HTTP_METHOD_GET = 'GET'; + const HTTP_METHOD_POST = 'POST'; + const HTTP_METHOD_PUT = 'PUT'; + + const HTTP_METHOD_DELETE = 'DELETE'; + const HTTP_METHOD_HEAD = 'HEAD'; + const HTTP_METHOD_PATCH = 'PATCH'; + + const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code'; + const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token'; + /** @noinspection PhpUnused */ + const GRANT_TYPE_PASSWORD = 'password'; + /** @noinspection PhpUnused */ + const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials'; + + private const REFRESH_TOKEN_TIMEOUT = 10; + private const DEFAULT_TIMEOUT = 3600 * 2; + + /** @var ?string */ + protected $clientId = null; + /** @var ?string */ + protected $clientSecret = null; + /** @var ?string */ + protected $accessToken = null; + /** @var ?string */ + protected $expiresAt = null; + /** @var int */ + protected $authType = self::AUTH_TYPE_URI; + /** @var string */ + protected $tokenType = self::TOKEN_TYPE_URI; + /** @var ?string */ + protected $accessTokenSecret = null; + /** @var string */ + protected $accessTokenParamName = 'access_token'; + /** @var ?string */ + protected $certificateFile = null; + /** @var array */ + protected $curlOptions = []; + + public function __construct() + { + if (!extension_loaded('curl')) { + throw new RuntimeException('CURL extension not found.'); + } + } + + /** + * @param string $clientId + * @return void + * @noinspection PhpUnused + */ + public function setClientId($clientId) + { + $this->clientId = $clientId; + } + + /** + * @param ?string $clientSecret + * @return void + * @noinspection PhpUnused + */ + public function setClientSecret($clientSecret) + { + $this->clientSecret = $clientSecret; + } + + /** + * @param ?string $accessToken + * @return void + * @noinspection PhpUnused + */ + public function setAccessToken($accessToken) + { + $this->accessToken = $accessToken; + } + + /** + * @param int $authType + * @return void + * @noinspection PhpUnused + */ + public function setAuthType($authType) + { + $this->authType = $authType; + } + + /** + * @param string $certificateFile + * @return void + * @noinspection PhpUnused + */ + public function setCertificateFile($certificateFile) + { + $this->certificateFile = $certificateFile; + } + + /** + * @param string $option + * @param mixed $value + * @return void + * @noinspection PhpUnused + */ + public function setCurlOption($option, $value) + { + $this->curlOptions[$option] = $value; + } + + /** + * @param array $options + * @return void + * @noinspection PhpUnused + */ + public function setCurlOptions($options) + { + $this->curlOptions = array_merge($this->curlOptions, $options); + } + + /** + * @param string $tokenType + * @return void + * @noinspection PhpUnused + */ + public function setTokenType($tokenType) + { + $lower = strtolower($tokenType); + + if ($lower === 'bearer') { + $tokenType = self::TOKEN_TYPE_BEARER; + } + + if ($lower === 'uri') { + $tokenType = self::TOKEN_TYPE_URI; + } + + if ($lower === 'oauth') { + $tokenType = self::TOKEN_TYPE_OAUTH; + } + + $this->tokenType = $tokenType; + } + + /** + * @param ?string $value + * @return void + * @noinspection PhpUnused + */ + public function setExpiresAt($value) + { + $this->expiresAt = $value; + } + + /** + * @param ?string $accessTokenSecret + * @return void + * @noinspection PhpUnused + */ + public function setAccessTokenSecret($accessTokenSecret) + { + $this->accessTokenSecret = $accessTokenSecret; + } + + /** + * @param non-empty-string $url + * @param array|string|null $params + * @param non-empty-string $httpMethod + * @param array $httpHeaders + * @return array{ + * result: array|string, + * code: int, + * contentType: string|false, + * header: string, + * } + * @throws Exception + */ + public function request( + $url, + $params = null, + $httpMethod = self::HTTP_METHOD_GET, + array $httpHeaders = [] + ) { + + if ($this->accessToken) { + switch ($this->tokenType) { + case self::TOKEN_TYPE_URI: + if (is_string($params) || $params === null) { + $params = []; + } + + $params[$this->accessTokenParamName] = $this->accessToken; + + break; + + case self::TOKEN_TYPE_BEARER: + $httpHeaders['Authorization'] = 'Bearer ' . $this->accessToken; + + break; + + case self::TOKEN_TYPE_OAUTH: + $httpHeaders['Authorization'] = 'OAuth ' . $this->accessToken; + + break; + + default: + throw new Exception('Unknown access token type.'); + } + } + + return $this->execute($url, $params, $httpMethod, $httpHeaders); + } + + /** + * @param non-empty-string $url + * @param array|string|null $params + * @param non-empty-string $httpMethod + * @param array $httpHeaders + * @return array{ + * result: array|string, + * code: int, + * contentType: string|false, + * header: string, + * } + * @throws Exception + */ + private function execute( + $url, + $params, + $httpMethod, + array $httpHeaders = [], + ?int $timeout = null + ) { + + $curlOptions = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_CUSTOMREQUEST => $httpMethod, + CURLOPT_TIMEOUT => $timeout ?: self::DEFAULT_TIMEOUT, + ]; + + switch ($httpMethod) { + /** @noinspection PhpMissingBreakStatementInspection */ + case self::HTTP_METHOD_POST: + $curlOptions[CURLOPT_POST] = true; + + case self::HTTP_METHOD_PUT: + case self::HTTP_METHOD_PATCH: + if (is_array($params)) { + $postFields = http_build_query($params, '', '&'); + } else { + $postFields = $params; + } + + if ($postFields !== '' && $postFields !== null) { + $curlOptions[CURLOPT_POSTFIELDS] = $postFields; + } + + break; + + /** @noinspection PhpMissingBreakStatementInspection */ + case self::HTTP_METHOD_HEAD: + $curlOptions[CURLOPT_NOBODY] = true; + + case self::HTTP_METHOD_DELETE: + case self::HTTP_METHOD_GET: + + if (!str_contains($url, '?')) { + $url .= '?'; + } + + if (is_array($params)) { + $url .= http_build_query($params, '', '&'); + } + + break; + + default: + break; + } + + $curlOptions[CURLOPT_URL] = $url; + + $curlOptHttpHeader = []; + + foreach ($httpHeaders as $key => $value) { + if (is_int($key)) { + $curlOptHttpHeader[] = $value; + + continue; + } + + $curlOptHttpHeader[] = "$key: $value"; + } + + $curlOptions[CURLOPT_HTTPHEADER] = $curlOptHttpHeader; + + $ch = curl_init(); + + curl_setopt_array($ch, $curlOptions); + + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + + if (!empty($this->certificateFile)) { + curl_setopt($ch, CURLOPT_CAINFO, $this->certificateFile); + } + + if (!empty($this->curlOptions)) { + curl_setopt_array($ch, $this->curlOptions); + } + + /** @var string|false $response */ + $response = curl_exec($ch); + + if ($response === false) { + $message = "Curl failure."; + + $curlError = curl_error($ch); + + if ($curlError) { + $message .= " " . $curlError; + } + + throw new Exception($message); + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + + $responseHeader = substr($response, 0, $headerSize); + $responseBody = substr($response, $headerSize); + + if ($curlError = curl_error($ch)) { + throw new Exception($curlError); + } + + $resultArray = json_decode($responseBody, true); + + /** @var array|string $result */ + $result = ($resultArray !== null) ? + $resultArray : + $responseBody; + + return [ + 'result' => $result, + 'code' => intval($httpCode), + 'contentType' => $contentType, + 'header' => $responseHeader, + ]; + } + + /** + * @param non-empty-string $url + * @param string $grantType + * @param array{ + * client_id?: string, + * client_secret?: string, + * redirect_uri?: string, + * code?: string, + * refresh_token?: string, + * } $params + * @return array{ + * result: array|string, + * code: int, + * contentType: string|false, + * header: string, + * } + */ + public function getAccessToken($url, $grantType, array $params) + { + $params['grant_type'] = $grantType; + + $httpHeaders = []; + + switch ($this->authType) { + case self::AUTH_TYPE_URI: + case self::AUTH_TYPE_FORM: + $params['client_id'] = $this->clientId; + $params['client_secret'] = $this->clientSecret; + + break; + + case self::AUTH_TYPE_AUTHORIZATION_BASIC: + $params['client_id'] = $this->clientId; + + $httpHeaders['Authorization'] = 'Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret); + + break; + + default: + throw new LogicException("Bad auth type."); + } + + try { + return $this->execute($url, $params, self::HTTP_METHOD_POST, $httpHeaders, self::REFRESH_TOKEN_TIMEOUT); + } catch (Exception $e) { + throw new RuntimeException(previous: $e); + } + } +} diff --git a/application/Espo/Core/Field/Address.php b/application/Espo/Core/Field/Address.php new file mode 100644 index 0000000..99afb03 --- /dev/null +++ b/application/Espo/Core/Field/Address.php @@ -0,0 +1,197 @@ +. + * + * 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\Field; + +use Espo\Core\Field\Address\AddressBuilder; + +/** + * An address value object. Immutable. + */ +class Address +{ + public function __construct( + private ?string $country = null, + private ?string $state = null, + private ?string $city = null, + private ?string $street = null, + private ?string $postalCode = null + ) {} + + /** + * Whether has a street. + */ + public function hasStreet(): bool + { + return $this->street !== null; + } + + /** + * Whether has a city. + */ + public function hasCity(): bool + { + return $this->city !== null; + } + + /** + * Whether has a country. + */ + public function hasCountry(): bool + { + return $this->country !== null; + } + + /** + * Whether has a state. + */ + public function hasState(): bool + { + return $this->state !== null; + } + + /** + * Whether has a postal code. + */ + public function hasPostalCode(): bool + { + return $this->postalCode !== null; + } + + /** + * Get a street. + */ + public function getStreet(): ?string + { + return $this->street; + } + + /** + * Get a city. + */ + public function getCity(): ?string + { + return $this->city; + } + + /** + * Get a country. + */ + public function getCountry(): ?string + { + return $this->country; + } + + /** + * Get a state. + */ + public function getState(): ?string + { + return $this->state; + } + + /** + * Get a postal code. + */ + public function getPostalCode(): ?string + { + return $this->postalCode; + } + + /** + * Clone with a street. + */ + public function withStreet(?string $street): self + { + return self::createBuilder() + ->clone($this) + ->setStreet($street) + ->build(); + } + + /** + * Clone with a city. + */ + public function withCity(?string $city): self + { + return self::createBuilder() + ->clone($this) + ->setCity($city) + ->build(); + } + + /** + * Clone with a country. + */ + public function withCountry(?string $country): self + { + return self::createBuilder() + ->clone($this) + ->setCountry($country) + ->build(); + } + + /** + * Clone with a state. + */ + public function withState(?string $state): self + { + return self::createBuilder() + ->clone($this) + ->setState($state) + ->build(); + } + + /** + * Clone with a postal code. + */ + public function withPostalCode(?string $postalCode): self + { + return self::createBuilder() + ->clone($this) + ->setPostalCode($postalCode) + ->build(); + } + + /** + * Create an empty address. + */ + public static function create(): self + { + return new self(); + } + + /** + * Create a builder. + */ + public static function createBuilder(): AddressBuilder + { + return new AddressBuilder(); + } +} diff --git a/application/Espo/Core/Field/Address/AddressAttributeExtractor.php b/application/Espo/Core/Field/Address/AddressAttributeExtractor.php new file mode 100644 index 0000000..a8f82d8 --- /dev/null +++ b/application/Espo/Core/Field/Address/AddressAttributeExtractor.php @@ -0,0 +1,69 @@ +. + * + * 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\Field\Address; + +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\Address; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor
+ */ +class AddressAttributeExtractor implements AttributeExtractor +{ + public function extract(object $value, string $field): stdClass + { + if (!$value instanceof Address) { + throw new InvalidArgumentException(); + } + + return (object) [ + $field . 'Street' => $value->getStreet(), + $field . 'City' => $value->getCity(), + $field . 'Country' => $value->getCountry(), + $field . 'State' => $value->getState(), + $field . 'PostalCode' => $value->getPostalCode(), + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field . 'Street' => null, + $field . 'City' => null, + $field . 'Country' => null, + $field . 'State' => null, + $field . 'PostalCode' => null, + ]; + } +} diff --git a/application/Espo/Core/Field/Address/AddressBuilder.php b/application/Espo/Core/Field/Address/AddressBuilder.php new file mode 100644 index 0000000..a376fd6 --- /dev/null +++ b/application/Espo/Core/Field/Address/AddressBuilder.php @@ -0,0 +1,105 @@ +. + * + * 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\Field\Address; + +use Espo\Core\Field\Address; + +/** + * An address value builder. + */ +class AddressBuilder +{ + private ?string $street; + + private ?string $city; + + private ?string $country; + + private ?string $state; + + private ?string $postalCode; + + public function clone(Address $address): self + { + $this->setStreet($address->getStreet()); + $this->setCity($address->getCity()); + $this->setCountry($address->getCountry()); + $this->setState($address->getState()); + $this->setPostalCode($address->getPostalCode()); + + return $this; + } + + public function setStreet(?string $street): self + { + $this->street = $street; + + return $this; + } + + public function setCity(?string $city): self + { + $this->city = $city; + + return $this; + } + + public function setCountry(?string $country): self + { + $this->country = $country; + + return $this; + } + + public function setState(?string $state): self + { + $this->state = $state; + + return $this; + } + + public function setPostalCode(?string $postalCode): self + { + $this->postalCode = $postalCode; + + return $this; + } + + public function build(): Address + { + return new Address( + $this->country, + $this->state, + $this->city, + $this->street, + $this->postalCode + ); + } +} diff --git a/application/Espo/Core/Field/Address/AddressFactory.php b/application/Espo/Core/Field/Address/AddressFactory.php new file mode 100644 index 0000000..690737e --- /dev/null +++ b/application/Espo/Core/Field/Address/AddressFactory.php @@ -0,0 +1,54 @@ +. + * + * 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\Field\Address; + +use Espo\ORM\Entity; +use Espo\ORM\Value\ValueFactory; + +use Espo\Core\Field\Address; + +class AddressFactory implements ValueFactory +{ + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + return true; + } + + public function createFromEntity(Entity $entity, string $field): Address + { + return (new AddressBuilder()) + ->setStreet($entity->get($field . 'Street')) + ->setCity($entity->get($field . 'City')) + ->setCountry($entity->get($field . 'Country')) + ->setState($entity->get($field . 'State')) + ->setPostalCode($entity->get($field . 'PostalCode')) + ->build(); + } +} diff --git a/application/Espo/Core/Field/Address/AddressFormatter.php b/application/Espo/Core/Field/Address/AddressFormatter.php new file mode 100644 index 0000000..5f660f3 --- /dev/null +++ b/application/Espo/Core/Field/Address/AddressFormatter.php @@ -0,0 +1,40 @@ +. + * + * 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\Field\Address; + +use Espo\Core\Field\Address; + +/** + * An address formatter. + */ +interface AddressFormatter +{ + public function format(Address $address): string; +} diff --git a/application/Espo/Core/Field/Address/AddressFormatterFactory.php b/application/Espo/Core/Field/Address/AddressFormatterFactory.php new file mode 100644 index 0000000..e2f1d08 --- /dev/null +++ b/application/Espo/Core/Field/Address/AddressFormatterFactory.php @@ -0,0 +1,63 @@ +. + * + * 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\Field\Address; + +use RuntimeException; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; + +class AddressFormatterFactory +{ + public function __construct( + private AddressFormatterMetadataProvider $metadataProvider, + private InjectableFactory $injectableFactory, + private Config $config + ) {} + + public function create(int $format): AddressFormatter + { + /** @var ?class-string $className */ + $className = $this->metadataProvider->getFormatterClassName($format); + + if (!$className) { + throw new RuntimeException("Unknown address format '{$format}'."); + } + + return $this->injectableFactory->create($className); + } + + public function createDefault(): AddressFormatter + { + $format = $this->config->get('addressFormat') ?? 1; + + return $this->create($format); + } +} diff --git a/application/Espo/Core/Field/Address/AddressFormatterMetadataProvider.php b/application/Espo/Core/Field/Address/AddressFormatterMetadataProvider.php new file mode 100644 index 0000000..49c8d2d --- /dev/null +++ b/application/Espo/Core/Field/Address/AddressFormatterMetadataProvider.php @@ -0,0 +1,49 @@ +. + * + * 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\Field\Address; + +use Espo\Core\Utils\Metadata; + +class AddressFormatterMetadataProvider +{ + private $metadata; + + public function __construct(Metadata $metadata) + { + $this->metadata = $metadata; + } + + public function getFormatterClassName(int $format): ?string + { + return $this->metadata->get([ + 'app', 'addressFormats', strval($format), 'formatterClassName', + ]); + } +} diff --git a/application/Espo/Core/Field/Currency.php b/application/Espo/Core/Field/Currency.php new file mode 100644 index 0000000..81da178 --- /dev/null +++ b/application/Espo/Core/Field/Currency.php @@ -0,0 +1,222 @@ +. + * + * 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\Field; + +use Espo\Core\Currency\CalculatorUtil; + +use InvalidArgumentException; + +/** + * A currency value object. Immutable. + */ +class Currency +{ + private const int DEFAULT_SCALE = 14; + + /** @var numeric-string */ + private string $amount; + private string $code; + + /** + * @param numeric-string|float|int $amount An amount. + * @param string $code A currency code. + * @throws InvalidArgumentException + */ + public function __construct($amount, string $code) + { + if (!is_string($amount) && !is_float($amount) && !is_int($amount)) { + throw new InvalidArgumentException(); + } + + if (strlen($code) !== 3) { + throw new InvalidArgumentException("Bad currency code."); + } + + if (is_int($amount)) { + $amount = (string) $amount; + } else if (is_float($amount)) { + $amount = self::floatToString($amount); + } + + $this->amount = $amount; + $this->code = $code; + } + + /** + * Get an amount as string. + * + * @return numeric-string + */ + public function getAmountAsString(): string + { + return $this->amount; + } + + /** + * Get an amount. + */ + public function getAmount(): float + { + return (float) $this->amount; + } + + /** + * Get a currency code. + */ + public function getCode(): string + { + return $this->code; + } + + /** + * Add a currency value. + * + * @throws InvalidArgumentException If currency codes are different. + */ + public function add(self $value): self + { + if ($this->getCode() !== $value->getCode()) { + throw new InvalidArgumentException("Can't add a currency value with a different code."); + } + + $amount = CalculatorUtil::add( + $this->getAmountAsString(), + $value->getAmountAsString() + ); + + return new self($amount, $this->getCode()); + } + + /** + * Subtract a currency value. + * + * @throws InvalidArgumentException If currency codes are different. + */ + public function subtract(self $value): self + { + if ($this->getCode() !== $value->getCode()) { + throw new InvalidArgumentException("Can't subtract a currency value with a different code."); + } + + $amount = CalculatorUtil::subtract( + $this->getAmountAsString(), + $value->getAmountAsString() + ); + + return new self($amount, $this->getCode()); + } + + /** + * Multiply by a multiplier. + * + * @param float|int|numeric-string $multiplier + */ + public function multiply(float|int|string $multiplier): self + { + $multiplier = is_float($multiplier) ? self::floatToString($multiplier) : (string) $multiplier; + + $amount = CalculatorUtil::multiply($this->getAmountAsString(), $multiplier); + + return new self($amount, $this->getCode()); + } + + /** + * Divide by a divider. + * + * @param float|int|numeric-string $divider + */ + public function divide(float|int|string $divider): self + { + $divider = is_float($divider) ? self::floatToString($divider) : (string) $divider; + + $amount = CalculatorUtil::divide($this->getAmountAsString(), $divider); + + return new self($amount, $this->getCode()); + } + + /** + * Round with a precision. + */ + public function round(int $precision = 0): self + { + $amount = CalculatorUtil::round($this->getAmountAsString(), $precision); + + return new self($amount, $this->getCode()); + } + + /** + * Compare with another currency value. Returns: + * - `1` if greater than the value; + * - `0` if equal to the value; + * - `-1` if less than the value. + * + * @throws InvalidArgumentException If currency codes are different. + */ + public function compare(self $value): int + { + if ($this->getCode() !== $value->getCode()) { + throw new InvalidArgumentException("Can't compare currencies with different codes."); + } + + return CalculatorUtil::compare( + $this->getAmountAsString(), + $value->getAmountAsString() + ); + } + + /** + * Check whether the value is negative. + */ + public function isNegative(): bool + { + return $this->compare(self::create(0.0, $this->code)) === -1; + } + + /** + * Create from an amount and code. + * + * @param numeric-string|float|int $amount An amount. + * @param string $code A currency code. + * @throws InvalidArgumentException + */ + public static function create($amount, string $code): self + { + return new self($amount, $code); + } + + /** + * @return numeric-string + */ + private static function floatToString(float $amount): string + { + /** @var numeric-string */ + return rtrim(rtrim(sprintf('%.' . self::DEFAULT_SCALE . 'f', $amount), '0'), '.'); + } +} diff --git a/application/Espo/Core/Field/Currency/CurrencyAttributeExtractor.php b/application/Espo/Core/Field/Currency/CurrencyAttributeExtractor.php new file mode 100644 index 0000000..85e7475 --- /dev/null +++ b/application/Espo/Core/Field/Currency/CurrencyAttributeExtractor.php @@ -0,0 +1,79 @@ +. + * + * 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\Field\Currency; + +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\Currency; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor + */ +class CurrencyAttributeExtractor implements AttributeExtractor +{ + public function __construct( + private string $entityType, + private Defs $ormDefs + ) {} + + public function extract(object $value, string $field): stdClass + { + if (!$value instanceof Currency) { + throw new InvalidArgumentException(); + } + + $useString = $this->ormDefs + ->getEntity($this->entityType) + ->getField($field) + ->getType() === Entity::VARCHAR; + + $amount = $useString ? + $value->getAmountAsString() : + $value->getAmount(); + + return (object) [ + $field => $amount, + $field . 'Currency' => $value->getCode(), + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field => null, + $field . 'Currency' => null, + ]; + } +} diff --git a/application/Espo/Core/Field/Currency/CurrencyFactory.php b/application/Espo/Core/Field/Currency/CurrencyFactory.php new file mode 100644 index 0000000..2e9208a --- /dev/null +++ b/application/Espo/Core/Field/Currency/CurrencyFactory.php @@ -0,0 +1,55 @@ +. + * + * 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\Field\Currency; + +use Espo\ORM\Entity; +use Espo\ORM\Value\ValueFactory; +use Espo\Core\Field\Currency; +use RuntimeException; + +class CurrencyFactory implements ValueFactory +{ + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + return $entity->get($field) !== null && $entity->get($field . 'Currency') !== null; + } + + public function createFromEntity(Entity $entity, string $field): Currency + { + if (!$this->isCreatableFromEntity($entity, $field)) { + throw new RuntimeException(); + } + + return new Currency( + $entity->get($field), + $entity->get($field . 'Currency') + ); + } +} diff --git a/application/Espo/Core/Field/Date.php b/application/Espo/Core/Field/Date.php new file mode 100644 index 0000000..31572e1 --- /dev/null +++ b/application/Espo/Core/Field/Date.php @@ -0,0 +1,310 @@ +. + * + * 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\Field; + +use Espo\Core\Field\DateTime\DateTimeable; + +use DateTimeImmutable; +use DateTimeInterface; +use DateInterval; +use DateTimeZone; +use InvalidArgumentException; +use RuntimeException; + +/** + * A date value object. Immutable. + */ +class Date implements DateTimeable +{ + private string $value; + private DateTimeImmutable $dateTime; + + private const string SYSTEM_FORMAT = 'Y-m-d'; + + /** + * @throws InvalidArgumentException + */ + public function __construct(string $value) + { + if (!$value) { + throw new InvalidArgumentException("Empty value."); + } + + $this->value = $value; + + $parsedValue = DateTimeImmutable::createFromFormat( + '!' . self::SYSTEM_FORMAT, + $value, + new DateTimeZone('UTC') + ); + + if ($parsedValue === false) { + throw new InvalidArgumentException("Bad value."); + } + + $this->dateTime = $parsedValue; + + if ($this->value !== $this->dateTime->format(self::SYSTEM_FORMAT)) { + throw new InvalidArgumentException("Bad value."); + } + } + + /** + * Get a string value in `Y-m-d` format. + */ + public function toString(): string + { + return $this->value; + } + + /** + * Get DateTimeImmutable. + */ + public function toDateTime(): DateTimeImmutable + { + return $this->dateTime; + } + + /** + * Get a timestamp. + */ + public function toTimestamp(): int + { + return $this->dateTime->getTimestamp(); + } + + /** + * Get a year. + */ + public function getYear(): int + { + return (int) $this->dateTime->format('Y'); + } + + /** + * Get a month. + */ + public function getMonth(): int + { + return (int) $this->dateTime->format('n'); + } + + /** + * Get a day (of month). + */ + public function getDay(): int + { + return (int) $this->dateTime->format('j'); + } + + /** + * Get a day of week. 0 (for Sunday) through 6 (for Saturday). + */ + public function getDayOfWeek(): int + { + return (int) $this->dateTime->format('w'); + } + + /** + * Clones and modifies. + */ + public function modify(string $modifier): self + { + /** @var DateTimeImmutable|false $dateTime */ + $dateTime = $this->dateTime->modify($modifier); + + if (!$dateTime) { + throw new RuntimeException("Modify failure."); + } + + return self::fromDateTime($dateTime); + } + + /** + * Clones and adds an interval. + */ + public function add(DateInterval $interval): self + { + $dateTime = $this->dateTime->add($interval); + + return self::fromDateTime($dateTime); + } + + /** + * Clones and subtracts an interval. + */ + public function subtract(DateInterval $interval): self + { + $dateTime = $this->dateTime->sub($interval); + + return self::fromDateTime($dateTime); + } + + /** + * Add days. + */ + public function addDays(int $days): self + { + $modifier = ($days >= 0 ? '+' : '-') . abs($days) . ' days'; + + return $this->modify($modifier); + } + + /** + * Add months. + */ + public function addMonths(int $months): self + { + $modifier = ($months >= 0 ? '+' : '-') . abs($months) . ' months'; + + return $this->modify($modifier); + } + + /** + * Add years. + */ + public function addYears(int $years): self + { + $modifier = ($years >= 0 ? '+' : '-') . abs($years) . ' years'; + + return $this->modify($modifier); + } + + /** + * A difference between another object (date or date-time) and self. + */ + public function diff(DateTimeable $other): DateInterval + { + return $this->toDateTime()->diff($other->toDateTime()); + } + + /** + * Whether greater than a given value. + */ + public function isGreaterThan(DateTimeable $other): bool + { + return $this->toDateTime() > $other->toDateTime(); + } + + /** + * Whether less than a given value. + */ + public function isLessThan(DateTimeable $other): bool + { + return $this->toDateTime() < $other->toDateTime(); + } + + /** + * Whether equals to a given value. + */ + public function isEqualTo(DateTimeable $other): bool + { + return $this->toDateTime() == $other->toDateTime(); + } + + /** + * Whether less than or equals to a given value. + * @since 9.0.0 + */ + public function isLessThanOrEqualTo(DateTimeable $other): bool + { + return $this->isLessThan($other) || $this->isEqualTo($other); + } + + /** + * Whether greater than or equals to a given value. + * @since 9.0.0 + */ + public function isGreaterThanOrEqualTo(DateTimeable $other): bool + { + return $this->isGreaterThan($other) || $this->isEqualTo($other); + } + + /** + * Create a today. + */ + public static function createToday(?DateTimeZone $timezone = null): self + { + $now = new DateTimeImmutable(); + + if ($timezone) { + $now = $now->setTimezone($timezone); + } + + return self::fromDateTime($now); + } + + /** + * Create from a string with a date in `Y-m-d` format. + * + * @throws InvalidArgumentException + */ + public static function fromString(string $value): self + { + return new self($value); + } + + /** + * Create from a DateTimeInterface. + */ + public static function fromDateTime(DateTimeInterface $dateTime): self + { + $value = $dateTime->format(self::SYSTEM_FORMAT); + + return new self($value); + } + + /** + * @deprecated As of v8.1. Use `toString` instead. + * @todo Remove in v11.0. + */ + public function getString(): string + { + return $this->toString(); + } + + /** + * @deprecated As of v8.1. Use `toDateTime` instead. + * @todo Remove in v11.0. + */ + public function getDateTime(): DateTimeImmutable + { + return $this->toDateTime(); + } + + /** + * @deprecated As of v8.1. Use `toTimestamp` instead. + * @todo Remove in v11.0. + */ + public function getTimestamp(): int + { + return $this->toTimestamp(); + } +} diff --git a/application/Espo/Core/Field/Date/DateAttributeExtractor.php b/application/Espo/Core/Field/Date/DateAttributeExtractor.php new file mode 100644 index 0000000..fbf94d0 --- /dev/null +++ b/application/Espo/Core/Field/Date/DateAttributeExtractor.php @@ -0,0 +1,64 @@ +. + * + * 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\Field\Date; + +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\Date; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor + */ +class DateAttributeExtractor implements AttributeExtractor +{ + /** + * @param Date $value + */ + public function extract(object $value, string $field): stdClass + { + if (!$value instanceof Date) { + throw new InvalidArgumentException(); + } + + return (object) [ + $field => $value->toString(), + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field => null, + ]; + } +} diff --git a/application/Espo/Core/Field/Date/DateFactory.php b/application/Espo/Core/Field/Date/DateFactory.php new file mode 100644 index 0000000..c523029 --- /dev/null +++ b/application/Espo/Core/Field/Date/DateFactory.php @@ -0,0 +1,56 @@ +. + * + * 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\Field\Date; + +use Espo\ORM\Entity; +use Espo\ORM\Value\ValueFactory; + +use Espo\Core\Field\Date; + +use RuntimeException; + +class DateFactory implements ValueFactory +{ + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + return $entity->get($field) !== null; + } + + public function createFromEntity(Entity $entity, string $field): Date + { + if (!$this->isCreatableFromEntity($entity, $field)) { + throw new RuntimeException(); + } + + return new Date( + $entity->get($field) + ); + } +} diff --git a/application/Espo/Core/Field/DateTime.php b/application/Espo/Core/Field/DateTime.php new file mode 100644 index 0000000..2fd40eb --- /dev/null +++ b/application/Espo/Core/Field/DateTime.php @@ -0,0 +1,417 @@ +. + * + * 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\Field; + +use Espo\Core\Field\DateTime\DateTimeable; + +use DateTimeImmutable; +use DateTimeInterface; +use DateInterval; +use DateTimeZone; +use RuntimeException; +use InvalidArgumentException; + +/** + * A date-time value object. Immutable. + */ +class DateTime implements DateTimeable +{ + private string $value; + private DateTimeImmutable $dateTime; + + private const string SYSTEM_FORMAT = 'Y-m-d H:i:s'; + + /** + * @throws InvalidArgumentException + */ + public function __construct(string $value) + { + if (!$value) { + throw new InvalidArgumentException("Empty value."); + } + + $normValue = strlen($value) === 16 ? $value . ':00' : $value; + + $this->value = $normValue; + + $parsedValue = DateTimeImmutable::createFromFormat( + self::SYSTEM_FORMAT, + $normValue, + new DateTimeZone('UTC') + ); + + if ($parsedValue === false) { + throw new InvalidArgumentException("Bad value."); + } + + $this->dateTime = $parsedValue; + + if ($this->value !== $this->dateTime->format(self::SYSTEM_FORMAT)) { + throw new InvalidArgumentException("Bad value."); + } + } + + /** + * Get a string value in `Y-m-d H:i:s` format. + */ + public function toString(): string + { + return $this->value; + } + + /** + * Get DateTimeImmutable. + */ + public function toDateTime(): DateTimeImmutable + { + return $this->dateTime; + } + + /** + * Get a timestamp. + */ + public function toTimestamp(): int + { + return $this->dateTime->getTimestamp(); + } + + /** + * Get a year. + */ + public function getYear(): int + { + return (int) $this->dateTime->format('Y'); + } + + /** + * Get a month. + */ + public function getMonth(): int + { + return (int) $this->dateTime->format('n'); + } + + /** + * Get a day (of month). + */ + public function getDay(): int + { + return (int) $this->dateTime->format('j'); + } + + /** + * Get a day of week. 0 (for Sunday) through 6 (for Saturday). + */ + public function getDayOfWeek(): int + { + return (int) $this->dateTime->format('w'); + } + + /** + * Get a hour. + */ + public function getHour(): int + { + return (int) $this->dateTime->format('G'); + } + + /** + * Get a minute. + */ + public function getMinute(): int + { + return (int) $this->dateTime->format('i'); + } + + /** + * Get a second. + */ + public function getSecond(): int + { + return (int) $this->dateTime->format('s'); + } + + /** + * Get a timezone. + */ + public function getTimezone(): DateTimeZone + { + return $this->dateTime->getTimezone(); + } + + /** + * Clones and modifies. + */ + public function modify(string $modifier): self + { + /** + * @var DateTimeImmutable|false $dateTime + */ + $dateTime = $this->dateTime->modify($modifier); + + if (!$dateTime) { + throw new RuntimeException("Modify failure."); + } + + return self::fromDateTime($dateTime); + } + + /** + * Clones and adds an interval. + */ + public function add(DateInterval $interval): self + { + $dateTime = $this->dateTime->add($interval); + + return self::fromDateTime($dateTime); + } + + /** + * Clones and subtracts an interval. + */ + public function subtract(DateInterval $interval): self + { + $dateTime = $this->dateTime->sub($interval); + + return self::fromDateTime($dateTime); + } + + /** + * Add days. + */ + public function addDays(int $days): self + { + $modifier = ($days >= 0 ? '+' : '-') . abs($days) . ' days'; + + return $this->modify($modifier); + } + + /** + * Add months. + */ + public function addMonths(int $months): self + { + $modifier = ($months >= 0 ? '+' : '-') . abs($months) . ' months'; + + return $this->modify($modifier); + } + + /** + * Add years. + */ + public function addYears(int $years): self + { + $modifier = ($years >= 0 ? '+' : '-') . abs($years) . ' years'; + + return $this->modify($modifier); + } + + /** + * Add hours. + */ + public function addHours(int $hours): self + { + $modifier = ($hours >= 0 ? '+' : '-') . abs($hours) . ' hours'; + + return $this->modify($modifier); + } + + /** + * Add minutes. + */ + public function addMinutes(int $minutes): self + { + $modifier = ($minutes >= 0 ? '+' : '-') . abs($minutes) . ' minutes'; + + return $this->modify($modifier); + } + + /** + * Add seconds. + */ + public function addSeconds(int $seconds): self + { + $modifier = ($seconds >= 0 ? '+' : '-') . abs($seconds) . ' seconds'; + + return $this->modify($modifier); + } + + /** + * A difference between another object (date or date-time) and self. + */ + public function diff(DateTimeable $other): DateInterval + { + return $this->toDateTime()->diff($other->toDateTime()); + } + + /** + * Clones and apply a timezone. + */ + public function withTimezone(DateTimeZone $timezone): self + { + $dateTime = $this->dateTime->setTimezone($timezone); + + return self::fromDateTime($dateTime); + } + + /** + * Clones and sets time. Null preserves a current value. + */ + public function withTime(?int $hour, ?int $minute, ?int $second = 0): self + { + $dateTime = $this->dateTime->setTime( + $hour ?? $this->getHour(), + $minute ?? $this->getMinute(), + $second ?? $this->getSecond() + ); + + return self::fromDateTime($dateTime); + } + + /** + * Whether greater than a given value. + */ + public function isGreaterThan(DateTimeable $other): bool + { + return $this->toDateTime() > $other->toDateTime(); + } + + /** + * Whether less than a given value. + */ + public function isLessThan(DateTimeable $other): bool + { + return $this->toDateTime() < $other->toDateTime(); + } + + /** + * Whether equals to a given value. + */ + public function isEqualTo(DateTimeable $other): bool + { + return $this->toDateTime() == $other->toDateTime(); + } + + /** + * Whether less than or equals to a given value. + * @since 9.0.0 + */ + public function isLessThanOrEqualTo(DateTimeable $other): bool + { + return $this->isLessThan($other) || $this->isEqualTo($other); + } + + /** + * Whether greater than or equals to a given value. + * @since 9.0.0 + */ + public function isGreaterThanOrEqualTo(DateTimeable $other): bool + { + return $this->isGreaterThan($other) || $this->isEqualTo($other); + } + + /** + * Create a current time. + */ + public static function createNow(): self + { + return self::fromDateTime(new DateTimeImmutable()); + } + + /** + * Create from a string with a date-time in `Y-m-d H:i:s` format. + * + * @throws InvalidArgumentException + */ + public static function fromString(string $value): self + { + return new self($value); + } + + /** + * Create from a timestamp. + */ + public static function fromTimestamp(int $timestamp): self + { + $dateTime = (new DateTimeImmutable)->setTimestamp($timestamp); + + return self::fromDateTime($dateTime); + } + + /** + * Create from a DateTimeInterface. + */ + public static function fromDateTime(DateTimeInterface $dateTime): self + { + /** @var DateTimeImmutable $value */ + $value = DateTimeImmutable::createFromFormat( + self::SYSTEM_FORMAT, + $dateTime->format(self::SYSTEM_FORMAT), + $dateTime->getTimezone() + ); + + $utcValue = $value + ->setTimezone(new DateTimeZone('UTC')) + ->format(self::SYSTEM_FORMAT); + + $obj = new self($utcValue); + + $obj->dateTime = $obj->dateTime->setTimezone($dateTime->getTimezone()); + + return $obj; + } + + /** + * @deprecated As of v8.1. Use `toString` instead. + * @todo Remove in v11.0. + */ + public function getString(): string + { + return $this->toString(); + } + + /** + * @deprecated As of v8.1. Use `toDateTime` instead. + * @todo Remove in v11.0. + */ + public function getDateTime(): DateTimeImmutable + { + return $this->toDateTime(); + } + + /** + * @deprecated As of v8.1. Use `toTimestamp` instead. + * @todo Remove in v11.0. + */ + public function getTimestamp(): int + { + return $this->toTimestamp(); + } +} diff --git a/application/Espo/Core/Field/DateTime/DateTimeAttributeExtractor.php b/application/Espo/Core/Field/DateTime/DateTimeAttributeExtractor.php new file mode 100644 index 0000000..8421a2a --- /dev/null +++ b/application/Espo/Core/Field/DateTime/DateTimeAttributeExtractor.php @@ -0,0 +1,64 @@ +. + * + * 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\Field\DateTime; + +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\DateTime; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor + */ +class DateTimeAttributeExtractor implements AttributeExtractor +{ + /** + * @param DateTime $value + */ + public function extract(object $value, string $field): stdClass + { + if (!$value instanceof DateTime) { + throw new InvalidArgumentException(); + } + + return (object) [ + $field => $value->toString(), + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field => null, + ]; + } +} diff --git a/application/Espo/Core/Field/DateTime/DateTimeFactory.php b/application/Espo/Core/Field/DateTime/DateTimeFactory.php new file mode 100644 index 0000000..408ede5 --- /dev/null +++ b/application/Espo/Core/Field/DateTime/DateTimeFactory.php @@ -0,0 +1,55 @@ +. + * + * 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\Field\DateTime; + +use Espo\ORM\Entity; +use Espo\ORM\Value\ValueFactory; +use Espo\Core\Field\DateTime; + +use RuntimeException; + +class DateTimeFactory implements ValueFactory +{ + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + return $entity->get($field) !== null; + } + + public function createFromEntity(Entity $entity, string $field): DateTime + { + if (!$this->isCreatableFromEntity($entity, $field)) { + throw new RuntimeException(); + } + + return new DateTime( + $entity->get($field) + ); + } +} diff --git a/application/Espo/Core/Field/DateTime/DateTimeable.php b/application/Espo/Core/Field/DateTime/DateTimeable.php new file mode 100644 index 0000000..1825c96 --- /dev/null +++ b/application/Espo/Core/Field/DateTime/DateTimeable.php @@ -0,0 +1,37 @@ +. + * + * 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\Field\DateTime; + +use DateTimeImmutable; + +interface DateTimeable +{ + public function toDateTime(): DateTimeImmutable; +} diff --git a/application/Espo/Core/Field/DateTimeOptional.php b/application/Espo/Core/Field/DateTimeOptional.php new file mode 100644 index 0000000..594cf0f --- /dev/null +++ b/application/Espo/Core/Field/DateTimeOptional.php @@ -0,0 +1,537 @@ +. + * + * 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\Field; + +use Espo\Core\Field\DateTime\DateTimeable; + +use DateTimeImmutable; +use DateTimeInterface; +use DateInterval; +use DateTimeZone; +use InvalidArgumentException; + +/** + * A date-time or date. Immutable. + */ +class DateTimeOptional implements DateTimeable +{ + private ?DateTime $dateTimeValue = null; + private ?Date $dateValue = null; + + private const string SYSTEM_FORMAT = 'Y-m-d H:i:s'; + private const string SYSTEM_FORMAT_DATE = 'Y-m-d'; + + /** + * @throws InvalidArgumentException + */ + public function __construct(string $value) + { + if (self::isStringDateTime($value)) { + $this->dateTimeValue = new DateTime($value); + } else { + $this->dateValue = new Date($value); + } + } + + /** + * Create from a string with a date-time in `Y-m-d H:i:s` format or date in `Y-m-d`. + * + * @throws InvalidArgumentException + */ + public static function fromString(string $value): self + { + return new self($value); + } + + /** + * Create from a string with a date-time in `Y-m-d H:i:s` format. + * + * @throws InvalidArgumentException + * @noinspection PhpUnused + */ + public static function fromDateTimeString(string $value): self + { + if (!self::isStringDateTime($value)) { + throw new InvalidArgumentException("Bad value."); + } + + return self::fromString($value); + } + + /** + * Get a string value in `Y-m-d H:i:s` format. + */ + public function toString(): string + { + return $this->getActualValue()->toString(); + } + + /** + * Get DateTimeImmutable. + */ + public function toDateTime(): DateTimeImmutable + { + return $this->getActualValue()->toDateTime(); + } + + /** + * Get a timestamp. + */ + public function toTimestamp(): int + { + return $this->getActualValue()->toDateTime()->getTimestamp(); + } + + /** + * Get a year. + */ + public function getYear(): int + { + return $this->getActualValue()->getYear(); + } + + /** + * Get a month. + */ + public function getMonth(): int + { + return $this->getActualValue()->getMonth(); + } + + /** + * Get a day (of month). + */ + public function getDay(): int + { + return $this->getActualValue()->getDay(); + } + + /** + * Get a day of week. 0 (for Sunday) through 6 (for Saturday). + */ + public function getDayOfWeek(): int + { + return $this->getActualValue()->getDayOfWeek(); + } + + /** + * Get an hour. + */ + public function getHour(): int + { + if ($this->isAllDay()) { + return 0; + } + + /** @var DateTime $value */ + $value = $this->getActualValue(); + + return $value->getHour(); + } + + /** + * Get a minute. + */ + public function getMinute(): int + { + if ($this->isAllDay()) { + return 0; + } + + /** @var DateTime $value */ + $value = $this->getActualValue(); + + return $value->getMinute(); + } + + /** + * Get a second. + */ + public function getSecond(): int + { + if ($this->isAllDay()) { + return 0; + } + + /** @var DateTime $value */ + $value = $this->getActualValue(); + + return $value->getSecond(); + } + + /** + * Whether is all-day (no time part). + */ + public function isAllDay(): bool + { + return $this->dateValue !== null; + } + + /** + * Get a timezone. + */ + public function getTimezone(): DateTimeZone + { + return $this->toDateTime()->getTimezone(); + } + + private function getActualValue(): Date|DateTime + { + /** @var Date|DateTime */ + return $this->dateValue ?? $this->dateTimeValue; + } + + /** + * Clones and apply a timezone. Non-all-day value is created. + */ + public function withTimezone(DateTimeZone $timezone): self + { + if ($this->isAllDay()) { + $dateTime = $this->getActualValue()->toDateTime()->setTimezone($timezone); + + return self::fromDateTime($dateTime); + } + + /** @var DateTime $value */ + $value = $this->getActualValue(); + + $dateTime = $value->withTimezone($timezone)->toDateTime(); + + return self::fromDateTime($dateTime); + } + + /** + * Clones and sets time. Null preserves a current value. + */ + public function withTime(?int $hour, ?int $minute, ?int $second = 0): self + { + if ($this->isAllDay()) { + $dateTime = DateTime::fromDateTime($this->getActualValue()->toDateTime()) + ->withTime($hour, $minute, $second); + + return self::fromDateTime($dateTime->toDateTime()); + } + + /** @var DateTime $value */ + $value = $this->getActualValue(); + + $dateTime = $value->withTime($hour, $minute, $second); + + return self::fromDateTime($dateTime->toDateTime()); + } + + /** + * Clones and modifies. + */ + public function modify(string $modifier): self + { + if ($this->isAllDay()) { + assert($this->dateValue !== null); + + return self::fromDateTimeAllDay( + $this->dateValue->modify($modifier)->toDateTime() + ); + } + + assert($this->dateTimeValue !== null); + + return self::fromDateTime( + $this->dateTimeValue->modify($modifier)->toDateTime() + ); + } + + /** + * Clones and adds an interval. + */ + public function add(DateInterval $interval): self + { + if ($this->isAllDay()) { + assert($this->dateValue !== null); + + return self::fromDateTimeAllDay( + $this->dateValue->add($interval)->toDateTime() + ); + } + + assert($this->dateTimeValue !== null); + + return self::fromDateTime( + $this->dateTimeValue->add($interval)->toDateTime() + ); + } + + /** + * Clones and subtracts an interval. + */ + public function subtract(DateInterval $interval): self + { + if ($this->isAllDay()) { + assert($this->dateValue !== null); + + return self::fromDateTimeAllDay( + $this->dateValue->subtract($interval)->toDateTime() + ); + } + + assert($this->dateTimeValue !== null); + + return self::fromDateTime( + $this->dateTimeValue->subtract($interval)->toDateTime() + ); + } + + /** + * Add days. + */ + public function addDays(int $days): self + { + $modifier = ($days >= 0 ? '+' : '-') . abs($days) . ' days'; + + return $this->modify($modifier); + } + + /** + * Add months. + */ + public function addMonths(int $months): self + { + $modifier = ($months >= 0 ? '+' : '-') . abs($months) . ' months'; + + return $this->modify($modifier); + } + + /** + * Add years. + */ + public function addYears(int $years): self + { + $modifier = ($years >= 0 ? '+' : '-') . abs($years) . ' years'; + + return $this->modify($modifier); + } + + /** + * Add hours. + */ + public function addHours(int $hours): self + { + $modifier = ($hours >= 0 ? '+' : '-') . abs($hours) . ' hours'; + + return $this->modify($modifier); + } + + /** + * Add minutes. + */ + public function addMinutes(int $minutes): self + { + $modifier = ($minutes >= 0 ? '+' : '-') . abs($minutes) . ' minutes'; + + return $this->modify($modifier); + } + + /** + * Add seconds. + */ + public function addSeconds(int $seconds): self + { + $modifier = ($seconds >= 0 ? '+' : '-') . abs($seconds) . ' seconds'; + + return $this->modify($modifier); + } + + /** + * A difference between another object (date or date-time) and self. + */ + public function diff(DateTimeable $other): DateInterval + { + return $this->toDateTime()->diff($other->toDateTime()); + } + + /** + * Whether greater than a given value. + */ + public function isGreaterThan(DateTimeable $other): bool + { + return $this->toDateTime() > $other->toDateTime(); + } + + /** + * Whether less than a given value. + */ + public function isLessThan(DateTimeable $other): bool + { + return $this->toDateTime() < $other->toDateTime(); + } + + /** + * Whether equals to a given value. + */ + public function isEqualTo(DateTimeable $other): bool + { + return $this->toDateTime() == $other->toDateTime(); + } + + /** + * Whether less than or equals to a given value. + * @since 9.0.0 + */ + public function isLessThanOrEqualTo(DateTimeable $other): bool + { + return $this->isLessThan($other) || $this->isEqualTo($other); + } + + /** + * Whether greater than or equals to a given value. + * @since 9.0.0 + */ + public function isGreaterThanOrEqualTo(DateTimeable $other): bool + { + return $this->isGreaterThan($other) || $this->isEqualTo($other); + } + + /** + * Create a current time. + */ + public static function createNow(): self + { + return self::fromDateTime(new DateTimeImmutable()); + } + + /** + * Create a today. + */ + public static function createToday(?DateTimeZone $timezone = null): self + { + $now = new DateTimeImmutable(); + + if ($timezone) { + $now = $now->setTimezone($timezone); + } + + return self::fromDateTimeAllDay($now); + } + + /** + * Create from a string with a date in `Y-m-d` format. + * + * @throws InvalidArgumentException + * @noinspection PhpUnused + */ + public static function fromDateString(string $value): self + { + if (self::isStringDateTime($value)) { + throw new InvalidArgumentException("Bad value."); + } + + return self::fromString($value); + } + + /** + * Create from a timestamp. + */ + public static function fromTimestamp(int $timestamp): self + { + $dateTime = (new DateTimeImmutable)->setTimestamp($timestamp); + + return self::fromDateTime($dateTime); + } + + /** + * Create from a DateTimeInterface. + */ + public static function fromDateTime(DateTimeInterface $dateTime): self + { + /** @var DateTimeImmutable $value */ + $value = DateTimeImmutable::createFromFormat( + self::SYSTEM_FORMAT, + $dateTime->format(self::SYSTEM_FORMAT), + $dateTime->getTimezone() + ); + + $utcValue = $value + ->setTimezone(new DateTimeZone('UTC')) + ->format(self::SYSTEM_FORMAT); + + $obj = self::fromString($utcValue); + + assert($obj->dateTimeValue !== null); + + $obj->dateTimeValue = $obj->dateTimeValue->withTimezone($dateTime->getTimezone()); + + return $obj; + } + + /** + * Create all-day from a DateTimeInterface. + */ + public static function fromDateTimeAllDay(DateTimeInterface $dateTime): self + { + $value = $dateTime->format(self::SYSTEM_FORMAT_DATE); + + return new self($value); + } + + private static function isStringDateTime(string $value): bool + { + if (strlen($value) > 10) { + return true; + } + + return false; + } + + /** + * @deprecated As of v8.1. Use `toString` instead. + * @todo Remove in v11.0. + */ + public function getString(): string + { + return $this->toString(); + } + + /** + * @deprecated As of v8.1. Use `toDateTime` instead. + * @todo Remove in v11.0. + */ + public function getDateTime(): DateTimeImmutable + { + return $this->toDateTime(); + } + + /** + * @deprecated As of v8.1. Use `toTimestamp` instead. + * @todo Remove in v11.0. + */ + public function getTimestamp(): int + { + return $this->toTimestamp(); + } +} diff --git a/application/Espo/Core/Field/DateTimeOptional/DateTimeOptionalAttributeExtractor.php b/application/Espo/Core/Field/DateTimeOptional/DateTimeOptionalAttributeExtractor.php new file mode 100644 index 0000000..51120cb --- /dev/null +++ b/application/Espo/Core/Field/DateTimeOptional/DateTimeOptionalAttributeExtractor.php @@ -0,0 +1,73 @@ +. + * + * 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\Field\DateTimeOptional; + +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\DateTimeOptional; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor + */ +class DateTimeOptionalAttributeExtractor implements AttributeExtractor +{ + /** + * @param DateTimeOptional $value + */ + public function extract(object $value, string $field): stdClass + { + if (!$value instanceof DateTimeOptional) { + throw new InvalidArgumentException(); + } + + if ($value->isAllDay()) { + return (object) [ + $field . 'Date' => $value->toString(), + $field => null, + ]; + } + + return (object) [ + $field => $value->toString(), + $field . 'Date' => null, + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field => null, + $field . 'Date' => null, + ]; + } +} diff --git a/application/Espo/Core/Field/DateTimeOptional/DateTimeOptionalFactory.php b/application/Espo/Core/Field/DateTimeOptional/DateTimeOptionalFactory.php new file mode 100644 index 0000000..735c7f0 --- /dev/null +++ b/application/Espo/Core/Field/DateTimeOptional/DateTimeOptionalFactory.php @@ -0,0 +1,55 @@ +. + * + * 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\Field\DateTimeOptional; + +use Espo\ORM\Entity; +use Espo\ORM\Value\ValueFactory; +use Espo\Core\Field\DateTimeOptional; + +use RuntimeException; + +class DateTimeOptionalFactory implements ValueFactory +{ + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + return $entity->get($field) !== null || $entity->get($field . 'Date') !== null; + } + + public function createFromEntity(Entity $entity, string $field): DateTimeOptional + { + if (!$this->isCreatableFromEntity($entity, $field)) { + throw new RuntimeException(); + } + + $stringValue = $entity->get($field . 'Date') ?? $entity->get($field); + + return new DateTimeOptional($stringValue); + } +} diff --git a/application/Espo/Core/Field/EmailAddress.php b/application/Espo/Core/Field/EmailAddress.php new file mode 100644 index 0000000..42ce8ed --- /dev/null +++ b/application/Espo/Core/Field/EmailAddress.php @@ -0,0 +1,152 @@ +. + * + * 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\Field; + +use InvalidArgumentException; + +use const FILTER_VALIDATE_EMAIL; + +/** + * An email address value. Immutable. + */ +class EmailAddress +{ + private string $address; + private bool $isOptedOut = false; + private bool $isInvalid = false; + + /** + * @throws InvalidArgumentException + */ + public function __construct(string $address) + { + if ($address === '') { + throw new InvalidArgumentException("Empty email address."); + } + + if (!filter_var($address, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException("Not valid email address '$address'."); + } + + $this->address = $address; + } + + /** + * Get an address. + */ + public function getAddress(): string + { + return $this->address; + } + + /** + * Whether opted-out. + */ + public function isOptedOut(): bool + { + return $this->isOptedOut; + } + + /** + * Whether invalid. + */ + public function isInvalid(): bool + { + return $this->isInvalid; + } + + /** + * Clone set invalid. + */ + public function invalid(): self + { + $obj = $this->clone(); + + $obj->isInvalid = true; + + return $obj; + } + + /** + * Clone set not invalid. + */ + public function notInvalid(): self + { + $obj = $this->clone(); + + $obj->isInvalid = false; + + return $obj; + } + + /** + * Clone set opted-out. + */ + public function optedOut(): self + { + $obj = $this->clone(); + + $obj->isOptedOut = true; + + return $obj; + } + + /** + * Clone set not opted-out. + */ + public function notOptedOut(): self + { + $obj = $this->clone(); + + $obj->isOptedOut = false; + + return $obj; + } + + /** + * Create from an address. + * + * @throws InvalidArgumentException + */ + public static function create(string $address): self + { + return new self($address); + } + + private function clone(): self + { + $obj = new self($this->address); + + $obj->isInvalid = $this->isInvalid; + $obj->isOptedOut = $this->isOptedOut; + + return $obj; + } +} diff --git a/application/Espo/Core/Field/EmailAddress/EmailAddressGroupAttributeExtractor.php b/application/Espo/Core/Field/EmailAddress/EmailAddressGroupAttributeExtractor.php new file mode 100644 index 0000000..a3da2de --- /dev/null +++ b/application/Espo/Core/Field/EmailAddress/EmailAddressGroupAttributeExtractor.php @@ -0,0 +1,80 @@ +. + * + * 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\Field\EmailAddress; + +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\EmailAddressGroup; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor + */ +class EmailAddressGroupAttributeExtractor implements AttributeExtractor +{ + /** + * @param EmailAddressGroup $group + */ + public function extract(object $group, string $field): stdClass + { + if (!$group instanceof EmailAddressGroup) { + throw new InvalidArgumentException(); + } + + $primaryAddress = $group->getPrimary() ? $group->getPrimary()->getAddress() : null; + + $dataList = []; + + foreach ($group->getList() as $emailAddress) { + $dataList[] = (object) [ + 'emailAddress' => $emailAddress->getAddress(), + 'lower' => strtolower($emailAddress->getAddress()), + 'primary' => $primaryAddress && $emailAddress->getAddress() === $primaryAddress, + 'optOut' => $emailAddress->isOptedOut(), + 'invalid' => $emailAddress->isInvalid(), + ]; + } + + return (object) [ + $field => $primaryAddress, + $field . 'Data' => $dataList, + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field => null, + $field . 'Data' => [], + ]; + } +} diff --git a/application/Espo/Core/Field/EmailAddress/EmailAddressGroupFactory.php b/application/Espo/Core/Field/EmailAddress/EmailAddressGroupFactory.php new file mode 100644 index 0000000..03c8860 --- /dev/null +++ b/application/Espo/Core/Field/EmailAddress/EmailAddressGroupFactory.php @@ -0,0 +1,158 @@ +. + * + * 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\Field\EmailAddress; + +use Espo\Core\ORM\Type\FieldType; +use Espo\Entities\EmailAddress as EmailAddressEntity; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\Repositories\EmailAddress as Repository; + +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Value\ValueFactory; + +use Espo\Core\Field\EmailAddress; +use Espo\Core\Field\EmailAddressGroup; +use Espo\Core\Utils\Metadata; + +use RuntimeException; +use stdClass; + +/** + * An email address group factory. + */ +class EmailAddressGroupFactory implements ValueFactory +{ + private Metadata $metadata; + private EntityManager $entityManager; + + /** + * @todo Use OrmDefs instead of Metadata. + */ + public function __construct(Metadata $metadata, EntityManager $entityManager) + { + $this->metadata = $metadata; + $this->entityManager = $entityManager; + } + + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + $type = $this->metadata->get(['entityDefs', $entity->getEntityType(), 'fields', $field, FieldParam::TYPE]); + + if ($type !== FieldType::EMAIL) { + return false; + } + + return true; + } + + public function createFromEntity(Entity $entity, string $field): EmailAddressGroup + { + if (!$this->isCreatableFromEntity($entity, $field)) { + throw new RuntimeException(); + } + + $emailAddressList = []; + + $primaryEmailAddress = null; + + $dataList = null; + + $dataAttribute = $field . 'Data'; + + if ($entity->has($dataAttribute)) { + $dataList = $this->sanitizeDataList( + $entity->get($dataAttribute) + ); + } + + if (!$dataList && $entity->has($field) && !$entity->get($field)) { + $dataList = []; + } + + if (!$dataList) { + /** @var Repository $repository */ + $repository = $this->entityManager->getRepository(EmailAddressEntity::ENTITY_TYPE); + + $dataList = $repository->getEmailAddressData($entity); + } + + foreach ($dataList as $item) { + $emailAddress = EmailAddress::create($item->emailAddress); + + if ($item->optOut ?? false) { + $emailAddress = $emailAddress->optedOut(); + } + + if ($item->invalid ?? false) { + $emailAddress = $emailAddress->invalid(); + } + + if ($item->primary ?? false) { + $primaryEmailAddress = $emailAddress; + } + + $emailAddressList[] = $emailAddress; + } + + $group = EmailAddressGroup::create($emailAddressList); + + if ($primaryEmailAddress) { + $group = $group->withPrimary($primaryEmailAddress); + } + + return $group; + } + + /** + * @param array|stdClass> $dataList + * @return stdClass[] + */ + private function sanitizeDataList(array $dataList): array + { + $sanitizedDataList = []; + + foreach ($dataList as $item) { + if (is_array($item)) { + $sanitizedDataList[] = (object) $item; + + continue; + } + + if (!is_object($item)) { + throw new RuntimeException("Bad data."); + } + + $sanitizedDataList[] = $item; + } + + return $sanitizedDataList; + } +} diff --git a/application/Espo/Core/Field/EmailAddressGroup.php b/application/Espo/Core/Field/EmailAddressGroup.php new file mode 100644 index 0000000..4ed503d --- /dev/null +++ b/application/Espo/Core/Field/EmailAddressGroup.php @@ -0,0 +1,297 @@ +. + * + * 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\Field; + +use InvalidArgumentException; + +/** + * An email address group. Contains a list of email addresses. One email address is set as primary. + * If not empty, then there always should be a primary address. Immutable. + */ +class EmailAddressGroup +{ + /** @var EmailAddress[] */ + private array $list = []; + private ?EmailAddress $primary = null; + + /** + * @param EmailAddress[] $list + * @throws InvalidArgumentException + */ + public function __construct(array $list = []) + { + foreach ($list as $item) { + $this->list[] = clone $item; + } + + $this->validateList(); + + if (count($this->list) !== 0) { + $this->primary = $this->list[0]; + } + } + + public function __clone() + { + $newList = []; + + foreach ($this->list as $item) { + $newList[] = clone $item; + } + + $this->list = $newList; + + if ($this->primary) { + $this->primary = clone $this->primary; + } + } + + /** + * Get a primary address as a string. If no primary, then returns null, + */ + public function getPrimaryAddress(): ?string + { + $primary = $this->getPrimary(); + + return $primary?->getAddress(); + } + + /** + * Get a primary email address. + */ + public function getPrimary(): ?EmailAddress + { + if ($this->isEmpty()) { + return null; + } + + return $this->primary; + } + + /** + * Get a list of all email addresses. + * + * @return EmailAddress[] + */ + public function getList(): array + { + return $this->list; + } + + /** + * Get a number of addresses. + */ + public function getCount(): int + { + return count($this->list); + } + + /** + * Get a list of email addresses w/o a primary. + * + * @return EmailAddress[] + */ + public function getSecondaryList(): array + { + $list = []; + + foreach ($this->list as $item) { + if ($item === $this->primary) { + continue; + } + + $list[] = $item; + } + + return $list; + } + + /** + * Get a list of email addresses represented as strings. + * + * @return string[] + */ + public function getAddressList(): array + { + $list = []; + + foreach ($this->list as $item) { + $list[] = $item->getAddress(); + } + + return $list; + } + + /** + * Get an email address by address represented as a string. + */ + public function getByAddress(string $address): ?EmailAddress + { + $index = $this->searchAddressInList($address); + + if ($index === null) { + return null; + } + + return $this->list[$index]; + } + + /** + * Whether an address is in the list. + */ + public function hasAddress(string $address): bool + { + return in_array($address, $this->getAddressList()); + } + + /** + * Clone with another primary email address. + */ + public function withPrimary(EmailAddress $emailAddress): self + { + $list = $this->list; + + $index = $this->searchAddressInList($emailAddress->getAddress()); + + if ($index !== null) { + unset($list[$index]); + + $list = array_values($list); + } + + $newList = array_merge([$emailAddress], $list); + + return self::create($newList); + } + + /** + * Clone with an added email address list. + * + * @param EmailAddress[] $list + */ + public function withAddedList(array $list): self + { + $newList = $this->list; + + foreach ($list as $item) { + $index = $this->searchAddressInList($item->getAddress()); + + if ($index !== null) { + $newList[$index] = $item; + + continue; + } + + $newList[] = $item; + } + + return self::create($newList); + } + + /** + * Clone with an added email address. + */ + public function withAdded(EmailAddress $emailAddress): self + { + return $this->withAddedList([$emailAddress]); + } + + /** + * Clone with removed email address. + */ + public function withRemoved(EmailAddress $emailAddress): self + { + return $this->withRemovedByAddress($emailAddress->getAddress()); + } + + /** + * Clone with removed email address passed by an address. + */ + public function withRemovedByAddress(string $address): self + { + $newList = $this->list; + + $index = $this->searchAddressInList($address); + + if ($index !== null) { + unset($newList[$index]); + + $newList = array_values($newList); + } + + return self::create($newList); + } + + /** + * Create with an optional email address list. A first item will be set as primary. + * + * @param EmailAddress[] $list + */ + public static function create(array $list = []): self + { + return new self($list); + } + + private function searchAddressInList(string $address): ?int + { + foreach ($this->getAddressList() as $i => $item) { + if ($item === $address) { + return $i; + } + } + + return null; + } + + /** + * @throws InvalidArgumentException + */ + private function validateList(): void + { + $addressList = []; + + foreach ($this->list as $item) { + if (!$item instanceof EmailAddress) { + throw new InvalidArgumentException("Bad item."); + } + + if (in_array(strtolower($item->getAddress()), $addressList)) { + throw new InvalidArgumentException("Address list contains a duplicate."); + } + + $addressList[] = strtolower($item->getAddress()); + } + } + + private function isEmpty(): bool + { + return count($this->list) === 0; + } +} diff --git a/application/Espo/Core/Field/Link.php b/application/Espo/Core/Field/Link.php new file mode 100644 index 0000000..1cfa5bd --- /dev/null +++ b/application/Espo/Core/Field/Link.php @@ -0,0 +1,104 @@ +. + * + * 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\Field; + +use Espo\ORM\Entity; +use InvalidArgumentException; + +/** + * A link value object. Immutable. + */ +class Link +{ + private string $id; + private ?string $name = null; + + /** + * @throws InvalidArgumentException + */ + public function __construct(string $id) + { + if (!$id) { + throw new InvalidArgumentException("Empty ID."); + } + + $this->id = $id; + } + + /** + * Get an ID. + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get a name. + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Clone with a name. + */ + public function withName(?string $name): self + { + $obj = new self($this->id); + + $obj->name = $name; + + return $obj; + } + + /** + * 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 10.0.0 + */ + public static function fromEntity(Entity $entity): self + { + return self::create($entity->getId()); + } +} diff --git a/application/Espo/Core/Field/Link/LinkAttributeExtractor.php b/application/Espo/Core/Field/Link/LinkAttributeExtractor.php new file mode 100644 index 0000000..ee5e4e0 --- /dev/null +++ b/application/Espo/Core/Field/Link/LinkAttributeExtractor.php @@ -0,0 +1,66 @@ +. + * + * 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\Field\Link; + +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\Link; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor + */ +class LinkAttributeExtractor implements AttributeExtractor +{ + /** + * @param Link $value + */ + public function extract(object $value, string $field): stdClass + { + if (!$value instanceof Link) { + throw new InvalidArgumentException(); + } + + return (object) [ + $field . 'Id' => $value->getId(), + $field . 'Name' => $value->getName(), + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field . 'Id' => null, + $field . 'Name' => null, + ]; + } +} diff --git a/application/Espo/Core/Field/Link/LinkFactory.php b/application/Espo/Core/Field/Link/LinkFactory.php new file mode 100644 index 0000000..cca7087 --- /dev/null +++ b/application/Espo/Core/Field/Link/LinkFactory.php @@ -0,0 +1,58 @@ +. + * + * 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\Field\Link; + +use Espo\ORM\Entity; +use Espo\ORM\Value\ValueFactory; +use Espo\Core\Field\Link; + +use RuntimeException; + +class LinkFactory implements ValueFactory +{ + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + return $entity->get($field . 'Id') !== null; + } + + public function createFromEntity(Entity $entity, string $field): Link + { + if (!$this->isCreatableFromEntity($entity, $field)) { + throw new RuntimeException(); + } + + $id = $entity->get($field . 'Id'); + $name = $entity->get($field . 'Name'); + + return Link + ::create($id) + ->withName($name); + } +} diff --git a/application/Espo/Core/Field/LinkMultiple.php b/application/Espo/Core/Field/LinkMultiple.php new file mode 100644 index 0000000..8bc41a5 --- /dev/null +++ b/application/Espo/Core/Field/LinkMultiple.php @@ -0,0 +1,237 @@ +. + * + * 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\Field; + +use InvalidArgumentException; + +/** + * A link-multiple value object. Immutable. + */ +class LinkMultiple +{ + /** + * @param LinkMultipleItem[] $list + * @throws InvalidArgumentException + */ + public function __construct(private array $list = []) + { + $this->validateList(); + } + + public function __clone() + { + $newList = []; + + foreach ($this->list as $item) { + $newList[] = clone $item; + } + + $this->list = $newList; + } + + /** + * Whether contains a specific ID. + */ + public function hasId(string $id): bool + { + return $this->searchIdInList($id) !== null; + } + + /** + * Get a list of IDs. + * + * @return string[] + */ + public function getIdList(): array + { + $idList = []; + + foreach ($this->list as $item) { + $idList[] = $item->getId(); + } + + return $idList; + } + + /** + * Get a list of items. + * + * @return LinkMultipleItem[] + */ + public function getList(): array + { + return $this->list; + } + + /** + * Get a number of items. + */ + public function getCount(): int + { + return count($this->list); + } + + /** + * Get item by ID. + */ + public function getById(string $id): ?LinkMultipleItem + { + foreach ($this->list as $item) { + if ($item->getId() === $id) { + return $item; + } + } + + return null; + } + + /** + * Clone with an added ID. + */ + public function withAddedId(string $id): self + { + return $this->withAdded(LinkMultipleItem::create($id)); + } + + /** + * Clone with an added IDs. + * + * @param string[] $idList IDs. + */ + public function withAddedIdList(array $idList): self + { + $obj = $this; + + foreach ($idList as $id) { + $obj = $obj->withAddedId($id); + } + + return $obj; + } + + /** + * Clone with an added item. + */ + public function withAdded(LinkMultipleItem $item): self + { + return $this->withAddedList([$item]); + } + + /** + * Clone with an added item list. + * . + * @param LinkMultipleItem[] $list + * @throws InvalidArgumentException + */ + public function withAddedList(array $list): self + { + $newList = $this->list; + + foreach ($list as $item) { + $index = $this->searchIdInList($item->getId()); + + if ($index !== null) { + $newList[$index] = $item; + + continue; + } + + $newList[] = $item; + } + + return self::create($newList); + } + + /** + * Clone with removed item. + */ + public function withRemoved(LinkMultipleItem $item): self + { + return $this->withRemovedById($item->getId()); + } + + /** + * Clone with removed item by ID. + */ + public function withRemovedById(string $id): self + { + $newList = $this->list; + + $index = $this->searchIdInList($id); + + if ($index !== null) { + unset($newList[$index]); + + $newList = array_values($newList); + } + + return self::create($newList); + } + + /** + * Create with an optional item list. + * + * @param LinkMultipleItem[] $list + * + * @throws InvalidArgumentException + */ + public static function create(array $list = []): self + { + return new self($list); + } + + private function validateList(): void + { + $idList = []; + + foreach ($this->list as $item) { + if (!$item instanceof LinkMultipleItem) { + throw new InvalidArgumentException("Bad item."); + } + + if (in_array($item->getId(), $idList)) { + throw new InvalidArgumentException("List contains duplicates."); + } + + $idList[] = strtolower($item->getId()); + } + } + + private function searchIdInList(string $id): ?int + { + foreach ($this->getIdList() as $i => $item) { + if ($item === $id) { + return $i; + } + } + + return null; + } +} diff --git a/application/Espo/Core/Field/LinkMultiple/LinkMultipleAttributeExtractor.php b/application/Espo/Core/Field/LinkMultiple/LinkMultipleAttributeExtractor.php new file mode 100644 index 0000000..771544a --- /dev/null +++ b/application/Espo/Core/Field/LinkMultiple/LinkMultipleAttributeExtractor.php @@ -0,0 +1,85 @@ +. + * + * 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\Field\LinkMultiple; + +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\LinkMultiple; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor + */ +class LinkMultipleAttributeExtractor implements AttributeExtractor +{ + /** + * @param LinkMultiple $value + */ + public function extract(object $value, string $field): stdClass + { + if (!$value instanceof LinkMultiple) { + throw new InvalidArgumentException(); + } + + $nameMap = (object) []; + $columnData = (object) []; + + foreach ($value->getList() as $item) { + $id = $item->getId(); + + $nameMap->$id = $item->getName(); + + $columnItemData = (object) []; + + foreach ($item->getColumnList() as $column) { + $columnItemData->$column = $item->getColumnValue($column); + } + + $columnData->$id = $columnItemData; + } + + return (object) [ + $field . 'Ids' => $value->getIdList(), + $field . 'Names' => $nameMap, + $field . 'Columns' => $columnData, + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field . 'Ids' => [], + $field . 'Names' => (object) [], + $field . 'Columns' => (object) [], + ]; + } +} diff --git a/application/Espo/Core/Field/LinkMultiple/LinkMultipleFactory.php b/application/Espo/Core/Field/LinkMultiple/LinkMultipleFactory.php new file mode 100644 index 0000000..9d273d3 --- /dev/null +++ b/application/Espo/Core/Field/LinkMultiple/LinkMultipleFactory.php @@ -0,0 +1,179 @@ +. + * + * 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\Field\LinkMultiple; + +use Espo\Core\ORM\Type\FieldType; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Value\ValueFactory; + +use Espo\Core\Field\LinkMultiple; +use Espo\Core\Field\LinkMultipleItem; +use Espo\Core\ORM\Entity as CoreEntity; + +use RuntimeException; +use InvalidArgumentException; +use stdClass; + +class LinkMultipleFactory implements ValueFactory +{ + public function __construct(private Defs $ormDefs, private EntityManager $entityManager) + {} + + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + $entityType = $entity->getEntityType(); + + $entityDefs = $this->ormDefs->getEntity($entityType); + + if (!$entityDefs->hasField($field)) { + return false; + } + + return $entityDefs->getField($field)->getType() === FieldType::LINK_MULTIPLE; + } + + public function createFromEntity(Entity $entity, string $field): LinkMultiple + { + if (!$this->isCreatableFromEntity($entity, $field)) { + throw new RuntimeException(); + } + + if (!$entity instanceof CoreEntity) { + throw new InvalidArgumentException(); + } + + $itemList = []; + + if (!$entity->has($field . 'Ids') && !$entity->isNew()) { + $this->loadLinkMultipleField($entity, $field); + } + + $idList = $entity->getLinkMultipleIdList($field); + + $nameMap = $entity->get($field . 'Names') ?? (object) []; + + $columnData = null; + + if ($entity->hasAttribute($field . 'Columns')) { + $columnData = $entity->get($field . 'Columns') ? + $entity->get($field . 'Columns') : + $this->loadColumnData($entity, $field); + } + + foreach ($idList as $id) { + $item = LinkMultipleItem::create($id); + + if ($columnData && property_exists($columnData, $id)) { + $item = $this->addColumnValues($item, $columnData->$id); + } + + $name = $nameMap->$id ?? null; + + if ($name !== null) { + $item = $item->withName($name); + } + + $itemList[] = $item; + } + + return new LinkMultiple($itemList); + } + + private function loadLinkMultipleField(CoreEntity $entity, string $field): void + { + $entity->loadLinkMultipleField($field); + } + + private function loadColumnData(Entity $entity, string $field): stdClass + { + if ($entity->isNew()) { + return (object) []; + } + + $columnData = (object) []; + + $select = [Attribute::ID]; + + $entityDefs = $this->ormDefs->getEntity($entity->getEntityType()); + + $columns = $entityDefs->getField($field)->getParam('columns') ?? []; + + if (count($columns) === 0) { + return $columnData; + } + + $foreignEntityType = $entityDefs->tryGetRelation($field)?->tryGetForeignEntityType(); + + if ($foreignEntityType) { + $foreignEntityDefs = $this->entityManager->getDefs()->getEntity($foreignEntityType); + + foreach ($columns as $column => $attribute) { + if (!$foreignEntityDefs->hasAttribute($attribute)) { + // For backward compatibility. If foreign attributes defined in the field do not exist. + unset($columns[$column]); + } + } + } + + foreach ($columns as $item) { + $select[] = $item; + } + + $collection = $this->entityManager + ->getRDBRepository($entity->getEntityType()) + ->getRelation($entity, $field) + ->select($select) + ->find(); + + foreach ($collection as $itemEntity) { + $id = $itemEntity->getId(); + + $columnData->$id = (object) []; + + foreach ($columns as $column => $attribute) { + $columnData->$id->$column = $itemEntity->get($attribute); + } + } + + return $columnData; + } + + private function addColumnValues(LinkMultipleItem $item, stdClass $data): LinkMultipleItem + { + foreach (get_object_vars($data) as $column => $value) { + $item = $item->withColumnValue($column, $value); + } + + return $item; + } +} diff --git a/application/Espo/Core/Field/LinkMultipleItem.php b/application/Espo/Core/Field/LinkMultipleItem.php new file mode 100644 index 0000000..e11a6eb --- /dev/null +++ b/application/Espo/Core/Field/LinkMultipleItem.php @@ -0,0 +1,174 @@ +. + * + * 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\Field; + +use Espo\Core\Name\Field; +use Espo\ORM\Entity; +use InvalidArgumentException; + +/** + * A link-multiple item. Immutable. + */ +class LinkMultipleItem +{ + private string $id; + private ?string $name = null; + /** @var array */ + private array $columnData = []; + + /** + * @throws InvalidArgumentException + */ + public function __construct(string $id) + { + if ($id === '') { + throw new InvalidArgumentException("Empty ID."); + } + + $this->id = $id; + } + + /** + * Get an ID. + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get a name. + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Get a column value. + * + * @return mixed + */ + public function getColumnValue(string $column) + { + return $this->columnData[$column] ?? null; + } + + /** + * Whether a column value is set. + */ + public function hasColumnValue(string $column): bool + { + return array_key_exists($column, $this->columnData); + } + + /** + * Get a list of set columns. + * + * @return array + */ + public function getColumnList(): array + { + return array_keys($this->columnData); + } + + /** + * Clone with a name. + * + * @param ?string $name Is nullable since 10.0.0. + */ + public function withName(?string $name): self + { + $obj = $this->clone(); + $obj->name = $name; + + return $obj; + } + + /** + * Clone with a column value. + * + * @param mixed $value + */ + public function withColumnValue(string $column, $value): self + { + $obj = $this->clone(); + $obj->columnData[$column] = $value; + + return $obj; + } + + /** + * Create. + * + * @throws InvalidArgumentException + */ + public static function create(string $id, ?string $name = null): self + { + $obj = new self($id); + $obj->name = $name; + + return $obj; + } + + /** + * Create from a link. + * + * @throws InvalidArgumentException + * + * @since 10.0.0 + */ + public static function fromLink(Link $link): self + { + return self::create($link->getId(), $link->getName()); + } + + /** + * Create from an entity. + * + * @throws InvalidArgumentException + * + * @since 10.0.0 + */ + public static function fromEntity(Entity $entity): self + { + return self::create($entity->getId()); + } + + private function clone(): self + { + $obj = new self($this->id); + + $obj->name = $this->name; + $obj->columnData = $this->columnData; + + return $obj; + } +} diff --git a/application/Espo/Core/Field/LinkParent.php b/application/Espo/Core/Field/LinkParent.php new file mode 100644 index 0000000..006252f --- /dev/null +++ b/application/Espo/Core/Field/LinkParent.php @@ -0,0 +1,124 @@ +. + * + * 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\Field; + +use Espo\ORM\Entity; +use InvalidArgumentException; + +/** + * A link-parent value object. Immutable. + */ +class LinkParent +{ + private string $entityType; + private string $id; + private ?string $name = null; + + public function __construct(string $entityType, string $id) + { + if (!$entityType) { + throw new InvalidArgumentException("Empty entity type."); + } + + if (!$id) { + throw new InvalidArgumentException("Empty ID."); + } + + $this->entityType = $entityType; + $this->id = $id; + } + + /** + * Get an ID. + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get an entity type. + */ + public function getEntityType(): string + { + return $this->entityType; + } + + /** + * Get a name. + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Clone with a name. + */ + public function withName(?string $name): self + { + $obj = new self($this->entityType, $this->id); + + $obj->name = $name; + + return $obj; + } + + /** + * Create. + * + * @throws InvalidArgumentException + */ + public static function create(string $entityType, string $id): self + { + return new self($entityType, $id); + } + + /** + * Create from an entity. + * + * @deprecated Since v10.0.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 10.0.0 + */ + public static function fromEntity(Entity $entity): self + { + return new self($entity->getEntityType(), $entity->getId()); + } +} diff --git a/application/Espo/Core/Field/LinkParent/LinkParentAttributeExtractor.php b/application/Espo/Core/Field/LinkParent/LinkParentAttributeExtractor.php new file mode 100644 index 0000000..b54f3a0 --- /dev/null +++ b/application/Espo/Core/Field/LinkParent/LinkParentAttributeExtractor.php @@ -0,0 +1,68 @@ +. + * + * 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\Field\LinkParent; + +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\LinkParent; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor + */ +class LinkParentAttributeExtractor implements AttributeExtractor +{ + /** + * @param LinkParent $value + */ + public function extract(object $value, string $field): stdClass + { + if (!$value instanceof LinkParent) { + throw new InvalidArgumentException(); + } + + return (object) [ + $field . 'Id' => $value->getId(), + $field . 'Type' => $value->getEntityType(), + $field . 'Name' => $value->getName(), + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field . 'Id' => null, + $field . 'Type' => null, + $field . 'Name' => null, + ]; + } +} diff --git a/application/Espo/Core/Field/LinkParent/LinkParentFactory.php b/application/Espo/Core/Field/LinkParent/LinkParentFactory.php new file mode 100644 index 0000000..6784552 --- /dev/null +++ b/application/Espo/Core/Field/LinkParent/LinkParentFactory.php @@ -0,0 +1,58 @@ +. + * + * 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\Field\LinkParent; + +use Espo\ORM\Entity; +use Espo\ORM\Value\ValueFactory; +use Espo\Core\Field\LinkParent; + +use RuntimeException; + +class LinkParentFactory implements ValueFactory +{ + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + return $entity->get($field . 'Id') !== null && $entity->get($field . 'Type') !== null; + } + + public function createFromEntity(Entity $entity, string $field): LinkParent + { + if (!$this->isCreatableFromEntity($entity, $field)) { + throw new RuntimeException(); + } + + $id = $entity->get($field . 'Id'); + $entityType = $entity->get($field . 'Type'); + + return LinkParent + ::create($entityType, $id) + ->withName($entity->get($field . 'Name')); + } +} diff --git a/application/Espo/Core/Field/PhoneNumber.php b/application/Espo/Core/Field/PhoneNumber.php new file mode 100644 index 0000000..f9faa20 --- /dev/null +++ b/application/Espo/Core/Field/PhoneNumber.php @@ -0,0 +1,175 @@ +. + * + * 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\Field; + +use InvalidArgumentException; + +/** + * A phone number value. Immutable. + */ +class PhoneNumber +{ + private string $number; + private ?string $type = null; + private bool $isOptedOut = false; + private bool $isInvalid = false; + + public function __construct(string $number) + { + if ($number === '') { + throw new InvalidArgumentException("Empty phone number."); + } + + $this->number = $number; + } + + /** + * Get a type. + */ + public function getType(): ?string + { + return $this->type; + } + + /** + * Get a number. + */ + public function getNumber(): string + { + return $this->number; + } + + /** + * Whether opted-out. + */ + public function isOptedOut(): bool + { + return $this->isOptedOut; + } + + /** + * Whether invalid. + */ + public function isInvalid(): bool + { + return $this->isInvalid; + } + + /** + * Clone with a type. + */ + public function withType(string $type): self + { + $obj = $this->clone(); + + $obj->type = $type; + + return $obj; + } + + /** + * Clone set invalid. + */ + public function invalid(): self + { + $obj = $this->clone(); + + $obj->isInvalid = true; + + return $obj; + } + + /** + * Clone set not invalid. + */ + public function notInvalid(): self + { + $obj = $this->clone(); + + $obj->isInvalid = false; + + return $obj; + } + + /** + * Clone set opted-out. + */ + public function optedOut(): self + { + $obj = $this->clone(); + + $obj->isOptedOut = true; + + return $obj; + } + + /** + * Clone set not opted-out. + */ + public function notOptedOut(): self + { + $obj = $this->clone(); + + $obj->isOptedOut = false; + + return $obj; + } + + /** + * Create with a number. + * + * @throws InvalidArgumentException + */ + public static function create(string $number): self + { + return new self($number); + } + + /** + * Create from a number and type. + * + * @throws InvalidArgumentException + */ + public static function createWithType(string $number, string $type): self + { + return self::create($number)->withType($type); + } + + private function clone(): self + { + $obj = new self($this->number); + + $obj->type = $this->type; + $obj->isInvalid = $this->isInvalid; + $obj->isOptedOut = $this->isOptedOut; + + return $obj; + } +} diff --git a/application/Espo/Core/Field/PhoneNumber/PhoneNumberGroupAttributeExtractor.php b/application/Espo/Core/Field/PhoneNumber/PhoneNumberGroupAttributeExtractor.php new file mode 100644 index 0000000..4e5ca37 --- /dev/null +++ b/application/Espo/Core/Field/PhoneNumber/PhoneNumberGroupAttributeExtractor.php @@ -0,0 +1,80 @@ +. + * + * 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\Field\PhoneNumber; + +use Espo\ORM\Value\AttributeExtractor; + +use Espo\Core\Field\PhoneNumberGroup; + +use stdClass; +use InvalidArgumentException; + +/** + * @implements AttributeExtractor + */ +class PhoneNumberGroupAttributeExtractor implements AttributeExtractor +{ + /** + * @param PhoneNumberGroup $group + */ + public function extract(object $group, string $field): stdClass + { + if (!$group instanceof PhoneNumberGroup) { + throw new InvalidArgumentException(); + } + + $primaryNumber = $group->getPrimary() ? $group->getPrimary()->getNumber() : null; + + $dataList = []; + + foreach ($group->getList() as $phoneNumber) { + $dataList[] = (object) [ + 'phoneNumber' => $phoneNumber->getNumber(), + 'type' => $phoneNumber->getType(), + 'primary' => $primaryNumber && $phoneNumber->getNumber() === $primaryNumber, + 'optOut' => $phoneNumber->isOptedOut(), + 'invalid' => $phoneNumber->isInvalid(), + ]; + } + + return (object) [ + $field => $primaryNumber, + $field . 'Data' => $dataList, + ]; + } + + public function extractFromNull(string $field): stdClass + { + return (object) [ + $field => null, + $field . 'Data' => [], + ]; + } +} diff --git a/application/Espo/Core/Field/PhoneNumber/PhoneNumberGroupFactory.php b/application/Espo/Core/Field/PhoneNumber/PhoneNumberGroupFactory.php new file mode 100644 index 0000000..ef97682 --- /dev/null +++ b/application/Espo/Core/Field/PhoneNumber/PhoneNumberGroupFactory.php @@ -0,0 +1,162 @@ +. + * + * 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\Field\PhoneNumber; + +use Espo\Core\ORM\Type\FieldType; +use Espo\Entities\PhoneNumber as PhoneNumberEntity; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\Repositories\PhoneNumber as Repository; + +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Value\ValueFactory; + +use Espo\Core\Field\PhoneNumber; +use Espo\Core\Field\PhoneNumberGroup; +use Espo\Core\Utils\Metadata; + +use RuntimeException; +use stdClass; + +/** + * A phone number group factory. + */ +class PhoneNumberGroupFactory implements ValueFactory +{ + private Metadata $metadata; + private EntityManager $entityManager; + + /** + * @todo Use OrmDefs instead of Metadata. + */ + public function __construct(Metadata $metadata, EntityManager $entityManager) + { + $this->metadata = $metadata; + $this->entityManager = $entityManager; + } + + public function isCreatableFromEntity(Entity $entity, string $field): bool + { + $type = $this->metadata->get(['entityDefs', $entity->getEntityType(), 'fields', $field, FieldParam::TYPE]); + + if ($type !== FieldType::PHONE) { + return false; + } + + return true; + } + + public function createFromEntity(Entity $entity, string $field): PhoneNumberGroup + { + if (!$this->isCreatableFromEntity($entity, $field)) { + throw new RuntimeException(); + } + + $phoneNumberList = []; + + $primaryPhoneNumber = null; + + $dataList = null; + + $dataAttribute = $field . 'Data'; + + if ($entity->has($dataAttribute)) { + $dataList = $this->sanitizeDataList( + $entity->get($dataAttribute) + ); + } + + if (!$dataList && $entity->has($field) && !$entity->get($field)) { + $dataList = []; + } + + if (!$dataList) { + /** @var Repository $repository */ + $repository = $this->entityManager->getRepository(PhoneNumberEntity::ENTITY_TYPE); + + $dataList = $repository->getPhoneNumberData($entity); + } + + foreach ($dataList as $item) { + $phoneNumber = PhoneNumber::create($item->phoneNumber); + + if ($item->type ?? false) { + $phoneNumber = $phoneNumber->withType($item->type); + } + + if ($item->optOut ?? false) { + $phoneNumber = $phoneNumber->optedOut(); + } + + if ($item->invalid ?? false) { + $phoneNumber = $phoneNumber->invalid(); + } + + if ($item->primary ?? false) { + $primaryPhoneNumber = $phoneNumber; + } + + $phoneNumberList[] = $phoneNumber; + } + + $group = PhoneNumberGroup::create($phoneNumberList); + + if ($primaryPhoneNumber) { + $group = $group->withPrimary($primaryPhoneNumber); + } + + return $group; + } + + /** + * @param array|stdClass> $dataList + * @return stdClass[] + */ + private function sanitizeDataList(array $dataList): array + { + $sanitizedDataList = []; + + foreach ($dataList as $item) { + if (is_array($item)) { + $sanitizedDataList[] = (object) $item; + + continue; + } + + if (!is_object($item)) { + throw new RuntimeException("Bad data."); + } + + $sanitizedDataList[] = $item; + } + + return $sanitizedDataList; + } +} diff --git a/application/Espo/Core/Field/PhoneNumberGroup.php b/application/Espo/Core/Field/PhoneNumberGroup.php new file mode 100644 index 0000000..3f0ea23 --- /dev/null +++ b/application/Espo/Core/Field/PhoneNumberGroup.php @@ -0,0 +1,296 @@ +. + * + * 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\Field; + +use InvalidArgumentException; + +/** + * A phone number group. Contains a list of phone numbers. One phone number is set as primary. + * If not empty, then there always should be a primary number. Immutable. + */ +class PhoneNumberGroup +{ + /** @var PhoneNumber[] */ + private $list = []; + private ?PhoneNumber $primary = null; + + /** + * @param PhoneNumber[] $list + * @throws InvalidArgumentException + */ + public function __construct(array $list = []) + { + foreach ($list as $item) { + $this->list[] = clone $item; + } + + $this->validateList(); + + if (count($this->list) !== 0) { + $this->primary = $this->list[0]; + } + } + + public function __clone() + { + $newList = []; + + foreach ($this->list as $item) { + $newList[] = clone $item; + } + + $this->list = $newList; + + if ($this->primary) { + $this->primary = clone $this->primary; + } + } + + /** + * Get a primary number as a string. If no primary, then returns null, + */ + public function getPrimaryNumber(): ?string + { + $primary = $this->getPrimary(); + + return $primary?->getNumber(); + + } + + /** + * Get a primary phone number. + */ + public function getPrimary(): ?PhoneNumber + { + if ($this->isEmpty()) { + return null; + } + + return $this->primary; + } + + /** + * Get a list of all phone numbers. + * + * @return PhoneNumber[] + */ + public function getList(): array + { + return $this->list; + } + + /** + * Get a number of phone numbers. + */ + public function getCount(): int + { + return count($this->list); + } + + /** + * Get a list of phone numbers w/o a primary. + * + * @return PhoneNumber[] + */ + public function getSecondaryList(): array + { + $list = []; + + foreach ($this->list as $item) { + if ($item === $this->primary) { + continue; + } + + $list[] = $item; + } + + return $list; + } + + /** + * Get a list of phone numbers represented as strings. + * + * @return string[] + */ + public function getNumberList(): array + { + $list = []; + + foreach ($this->list as $item) { + $list[] = $item->getNumber(); + } + + return $list; + } + + /** + * Get a phone number by number represented as a string. + */ + public function getByNumber(string $number): ?PhoneNumber + { + $index = $this->searchNumberInList($number); + + if ($index === null) { + return null; + } + + return $this->list[$index]; + } + + /** + * Whether the number is in the list. + */ + public function hasNumber(string $number): bool + { + return in_array($number, $this->getNumberList()); + } + + /** + * Clone with another primary phone number. + */ + public function withPrimary(PhoneNumber $phoneNumber): self + { + $list = $this->list; + + $index = $this->searchNumberInList($phoneNumber->getNumber()); + + if ($index !== null) { + unset($list[$index]); + + $list = array_values($list); + } + + $newList = array_merge([$phoneNumber], $list); + + return self::create($newList); + } + + /** + * Clone with an added phone number list. + * + * @param PhoneNumber[] $list + */ + public function withAddedList(array $list): self + { + $newList = $this->list; + + foreach ($list as $item) { + $index = $this->searchNumberInList($item->getNumber()); + + if ($index !== null) { + $newList[$index] = $item; + + continue; + } + + $newList[] = $item; + } + + return self::create($newList); + } + + /** + * Clone with an added phone number. + */ + public function withAdded(PhoneNumber $phoneNumber): self + { + return $this->withAddedList([$phoneNumber]); + } + + /** + * Clone with removed phone number. + */ + public function withRemoved(PhoneNumber $phoneNumber): self + { + return $this->withRemovedByNumber($phoneNumber->getNumber()); + } + + /** + * Clone with removed phone number passed by a number. + */ + public function withRemovedByNumber(string $number): self + { + $newList = $this->list; + + $index = $this->searchNumberInList($number); + + if ($index !== null) { + unset($newList[$index]); + + $newList = array_values($newList); + } + + return self::create($newList); + } + + /** + * Create with an optional phone number list. A first item will be set as primary. + * + * @param PhoneNumber[] $list + * @throws InvalidArgumentException + */ + public static function create(array $list = []): self + { + return new self($list); + } + + private function searchNumberInList(string $number): ?int + { + foreach ($this->getNumberList() as $i => $item) { + if ($item === $number) { + return $i; + } + } + + return null; + } + + private function validateList(): void + { + $numberList = []; + + foreach ($this->list as $item) { + if (!$item instanceof PhoneNumber) { + throw new InvalidArgumentException("Bad item."); + } + + if (in_array($item->getNumber(), $numberList)) { + throw new InvalidArgumentException("Number list contains a duplicate."); + } + + $numberList[] = strtolower($item->getNumber()); + } + } + + private function isEmpty(): bool + { + return count($this->list) === 0; + } +} diff --git a/application/Espo/Core/FieldProcessing/EmailAddress/AccessChecker.php b/application/Espo/Core/FieldProcessing/EmailAddress/AccessChecker.php new file mode 100644 index 0000000..858e820 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/EmailAddress/AccessChecker.php @@ -0,0 +1,74 @@ +. + * + * 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\FieldProcessing\EmailAddress; + +use Espo\Repositories\EmailAddress as Repository; +use Espo\ORM\Entity; + +use Espo\Entities\EmailAddress; +use Espo\Entities\User; + +use Espo\Core\AclManager; +use Espo\Core\ORM\EntityManager; + +class AccessChecker +{ + public function __construct( + private EntityManager $entityManager, + private AclManager $aclManager + ) {} + + public function checkEdit(User $user, EmailAddress $emailAddress, Entity $excludeEntity): bool + { + /** @var Repository $repository */ + $repository = $this->entityManager->getRepository('EmailAddress'); + + $entityWithSameAddressList = $repository->getEntityListByAddressId($emailAddress->getId(), $excludeEntity); + + foreach ($entityWithSameAddressList as $e) { + if ($this->aclManager->checkEntityEdit($user, $e)) { + continue; + } + + if ( + $e instanceof User && + $e->isPortal() && + $excludeEntity->getEntityType() === 'Contact' && + $e->get('contactId') === $excludeEntity->getEntityType() + ) { + continue; + } + + return false; + } + + return true; + } +} diff --git a/application/Espo/Core/FieldProcessing/EmailAddress/Loader.php b/application/Espo/Core/FieldProcessing/EmailAddress/Loader.php new file mode 100644 index 0000000..5d1e9e5 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/EmailAddress/Loader.php @@ -0,0 +1,67 @@ +. + * + * 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\FieldProcessing\EmailAddress; + +use Espo\Repositories\EmailAddress as Repository; +use Espo\ORM\Entity; +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; +use Espo\ORM\Defs as OrmDefs; + +/** + * @implements LoaderInterface + */ +class Loader implements LoaderInterface +{ + public function __construct(private OrmDefs $ormDefs, private EntityManager $entityManager) + {} + + public function process(Entity $entity, Params $params): void + { + $entityDefs = $this->ormDefs->getEntity($entity->getEntityType()); + + if (!$entityDefs->hasField('emailAddress')) { + return; + } + + if ($entityDefs->getField('emailAddress')->getType() !== 'email') { + return; + } + + /** @var Repository $repository */ + $repository = $this->entityManager->getRepository('EmailAddress'); + + $emailAddressData = $repository->getEmailAddressData($entity); + + $entity->set('emailAddressData', $emailAddressData); + $entity->setFetched('emailAddressData', $emailAddressData); + } +} diff --git a/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php b/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php new file mode 100644 index 0000000..19e55cb --- /dev/null +++ b/application/Espo/Core/FieldProcessing/EmailAddress/Saver.php @@ -0,0 +1,596 @@ +. + * + * 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\FieldProcessing\EmailAddress; + +use Espo\Core\FieldProcessing\Loader\Params as LoaderParams; +use Espo\Core\Name\Link; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\ORM\Type\FieldType; +use Espo\Entities\EmailAddress; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Repository\RDBRelation; +use Espo\Repositories\EmailAddress as EmailAddressRepository; +use Espo\ORM\Entity; +use Espo\Core\ApplicationState; +use Espo\Core\FieldProcessing\Saver as SaverInterface; +use Espo\Core\FieldProcessing\Saver\Params; +use Espo\Core\ORM\EntityManager; + +/** + * @implements SaverInterface + */ +class Saver implements SaverInterface +{ + private const string ATTR_EMAIL_ADDRESS = 'emailAddress'; + private const string ATTR_EMAIL_ADDRESS_DATA = 'emailAddressData'; + private const string ATTR_EMAIL_ADDRESS_IS_OPTED_OUT = 'emailAddressIsOptedOut'; + private const string ATTR_EMAIL_ADDRESS_IS_INVALID = 'emailAddressIsInvalid'; + + private const string LINK_EMAIL_ADDRESSES = Link::EMAIL_ADDRESSES; + + public function __construct( + private EntityManager $entityManager, + private ApplicationState $applicationState, + private AccessChecker $accessChecker, + private Loader $loader, + ) {} + + public function process(Entity $entity, Params $params): void + { + $entityType = $entity->getEntityType(); + + $defs = $this->entityManager->getDefs()->getEntity($entityType); + + if (!$defs->hasField(self::ATTR_EMAIL_ADDRESS)) { + return; + } + + if ($defs->getField(self::ATTR_EMAIL_ADDRESS)->getType() !== FieldType::EMAIL) { + return; + } + + $emailAddressData = null; + + if ($entity->has(self::ATTR_EMAIL_ADDRESS_DATA)) { + $emailAddressData = $entity->get(self::ATTR_EMAIL_ADDRESS_DATA); + } + + if ($emailAddressData !== null && $entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_DATA)) { + $this->storeData($entity); + + return; + } + + if ($entity->has(self::ATTR_EMAIL_ADDRESS)) { + $this->storePrimaryAndPrepareData($entity); + } + } + + private function storeData(Entity $entity): void + { + if (!$entity->has(self::ATTR_EMAIL_ADDRESS_DATA)) { + return; + } + + $emailAddressValue = $entity->get(self::ATTR_EMAIL_ADDRESS); + + if (is_string($emailAddressValue)) { + $emailAddressValue = trim($emailAddressValue); + } + + $emailAddressData = $entity->get(self::ATTR_EMAIL_ADDRESS_DATA); + + if (!is_array($emailAddressData)) { + return; + } + + $noPrimary = array_filter($emailAddressData, fn ($item) => !empty($item->primary)) === []; + + if ($noPrimary && $emailAddressData !== []) { + $emailAddressData[0]->primary = true; + } + + $keyList = []; + $keyPreviousList = []; + $previousEmailAddressData = []; + + if (!$entity->isNew()) { + /** @var EmailAddressRepository $repository */ + $repository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + $previousEmailAddressData = $repository->getEmailAddressData($entity); + } + + $hash = (object) []; + $hashPrevious = (object) []; + + foreach ($emailAddressData as $row) { + $key = trim($row->emailAddress); + + if (empty($key)) { + continue; + } + + $key = strtolower($key); + + $hash->$key = [ + 'primary' => !empty($row->primary), + 'optOut' => !empty($row->optOut), + 'invalid' => !empty($row->invalid), + 'emailAddress' => trim($row->emailAddress), + ]; + + $keyList[] = $key; + } + + if ( + $entity->has(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) && + ( + $entity->isNew() || + ( + $entity->hasFetched(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) && + $entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) + ) + ) && + $emailAddressValue + ) { + $key = strtolower($emailAddressValue); + + if ($key && isset($hash->$key)) { + $hash->{$key}['optOut'] = (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT); + } + } + + if ( + $entity->has(self::ATTR_EMAIL_ADDRESS_IS_INVALID) && + ( + $entity->isNew() || + ( + $entity->hasFetched(self::ATTR_EMAIL_ADDRESS_IS_INVALID) && + $entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_IS_INVALID) + ) + ) && + $emailAddressValue + ) { + $key = strtolower($emailAddressValue); + + if ($key && isset($hash->$key)) { + $hash->{$key}['invalid'] = (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_INVALID); + } + } + + foreach ($previousEmailAddressData as $row) { + $key = $row->lower; + + if (empty($key)) { + continue; + } + + $hashPrevious->$key = [ + 'primary' => (bool) $row->primary, + 'optOut' => (bool) $row->optOut, + 'invalid' => (bool) $row->invalid, + 'emailAddress' => $row->emailAddress, + ]; + + $keyPreviousList[] = $key; + } + + $primary = null; + + $toCreateList = []; + $toUpdateList = []; + $toRemoveList = []; + + $revertData = []; + + foreach ($keyList as $key) { + $new = true; + $changed = false; + + if ($hash->{$key}['primary']) { + $primary = $key; + } + + if (property_exists($hashPrevious, $key)) { + $new = false; + + $changed = + $hash->{$key}['optOut'] != $hashPrevious->{$key}['optOut'] || + $hash->{$key}['invalid'] != $hashPrevious->{$key}['invalid'] || + $hash->{$key}['emailAddress'] !== $hashPrevious->{$key}['emailAddress']; + + if ( + $hash->{$key}['primary'] && + $hash->{$key}['primary'] === $hashPrevious->{$key}['primary'] + ) { + $primary = null; + } + } + + if ($new) { + $toCreateList[] = $key; + } + + if ($changed) { + $toUpdateList[] = $key; + } + } + + foreach ($keyPreviousList as $key) { + if (!property_exists($hash, $key)) { + $toRemoveList[] = $key; + } + } + + foreach ($toRemoveList as $address) { + $emailAddress = $this->getByAddress($address); + + if (!$emailAddress) { + continue; + } + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from('EntityEmailAddress') + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'emailAddressId' => $emailAddress->getId(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + + foreach ($toUpdateList as $address) { + $emailAddress = $this->getByAddress($address); + + if ($emailAddress) { + $skipSave = $this->checkChangeIsForbidden($emailAddress, $entity); + + if (!$skipSave) { + $emailAddress->set([ + 'optOut' => $hash->{$address}['optOut'], + 'invalid' => $hash->{$address}['invalid'], + 'name' => $hash->{$address}['emailAddress'] + ]); + + $this->entityManager->saveEntity($emailAddress); + } else { + $revertData[$address] = [ + 'optOut' => $emailAddress->isOptedOut(), + 'invalid' => $emailAddress->isInvalid(), + ]; + } + } + } + + foreach ($toCreateList as $address) { + $emailAddress = $this->getByAddress($address); + + if (!$emailAddress) { + $emailAddress = $this->entityManager->getNewEntity(EmailAddress::ENTITY_TYPE); + + $emailAddress->set([ + 'name' => $hash->{$address}['emailAddress'], + 'optOut' => $hash->{$address}['optOut'], + 'invalid' => $hash->{$address}['invalid'], + ]); + + $this->entityManager->saveEntity($emailAddress); + } else { + $skipSave = $this->checkChangeIsForbidden($emailAddress, $entity); + + if (!$skipSave) { + if ( + $emailAddress->get('optOut') != $hash->{$address}['optOut'] || + $emailAddress->get('invalid') != $hash->{$address}['invalid'] || + $emailAddress->get(self::ATTR_EMAIL_ADDRESS) != $hash->{$address}['emailAddress'] + ) { + $emailAddress->set([ + 'optOut' => $hash->{$address}['optOut'], + 'invalid' => $hash->{$address}['invalid'], + 'name' => $hash->{$address}['emailAddress'] + ]); + + $this->entityManager->saveEntity($emailAddress); + } + } else { + $revertData[$address] = [ + 'optOut' => $emailAddress->isOptedOut(), + 'invalid' => $emailAddress->isInvalid(), + ]; + } + } + + $entityEmailAddress = $this->entityManager->getNewEntity('EntityEmailAddress'); + + $entityEmailAddress->setMultiple([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'emailAddressId' => $emailAddress->getId(), + 'primary' => $address === $primary, + Attribute::DELETED => false, + ]); + + $mapper = $this->entityManager->getMapper(); + + $mapper->insertOnDuplicateUpdate($entityEmailAddress, [ + 'primary', + Attribute::DELETED, + ]); + } + + if ($primary) { + $emailAddress = $this->getByAddress($primary); + + $entity->set(self::ATTR_EMAIL_ADDRESS, $primary); + + if ($emailAddress) { + $update1 = $this->entityManager + ->getQueryBuilder() + ->update() + ->in('EntityEmailAddress') + ->set(['primary' => false]) + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'primary' => true, + Attribute::DELETED => false, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($update1); + + $update2 = $this->entityManager + ->getQueryBuilder() + ->update() + ->in('EntityEmailAddress') + ->set(['primary' => true]) + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'emailAddressId' => $emailAddress->getId(), + Attribute::DELETED => false, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($update2); + } + } + + if (!empty($revertData)) { + foreach ($emailAddressData as $row) { + if (empty($revertData[$row->emailAddress])) { + continue; + } + + $row->optOut = $revertData[$row->emailAddress]['optOut']; + $row->invalid = $revertData[$row->emailAddress]['invalid']; + } + + $entity->set(self::ATTR_EMAIL_ADDRESS_DATA, $emailAddressData); + } + } + + private function storePrimary(Entity $entity): void + { + if (!$entity->has(self::ATTR_EMAIL_ADDRESS)) { + return; + } + + $address = $entity->get(self::ATTR_EMAIL_ADDRESS); + + if (is_string($address)) { + $address = trim($address); + } + + if ($address) { + $this->storePrimaryNotEmpty($entity, $address); + + return; + } + + $this->storePrimaryEmpty($entity); + } + + private function storePrimaryNotEmpty(Entity $entity, string $emailAddressValue): void + { + if ($emailAddressValue === $entity->getFetched(self::ATTR_EMAIL_ADDRESS)) { + if ( + $entity->has(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) && + ( + $entity->isNew() || + ( + $entity->hasFetched(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) && + $entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT) + ) + ) + ) { + $this->markAddressOptedOut($emailAddressValue, + (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT)); + } + + if ( + $entity->has(self::ATTR_EMAIL_ADDRESS_IS_INVALID) && + ( + $entity->isNew() || + ( + $entity->hasFetched(self::ATTR_EMAIL_ADDRESS_IS_INVALID) && + $entity->isAttributeChanged(self::ATTR_EMAIL_ADDRESS_IS_INVALID) + ) + ) + ) { + $this->markAddressInvalid($emailAddressValue, (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_INVALID)); + } + + return; + } + + $entityRepository = $this->entityManager->getRDBRepository($entity->getEntityType()); + + $emailAddressNew = $this->entityManager + ->getRDBRepositoryByClass(EmailAddress::class) + ->where([ + 'lower' => strtolower($emailAddressValue), + ]) + ->findOne(); + + if (!$emailAddressNew) { + $emailAddressNew = $this->entityManager->getRDBRepositoryByClass(EmailAddress::class)->getNew(); + + $emailAddressNew->setAddress($emailAddressValue); + + if ($entity->has(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT)) { + $emailAddressNew->setOptedOut((bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT)); + } + + if ($entity->has(self::ATTR_EMAIL_ADDRESS_IS_INVALID)) { + $emailAddressNew->setInvalid((bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_INVALID)); + } + + $this->entityManager->saveEntity($emailAddressNew); + } + + $emailAddressValueOld = $entity->getFetched(self::ATTR_EMAIL_ADDRESS); + + if ($emailAddressValueOld) { + $emailAddressOld = $this->getByAddress($emailAddressValueOld); + + if ($emailAddressOld) { + $entityRepository + ->getRelation($entity, self::LINK_EMAIL_ADDRESSES) + ->unrelate($emailAddressOld, [SaveOption::SKIP_HOOKS => true]); + } + } + + $entityRepository + ->getRelation($entity, self::LINK_EMAIL_ADDRESSES) + ->relate($emailAddressNew, null, [SaveOption::SKIP_HOOKS => true]); + + if ($entity->has(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT)) { + $this->markAddressOptedOut($emailAddressValue, (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_OPTED_OUT)); + } + + if ($entity->has(self::ATTR_EMAIL_ADDRESS_IS_INVALID)) { + $this->markAddressInvalid($emailAddressValue, (bool) $entity->get(self::ATTR_EMAIL_ADDRESS_IS_INVALID)); + } + + $updateQuery = $this->entityManager + ->getQueryBuilder() + ->update() + ->in(EmailAddress::RELATION_ENTITY_EMAIL_ADDRESS) + ->set(['primary' => true]) + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'emailAddressId' => $emailAddressNew->getId(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($updateQuery); + } + + private function getByAddress(string $address): ?EmailAddress + { + /** @var EmailAddressRepository $repository */ + $repository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + return $repository->getByAddress($address); + } + + private function markAddressOptedOut(string $address, bool $isOptedOut = true): void + { + /** @var EmailAddressRepository $repository */ + $repository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + $repository->markAddressOptedOut($address, $isOptedOut); + } + + private function markAddressInvalid(string $address, bool $isInvalid = true): void + { + /** @var EmailAddressRepository $repository */ + $repository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + $repository->markAddressInvalid($address, $isInvalid); + } + + private function checkChangeIsForbidden(EmailAddress $emailAddress, Entity $entity): bool + { + if (!$this->applicationState->hasUser()) { + return true; + } + + $user = $this->applicationState->getUser(); + + // @todo Check if not modified by system. + + return !$this->accessChecker->checkEdit($user, $emailAddress, $entity); + } + + private function storePrimaryAndPrepareData(Entity $entity): void + { + $this->loader->process($entity, LoaderParams::create()); + $previous = $entity->get(self::ATTR_EMAIL_ADDRESS_DATA); + + $this->storePrimary($entity); + + $this->loader->process($entity, LoaderParams::create()); + $entity->setFetched(self::ATTR_EMAIL_ADDRESS_DATA, $previous); + } + + private function storePrimaryEmpty(Entity $entity): void + { + $fetchedAddress = $entity->getFetched(self::ATTR_EMAIL_ADDRESS); + + if (!$fetchedAddress) { + return; + } + + /** @var RDBRelation $relation */ + $relation = $this->entityManager->getRelation($entity, self::LINK_EMAIL_ADDRESSES); + + $emailAddressOld = $this->getByAddress($fetchedAddress); + + if ($emailAddressOld) { + $relation->unrelate($emailAddressOld, [SaveOption::SKIP_HOOKS => true]); + } + + $one = $relation->findOne(); + + if (!$one) { + return; + } + + $relation->updateColumns($one, ['primary' => true]); + } +} diff --git a/application/Espo/Core/FieldProcessing/File/Saver.php b/application/Espo/Core/FieldProcessing/File/Saver.php new file mode 100644 index 0000000..9a297ff --- /dev/null +++ b/application/Espo/Core/FieldProcessing/File/Saver.php @@ -0,0 +1,156 @@ +. + * + * 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\FieldProcessing\File; + +use Espo\Entities\Attachment; +use Espo\ORM\Entity; + +use Espo\Core\FieldProcessing\Saver as SaverInterface; +use Espo\Core\FieldProcessing\Saver\Params; +use Espo\Core\ORM\EntityManager; + +/** + * @implements SaverInterface + */ +class Saver implements SaverInterface +{ + private EntityManager $entityManager; + + /** @var array*/ + private $fieldListMapCache = []; + + public function __construct(EntityManager $entityManager) + { + $this->entityManager = $entityManager; + } + + public function process(Entity $entity, Params $params): void + { + foreach ($this->getFieldList($entity->getEntityType()) as $name) { + $this->processItem($entity, $name); + } + } + + private function processItem(Entity $entity, string $name): void + { + $attribute = $name . 'Id'; + + if (!$entity->isAttributeChanged($attribute)) { + return; + } + + $id = $entity->get($attribute); + + if (!$id) { + $this->removePrevious($entity, $attribute); + + return; + } + + $attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id); + + if (!$attachment) { + return; + } + + $attachment->set([ + 'relatedId' => $entity->getId(), + 'relatedType' => $entity->getEntityType(), + ]); + + $this->entityManager->saveEntity($attachment); + + if ($entity->isNew()) { + return; + } + + $this->removePrevious($entity, $attribute); + } + + private function removePrevious(Entity $entity, string $attribute): void + { + $previousId = $entity->getFetched($attribute); + + if (!$previousId) { + return; + } + + $previousAttachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $previousId); + + if (!$previousAttachment) { + return; + } + + $this->entityManager->removeEntity($previousAttachment); + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType): array + { + if (array_key_exists($entityType, $this->fieldListMapCache)) { + return $this->fieldListMapCache[$entityType]; + } + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType); + + $list = []; + + foreach ($entityDefs->getRelationNameList() as $name) { + $defs = $entityDefs->getRelation($name); + + $type = $defs->getType(); + + if (!$defs->hasForeignEntityType()) { + continue; + } + + $foreignEntityType = $defs->getForeignEntityType(); + + if ($type !== Entity::BELONGS_TO || $foreignEntityType !== 'Attachment') { + continue; + } + + + if (!$entityDefs->hasAttribute($name . 'Id')) { + continue; + } + + $list[] = $name; + } + + $this->fieldListMapCache[$entityType] = $list; + + return $list; + } +} diff --git a/application/Espo/Core/FieldProcessing/Link/HasOneLoader.php b/application/Espo/Core/FieldProcessing/Link/HasOneLoader.php new file mode 100644 index 0000000..6bfdcdc --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Link/HasOneLoader.php @@ -0,0 +1,111 @@ +. + * + * 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\FieldProcessing\Link; + +use Espo\Core\ORM\Type\FieldType; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; + +use Espo\ORM\Defs as OrmDefs; + +/** + * @implements LoaderInterface + */ +class HasOneLoader implements LoaderInterface +{ + private OrmDefs $ormDefs; + + /** + * @var array + */ + private $fieldListCacheMap = []; + + public function __construct(OrmDefs $ormDefs) + { + $this->ormDefs = $ormDefs; + } + + public function process(Entity $entity, Params $params): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + foreach ($this->getFieldList($entity->getEntityType()) as $field) { + if ($entity->get($field . 'Name')) { + continue; + } + + $entity->loadLinkField($field); + } + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType): array + { + if (array_key_exists($entityType, $this->fieldListCacheMap)) { + return $this->fieldListCacheMap[$entityType]; + } + + $list = []; + + $entityDefs = $this->ormDefs->getEntity($entityType); + + foreach ($entityDefs->getFieldList() as $fieldDefs) { + if ($fieldDefs->getType() !== FieldType::LINK) { + continue; + } + + if ($fieldDefs->getParam('noLoad')) { + continue; + } + + $name = $fieldDefs->getName(); + + if (!$entityDefs->hasRelation($name)) { + continue; + } + + if ($entityDefs->getRelation($name)->getType() !== Entity::HAS_ONE) { + continue; + } + + $list[] = $name; + } + + $this->fieldListCacheMap[$entityType] = $list; + + return $list; + } +} diff --git a/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php b/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php new file mode 100644 index 0000000..0905405 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Link/NotJoinedLoader.php @@ -0,0 +1,160 @@ +. + * + * 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\FieldProcessing\Link; + +use Espo\Core\Name\Field; +use Espo\ORM\Entity; + +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; +use Espo\ORM\Defs as OrmDefs; +use Espo\ORM\Name\Attribute; + +/** + * @implements LoaderInterface + */ +class NotJoinedLoader implements LoaderInterface +{ + /** @var array */ + private array $fieldListCacheMap = []; + + public function __construct( + private OrmDefs $ormDefs, + private EntityManager $entityManager + ) {} + + public function process(Entity $entity, Params $params): void + { + foreach ($this->getFieldList($entity->getEntityType()) as $field) { + $this->processItem($entity, $field); + } + } + + private function processItem(Entity $entity, string $field): void + { + $nameAttribute = $field . 'Name'; + $idAttribute = $field . 'Id'; + + $id = $entity->get($idAttribute); + + if (!$id) { + $entity->set($nameAttribute, null); + + return; + } + + if ($entity->get($nameAttribute)) { + return; + } + + $foreignEntityType = $this->ormDefs + ->getEntity($entity->getEntityType()) + ->getRelation($field) + ->getForeignEntityType(); + + $foreignEntity = $this->entityManager + ->getRDBRepository($foreignEntityType) + ->select([Attribute::ID, Field::NAME]) + ->where([Attribute::ID => $id]) + ->findOne(); + + if (!$foreignEntity) { + $entity->set($nameAttribute, null); + + return; + } + + $name = $foreignEntity->get(Field::NAME); + + if ($name === null) { + $foreignEntity = $this->entityManager + ->getRDBRepository($foreignEntityType) + ->getById($id); + + if ($foreignEntity) { + $name = $foreignEntity->get(Field::NAME); + } + } + + $entity->set($nameAttribute, $name); + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType): array + { + if (array_key_exists($entityType, $this->fieldListCacheMap)) { + return $this->fieldListCacheMap[$entityType]; + } + + $list = []; + + $entityDefs = $this->ormDefs->getEntity($entityType); + + foreach ($entityDefs->getRelationList() as $relationDefs) { + if ($relationDefs->getType() !== Entity::BELONGS_TO) { + continue; + } + + // Commented to load name of leads w/o person name. + /*if (!$relationDefs->getParam('noJoin')) { + continue; + }*/ + + if (!$relationDefs->hasForeignEntityType()) { + continue; + } + + $foreignEntityType = $relationDefs->getForeignEntityType(); + + if (!$this->entityManager->hasRepository($foreignEntityType)) { + continue; + } + + $name = $relationDefs->getName(); + + if (!$entityDefs->hasAttribute($name . 'Id')) { + continue; + } + + if (!$entityDefs->hasAttribute($name . 'Name')) { + continue; + } + + $list[] = $name; + } + + $this->fieldListCacheMap[$entityType] = $list; + + return $list; + } +} diff --git a/application/Espo/Core/FieldProcessing/LinkMultiple/ListLoader.php b/application/Espo/Core/FieldProcessing/LinkMultiple/ListLoader.php new file mode 100644 index 0000000..bab9a04 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/LinkMultiple/ListLoader.php @@ -0,0 +1,127 @@ +. + * + * 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\FieldProcessing\LinkMultiple; + +use Espo\Core\ORM\Type\FieldType; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; + +use Espo\ORM\Defs as OrmDefs; + +/** + * @implements LoaderInterface + */ +class ListLoader implements LoaderInterface +{ + /** @var array */ + private array $fieldListCacheMap = []; + + public function __construct(private OrmDefs $ormDefs) + {} + + public function process(Entity $entity, Params $params): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + $entityType = $entity->getEntityType(); + + $select = $params->getSelect() ?? []; + + if (count($select) === 0) { + return; + } + + foreach ($this->getFieldList($entityType) as $field) { + if ( + !in_array($field . 'Ids', $select) && + !in_array($field . 'Names', $select) && + ( + !$entity->hasAttribute($field . 'Columns') || + !in_array($field . 'Columns', $select) + ) + ) { + continue; + } + + if ($entity->has($field . 'Ids') && $entity->has($field . 'Names')) { + continue; + } + + $entity->loadLinkMultipleField($field); + } + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType): array + { + if (array_key_exists($entityType, $this->fieldListCacheMap)) { + return $this->fieldListCacheMap[$entityType]; + } + + $list = []; + + $entityDefs = $this->ormDefs->getEntity($entityType); + + foreach ($entityDefs->getFieldList() as $fieldDefs) { + if ( + $fieldDefs->getType() !== FieldType::LINK_MULTIPLE && + $fieldDefs->getType() !== FieldType::ATTACHMENT_MULTIPLE + ) { + continue; + } + + if ($fieldDefs->getParam('noLoad')) { + continue; + } + + if ($fieldDefs->isNotStorable()) { + continue; + } + + $name = $fieldDefs->getName(); + + if (!$entityDefs->hasRelation($name)) { + continue; + } + + $list[] = $name; + } + + $this->fieldListCacheMap[$entityType] = $list; + + return $list; + } +} diff --git a/application/Espo/Core/FieldProcessing/LinkMultiple/Loader.php b/application/Espo/Core/FieldProcessing/LinkMultiple/Loader.php new file mode 100644 index 0000000..f60c6b8 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/LinkMultiple/Loader.php @@ -0,0 +1,105 @@ +. + * + * 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\FieldProcessing\LinkMultiple; + +use Espo\Core\ORM\Type\FieldType; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\ORM\Defs as OrmDefs; + +/** + * @implements LoaderInterface + */ +class Loader implements LoaderInterface +{ + /** @var array */ + private array $fieldListCacheMap = []; + + public function __construct(private OrmDefs $ormDefs) + {} + + public function process(Entity $entity, Params $params): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + $entityType = $entity->getEntityType(); + + foreach ($this->getFieldList($entityType) as $field) { + $entity->loadLinkMultipleField($field); + } + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType): array + { + if (array_key_exists($entityType, $this->fieldListCacheMap)) { + return $this->fieldListCacheMap[$entityType]; + } + + $list = []; + + $entityDefs = $this->ormDefs->getEntity($entityType); + + foreach ($entityDefs->getFieldList() as $fieldDefs) { + if ( + $fieldDefs->getType() !== FieldType::LINK_MULTIPLE && + $fieldDefs->getType() !== FieldType::ATTACHMENT_MULTIPLE + ) { + continue; + } + + if ($fieldDefs->getParam('noLoad')) { + continue; + } + + if ($fieldDefs->isNotStorable()) { + continue; + } + + $name = $fieldDefs->getName(); + + if (!$entityDefs->hasRelation($name)) { + continue; + } + + $list[] = $name; + } + + $this->fieldListCacheMap[$entityType] = $list; + + return $list; + } +} diff --git a/application/Espo/Core/FieldProcessing/LinkParent/Loader.php b/application/Espo/Core/FieldProcessing/LinkParent/Loader.php new file mode 100644 index 0000000..a275f75 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/LinkParent/Loader.php @@ -0,0 +1,89 @@ +. + * + * 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\FieldProcessing\LinkParent; + +use Espo\Core\ORM\Type\FieldType; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; + +use Espo\ORM\Defs as OrmDefs; + +/** + * @implements LoaderInterface + */ +class Loader implements LoaderInterface +{ + /** @var array */ + private array $fieldListCacheMap = []; + + public function __construct(private OrmDefs $ormDefs) + {} + + public function process(Entity $entity, Params $params): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + foreach ($this->getFieldList($entity->getEntityType()) as $field) { + $entity->loadParentNameField($field); + } + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType): array + { + if (array_key_exists($entityType, $this->fieldListCacheMap)) { + return $this->fieldListCacheMap[$entityType]; + } + + $list = []; + + $entityDefs = $this->ormDefs->getEntity($entityType); + + foreach ($entityDefs->getFieldList() as $fieldDefs) { + if ($fieldDefs->getType() !== FieldType::LINK_PARENT) { + continue; + } + + $name = $fieldDefs->getName(); + + $list[] = $name; + } + + $this->fieldListCacheMap[$entityType] = $list; + + return $list; + } +} diff --git a/application/Espo/Core/FieldProcessing/LinkParent/TargetLoader.php b/application/Espo/Core/FieldProcessing/LinkParent/TargetLoader.php new file mode 100644 index 0000000..ae0d7f0 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/LinkParent/TargetLoader.php @@ -0,0 +1,86 @@ +. + * + * 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\FieldProcessing\LinkParent; + +use Espo\Core\Name\Field; +use Espo\ORM\Entity; + +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +/** + * @implements LoaderInterface + */ +class TargetLoader implements LoaderInterface +{ + public function __construct(private EntityManager $entityManager) + {} + + public function process(Entity $entity, Params $params): void + { + $targetType = $entity->get('targetType'); + $targetId = $entity->get('targetId'); + + if (!$targetType || !$targetId) { + return; + } + + if (!$this->entityManager->hasRepository($targetType)) { + return; + } + + $query = $this->entityManager + ->getQueryBuilder() + ->select() + ->from($targetType) + ->withDeleted() + ->where([ + Attribute::ID => $targetId, + ]) + ->build(); + + $target = $this->entityManager + ->getRDBRepository($targetType) + ->clone($query) + ->findOne(); + + if (!$target) { + return; + } + + if (!$target->get(Field::NAME)) { + return; + } + + $entity->set('targetName', $target->get(Field::NAME)); + } +} diff --git a/application/Espo/Core/FieldProcessing/ListLoadProcessor.php b/application/Espo/Core/FieldProcessing/ListLoadProcessor.php new file mode 100644 index 0000000..586fcee --- /dev/null +++ b/application/Espo/Core/FieldProcessing/ListLoadProcessor.php @@ -0,0 +1,163 @@ +. + * + * 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\FieldProcessing; + +use Espo\Core\Acl; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Utils\FieldUtil; +use Espo\Entities\User; +use Espo\ORM\Defs; +use Espo\ORM\Entity; + +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +/** + * Processes loading special fields for list view (before output). + */ +class ListLoadProcessor +{ + /** @var array[]> */ + private $loaderListMapCache = []; + + private BindingContainer $bindingContainer; + + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private Acl $acl, + private User $user, + private Defs $defs, + private FieldUtil $fieldUtil, + ) { + $this->bindingContainer = BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->bindInstance(Acl::class, $this->acl) + ->build(); + } + + public function process(Entity $entity, ?Params $params = null): void + { + if (!$params) { + $params = new Params(); + } + + foreach ($this->getLoaderList($entity->getEntityType(), $params) as $processor) { + $processor->process($entity, $params); + } + } + + /** + * @return Loader[] + */ + private function getLoaderList(string $entityType, Params $params): array + { + if (array_key_exists($entityType, $this->loaderListMapCache)) { + return $this->loaderListMapCache[$entityType]; + } + + $list = []; + + foreach ($this->getLoaderClassNameList($entityType, $params) as $className) { + $list[] = $this->createLoader($className); + } + + $this->loaderListMapCache[$entityType] = $list; + + return $list; + } + + /** + * @return class-string>[] + */ + private function getLoaderClassNameList(string $entityType, Params $params): array + { + $entityLevelList = $this->getEntityLevelClassNameList($entityType, $params); + + $list = $this->metadata + ->get(['app', 'fieldProcessing', 'listLoaderClassNameList']) ?? []; + + $additionalList = $this->metadata + ->get(['recordDefs', $entityType, 'listLoaderClassNameList']) ?? []; + + $list = array_merge($list, $additionalList, $entityLevelList); + + return array_values(array_unique($list)); + } + + /** + * @param class-string> $className + * @return Loader + */ + private function createLoader(string $className): Loader + { + return $this->injectableFactory->createWithBinding($className, $this->bindingContainer); + } + + /** + * @return class-string>[] + */ + private function getEntityLevelClassNameList(string $entityType, Params $params): array + { + $entityLevelList = []; + + $fieldList = $this->defs->getEntity($entityType)->getFieldList(); + + foreach ($fieldList as $fieldDefs) { + $className = $fieldDefs->getParam('loaderClassName'); + + if (!$className || in_array($className, $entityLevelList)) { + continue; + } + + if ($params->hasSelect()) { + $hasAttribute = false; + + foreach ($this->fieldUtil->getAttributeList($entityType, $fieldDefs->getName()) as $attribute) { + if ($params->hasInSelect($attribute)) { + $hasAttribute = true; + + break; + } + } + + if (!$hasAttribute) { + continue; + } + } + + $entityLevelList[] = $className; + } + + return $entityLevelList; + } +} diff --git a/application/Espo/Core/FieldProcessing/Loader.php b/application/Espo/Core/FieldProcessing/Loader.php new file mode 100644 index 0000000..b6b9afe --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Loader.php @@ -0,0 +1,47 @@ +. + * + * 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\FieldProcessing; + +use Espo\ORM\Entity; + +use Espo\Core\FieldProcessing\Loader\Params; + +/** + * Processes loading special fields before output. + * + * @template TEntity of Entity + */ +interface Loader +{ + /** + * @param TEntity $entity + */ + public function process(Entity $entity, Params $params): void; +} diff --git a/application/Espo/Core/FieldProcessing/Loader/Params.php b/application/Espo/Core/FieldProcessing/Loader/Params.php new file mode 100644 index 0000000..be5706a --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Loader/Params.php @@ -0,0 +1,75 @@ +. + * + * 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\FieldProcessing\Loader; + +/** + * Immutable. + */ +class Params +{ + /** @var ?string[] */ + private ?array $select = null; + + public function __construct() {} + + public function hasInSelect(string $field): bool + { + return $this->hasSelect() && in_array($field, $this->select ?? []); + } + + public function hasSelect(): bool + { + return $this->select !== null; + } + + /** + * @return ?string[] + */ + public function getSelect(): ?array + { + return $this->select; + } + + /** + * @param ?string[] $select + */ + public function withSelect(?array $select): self + { + $obj = clone $this; + $obj->select = $select; + + return $obj; + } + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/FieldProcessing/MultiEnum/Saver.php b/application/Espo/Core/FieldProcessing/MultiEnum/Saver.php new file mode 100644 index 0000000..09a4516 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/MultiEnum/Saver.php @@ -0,0 +1,125 @@ +. + * + * 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\FieldProcessing\MultiEnum; + +use Espo\Entities\ArrayValue; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; + +use Espo\Repositories\ArrayValue as Repository; + +use Espo\Core\FieldProcessing\Saver as SaverInterface; +use Espo\Core\FieldProcessing\Saver\Params; +use Espo\Core\ORM\EntityManager; + +/** + * @implements SaverInterface + */ +class Saver implements SaverInterface +{ + private EntityManager $entityManager; + /** @var array */ + private $fieldListMapCache = []; + + public function __construct(EntityManager $entityManager) + { + $this->entityManager = $entityManager; + } + + public function process(Entity $entity, Params $params): void + { + foreach ($this->getFieldList($entity->getEntityType()) as $name) { + $this->processItem($entity, $name); + } + } + + private function processItem(Entity $entity, string $name): void + { + if (!$entity->has($name)) { + return; + } + + if (!$entity->isAttributeChanged($name)) { + return; + } + + /** @var Repository $repository */ + $repository = $this->entityManager->getRepository(ArrayValue::ENTITY_TYPE); + + assert($entity instanceof CoreEntity); + + $repository->storeEntityAttribute($entity, $name); + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType): array + { + if (array_key_exists($entityType, $this->fieldListMapCache)) { + return $this->fieldListMapCache[$entityType]; + } + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType); + + $list = []; + + foreach ($entityDefs->getAttributeNameList() as $name) { + $defs = $entityDefs->getAttribute($name); + + if ($defs->getType() !== Entity::JSON_ARRAY) { + continue; + } + + if (!$defs->getParam('storeArrayValues')) { + continue; + } + + if ( + $entityDefs->hasField($name) && + $entityDefs->getField($name)->getParam('doNotStoreArrayValues') + ) { + continue; + } + + if ($defs->isNotStorable()) { + continue; + } + + $list[] = $name; + } + + $this->fieldListMapCache[$entityType] = $list; + + return $list; + } +} diff --git a/application/Espo/Core/FieldProcessing/NextNumber/Processor.php b/application/Espo/Core/FieldProcessing/NextNumber/Processor.php new file mode 100644 index 0000000..9882ec9 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/NextNumber/Processor.php @@ -0,0 +1,230 @@ +. + * + * 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\FieldProcessing\NextNumber; + +use Espo\Core\Exceptions\Error; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\ORM\Entity; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Metadata; +use Espo\Entities\NextNumber; + +use Espo\ORM\Repository\Option\SaveOptions; +use RuntimeException; +use const STR_PAD_LEFT; + +/** + * @since 9.3.0 + */ +class Processor +{ + /** @var array */ + private $fieldListMapCache = []; + + public function __construct( + private Metadata $metadata, + private EntityManager $entityManager, + ) {} + + /** + * For an existing record. + * + * @throws Error + */ + public function processPopulate(Entity $entity, string $field): void + { + $fieldList = $this->getFieldList($entity->getEntityType()); + + if (!in_array($field, $fieldList)) { + throw new Error("Bad field."); + } + + $this->processItem($entity, $field, [], true); + } + + public function process(Entity $entity, SaveOptions $options): void + { + if (!$entity->isNew()) { + return; + } + + $fieldList = $this->getFieldList($entity->getEntityType()); + + foreach ($fieldList as $field) { + $this->processItem($entity, $field, $options->toAssoc()); + } + } + + /** + * To be invoked in custom hooks. The default hook must be suppressed. + * Useful when the number should change only if some conditions met. + * Processes even a non-new entity. + * + * @since 9.3.0 + * @noinspection PhpUnused + */ + public function processField(Entity $entity, string $field, SaveOptions $options): void + { + if ( + $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()) + ->getField($field) + ->getType() !== FieldType::NUMBER + ) { + throw new RuntimeException("Not a 'number' field."); + } + + $this->processItem( + entity: $entity, + field: $field, + options: $options->toAssoc(), + force: true, + ); + } + + /** + * @param array $options + */ + private function processItem( + Entity $entity, + string $field, + array $options, + bool $populate = false, + bool $force = false, + ): void { + + if (!empty($options[SaveOption::IMPORT]) && $entity->has($field)) { + return; + } + + if (!$entity->isNew() && !$force) { + if ($entity->isAttributeChanged($field)) { + $entity->set($field, $entity->getFetched($field)); + } + + if (!$populate) { + return; + } + } + + $this->entityManager->getTransactionManager()->run(function () use ($entity, $field) { + $nextNumber = $this->getNextNumberEntity($entity, $field); + + $entity->set($field, $this->composeNumberStringValue($nextNumber)); + + $nextNumber->setNumberValue($this->prepareNextNumberValue($nextNumber)); + + $this->entityManager->saveEntity($nextNumber); + }); + } + + private function composeNumberStringValue(NextNumber $nextNumber): string + { + $entityType = $nextNumber->getTargetEntityType(); + $fieldName = $nextNumber->getTargetFieldName(); + $value = $nextNumber->getNumberValue(); + + $prefix = $this->metadata->get(['entityDefs', $entityType, 'fields', $fieldName, 'prefix'], ''); + $padLength = $this->metadata->get(['entityDefs', $entityType, 'fields', $fieldName, 'padLength'], 0); + + return $prefix . str_pad(strval($value), $padLength, '0', STR_PAD_LEFT); + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType): array + { + if (array_key_exists($entityType, $this->fieldListMapCache)) { + return $this->fieldListMapCache[$entityType]; + } + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType); + + $list = []; + + foreach ($entityDefs->getFieldNameList() as $name) { + $defs = $entityDefs->getField($name); + + if ($defs->getType() !== FieldType::NUMBER) { + continue; + } + + if ($defs->getParam('suppressHook')) { + continue; + } + + $list[] = $name; + } + + $this->fieldListMapCache[$entityType] = $list; + + return $list; + } + + private function prepareNextNumberValue(NextNumber $nextNumber): int + { + $value = $nextNumber->getNumberValue(); + + if (!$value) { + $value = 1; + } + + $value++; + + return $value; + } + + private function getNextNumberEntity(Entity $entity, string $field): NextNumber + { + $nextNumber = $this->entityManager + ->getRDBRepositoryByClass(NextNumber::class) + ->where([ + 'fieldName' => $field, + 'entityType' => $entity->getEntityType(), + ]) + ->forUpdate() + ->findOne(); + + if (!$nextNumber) { + $nextNumber = $this->entityManager->getRDBRepositoryByClass(NextNumber::class)->getNew(); + + $nextNumber + ->setTargetEntityType($entity->getEntityType()) + ->setTargetFieldName($field); + } + + return $nextNumber; + } +} diff --git a/application/Espo/Core/FieldProcessing/PhoneNumber/AccessChecker.php b/application/Espo/Core/FieldProcessing/PhoneNumber/AccessChecker.php new file mode 100644 index 0000000..6c618be --- /dev/null +++ b/application/Espo/Core/FieldProcessing/PhoneNumber/AccessChecker.php @@ -0,0 +1,72 @@ +. + * + * 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\FieldProcessing\PhoneNumber; + +use Espo\Repositories\PhoneNumber as Repository; +use Espo\ORM\Entity; +use Espo\Entities\PhoneNumber; +use Espo\Entities\User; +use Espo\Core\AclManager; +use Espo\Core\ORM\EntityManager; + +class AccessChecker +{ + public function __construct( + private EntityManager $entityManager, + private AclManager $aclManager + ) {} + + public function checkEdit(User $user, PhoneNumber $phoneNumber, Entity $excludeEntity): bool + { + /** @var Repository $repository */ + $repository = $this->entityManager->getRepository('PhoneNumber'); + + $entityWithSameNumberList = $repository->getEntityListByPhoneNumberId($phoneNumber->getId(), $excludeEntity); + + foreach ($entityWithSameNumberList as $e) { + if ($this->aclManager->checkEntityEdit($user, $e)) { + continue; + } + + if ( + $e instanceof User && + $e->isPortal() && + $excludeEntity->getEntityType() === 'Contact' && + $e->get('contactId') === $excludeEntity->getId() + ) { + continue; + } + + return false; + } + + return true; + } +} diff --git a/application/Espo/Core/FieldProcessing/PhoneNumber/Loader.php b/application/Espo/Core/FieldProcessing/PhoneNumber/Loader.php new file mode 100644 index 0000000..74d57b5 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/PhoneNumber/Loader.php @@ -0,0 +1,70 @@ +. + * + * 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\FieldProcessing\PhoneNumber; + +use Espo\Core\ORM\Type\FieldType; +use Espo\Entities\PhoneNumber; +use Espo\ORM\Entity; +use Espo\Repositories\PhoneNumber as Repository; +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; + +use Espo\ORM\Defs as OrmDefs; + +/** + * @implements LoaderInterface + */ +class Loader implements LoaderInterface +{ + public function __construct(private OrmDefs $ormDefs, private EntityManager $entityManager) + {} + + public function process(Entity $entity, Params $params): void + { + $entityDefs = $this->ormDefs->getEntity($entity->getEntityType()); + + if (!$entityDefs->hasField('phoneNumber')) { + return; + } + + if ($entityDefs->getField('phoneNumber')->getType() !== FieldType::PHONE) { + return; + } + + /** @var Repository $repository */ + $repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE); + + $phoneNumberData = $repository->getPhoneNumberData($entity); + + $entity->set('phoneNumberData', $phoneNumberData); + $entity->setFetched('phoneNumberData', $phoneNumberData); + } +} diff --git a/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php b/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php new file mode 100644 index 0000000..1c15330 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/PhoneNumber/Saver.php @@ -0,0 +1,607 @@ +. + * + * 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\FieldProcessing\PhoneNumber; + +use Espo\Core\FieldProcessing\Loader\Params as LoeaderParams; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\ORM\Type\FieldType; +use Espo\Entities\PhoneNumber; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Repository\RDBRelation; +use Espo\Repositories\PhoneNumber as PhoneNumberRepository; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Mapper\BaseMapper; +use Espo\Core\ApplicationState; +use Espo\Core\FieldProcessing\Saver as SaverInterface; +use Espo\Core\FieldProcessing\Saver\Params; +use Espo\Core\Utils\Metadata; + +/** + * @implements SaverInterface + */ +class Saver implements SaverInterface +{ + private const string ATTR_PHONE_NUMBER = 'phoneNumber'; + private const string ATTR_PHONE_NUMBER_DATA = 'phoneNumberData'; + private const string ATTR_PHONE_NUMBER_IS_OPTED_OUT = 'phoneNumberIsOptedOut'; + private const string ATTR_PHONE_NUMBER_IS_INVALID = 'phoneNumberIsInvalid'; + + public function __construct( + private EntityManager $entityManager, + private ApplicationState $applicationState, + private AccessChecker $accessChecker, + private Metadata $metadata, + private Loader $loader, + ) {} + + public function process(Entity $entity, Params $params): void + { + $entityType = $entity->getEntityType(); + + $defs = $this->entityManager->getDefs()->getEntity($entityType); + + if (!$defs->hasField(self::ATTR_PHONE_NUMBER)) { + return; + } + + if ($defs->getField(self::ATTR_PHONE_NUMBER)->getType() !== FieldType::PHONE) { + return; + } + + $phoneNumberData = null; + + if ($entity->has(self::ATTR_PHONE_NUMBER_DATA)) { + $phoneNumberData = $entity->get(self::ATTR_PHONE_NUMBER_DATA); + } + + if ($phoneNumberData !== null && $entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_DATA)) { + $this->storeData($entity); + + return; + } + + if ($entity->has(self::ATTR_PHONE_NUMBER)) { + $this->storePrimaryAndPrepareData($entity); + } + } + + private function storeData(Entity $entity): void + { + if (!$entity->has(self::ATTR_PHONE_NUMBER_DATA)) { + return; + } + + $phoneNumberValue = $entity->get(self::ATTR_PHONE_NUMBER); + + if (is_string($phoneNumberValue)) { + $phoneNumberValue = trim($phoneNumberValue); + } + + $phoneNumberData = $entity->get(self::ATTR_PHONE_NUMBER_DATA); + + if (!is_array($phoneNumberData)) { + return; + } + + $noPrimary = array_filter($phoneNumberData, fn ($item) => !empty($item->primary)) === []; + + if ($noPrimary && $phoneNumberData !== []) { + $phoneNumberData[0]->primary = true; + } + + $keyList = []; + $keyPreviousList = []; + $previousPhoneNumberData = []; + + if (!$entity->isNew()) { + /** @var PhoneNumberRepository $repository */ + $repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE); + + $previousPhoneNumberData = $repository->getPhoneNumberData($entity); + } + + $hash = (object) []; + $hashPrevious = (object) []; + + foreach ($phoneNumberData as $row) { + $key = trim($row->phoneNumber); + + if (empty($key)) { + continue; + } + + $type = $row->type ?? + $this->metadata + ->get(['entityDefs', $entity->getEntityType(), 'fields', 'phoneNumber', 'defaultType']); + + $hash->$key = [ + 'primary' => !empty($row->primary), + 'type' => $type, + 'optOut' => !empty($row->optOut), + 'invalid' => !empty($row->invalid), + ]; + + $keyList[] = $key; + } + + if ( + $entity->has(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) && ( + $entity->isNew() || + ( + $entity->hasFetched(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) && + $entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) + ) + ) && + $phoneNumberValue + ) { + $key = $phoneNumberValue; + + if (isset($hash->$key)) { + $hash->{$key}['optOut'] = (bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT); + } + } + + if ( + $entity->has(self::ATTR_PHONE_NUMBER_IS_INVALID) && ( + $entity->isNew() || + ( + $entity->hasFetched(self::ATTR_PHONE_NUMBER_IS_INVALID) && + $entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_IS_INVALID) + ) + ) && + $phoneNumberValue + ) { + $key = $phoneNumberValue; + + if (isset($hash->$key)) { + $hash->{$key}['invalid'] = (bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_INVALID); + } + } + + foreach ($previousPhoneNumberData as $row) { + $key = $row->phoneNumber; + + if (empty($key)) { + continue; + } + + $hashPrevious->$key = [ + 'primary' => (bool) $row->primary, + 'type' => $row->type, + 'optOut' => (bool) $row->optOut, + 'invalid' => (bool) $row->invalid, + ]; + + $keyPreviousList[] = $key; + } + + $primary = null; + + $toCreateList = []; + $toUpdateList = []; + $toRemoveList = []; + + $revertData = []; + + foreach ($keyList as $key) { + $new = true; + $changed = false; + + if ($hash->{$key}['primary']) { + $primary = $key; + } + + if (property_exists($hashPrevious, $key)) { + $new = false; + + $changed = + $hash->{$key}['type'] != $hashPrevious->{$key}['type'] || + $hash->{$key}['optOut'] != $hashPrevious->{$key}['optOut'] || + $hash->{$key}['invalid'] != $hashPrevious->{$key}['invalid']; + + if ( + $hash->{$key}['primary'] && + $hash->{$key}['primary'] === $hashPrevious->{$key}['primary'] + ) { + $primary = null; + } + } + + if ($new) { + $toCreateList[] = $key; + } + + if ($changed) { + $toUpdateList[] = $key; + } + } + + foreach ($keyPreviousList as $key) { + if (!property_exists($hash, $key)) { + $toRemoveList[] = $key; + } + } + + foreach ($toRemoveList as $number) { + $phoneNumber = $this->getByNumber($number); + + if (!$phoneNumber) { + continue; + } + + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from('EntityPhoneNumber') + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'phoneNumberId' => $phoneNumber->getId(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + + foreach ($toUpdateList as $number) { + $phoneNumber = $this->getByNumber($number); + + if ($phoneNumber) { + $skipSave = $this->checkChangeIsForbidden($phoneNumber, $entity); + + if (!$skipSave) { + $phoneNumber->set([ + 'type' => $hash->{$number}['type'], + 'optOut' => $hash->{$number}['optOut'], + 'invalid' => $hash->{$number}['invalid'], + ]); + + $this->entityManager->saveEntity($phoneNumber); + } else { + $revertData[$number] = [ + 'type' => $phoneNumber->get('type'), + 'optOut' => $phoneNumber->get('optOut'), + 'invalid' => $phoneNumber->get('invalid'), + ]; + } + } + } + + foreach ($toCreateList as $number) { + $phoneNumber = $this->getByNumber($number); + + if (!$phoneNumber) { + $phoneNumber = $this->entityManager->getNewEntity(PhoneNumber::ENTITY_TYPE); + + $phoneNumber->set([ + 'name' => $number, + 'type' => $hash->{$number}['type'], + 'optOut' => $hash->{$number}['optOut'], + 'invalid' => $hash->{$number}['invalid'], + ]); + + $this->entityManager->saveEntity($phoneNumber); + } else { + $skipSave = $this->checkChangeIsForbidden($phoneNumber, $entity); + + if (!$skipSave) { + if ( + $phoneNumber->get('type') != $hash->{$number}['type'] || + $phoneNumber->get('optOut') != $hash->{$number}['optOut'] || + $phoneNumber->get('invalid') != $hash->{$number}['invalid'] + ) { + $phoneNumber->set([ + 'type' => $hash->{$number}['type'], + 'optOut' => $hash->{$number}['optOut'], + 'invalid' => $hash->{$number}['invalid'], + ]); + + $this->entityManager->saveEntity($phoneNumber); + } + } else { + $revertData[$number] = [ + 'type' => $phoneNumber->getType(), + 'optOut' => $phoneNumber->isOptedOut(), + 'invalid' => $phoneNumber->isInvalid(), + ]; + } + } + + $entityPhoneNumber = $this->entityManager->getNewEntity('EntityPhoneNumber'); + + $entityPhoneNumber->setMultiple([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'phoneNumberId' => $phoneNumber->getId(), + 'primary' => $number === $primary, + Attribute::DELETED => false, + ]); + + /** @var BaseMapper $mapper */ + $mapper = $this->entityManager->getMapper(); + + $mapper->insertOnDuplicateUpdate($entityPhoneNumber, [ + 'primary', + Attribute::DELETED, + ]); + } + + if ($primary) { + $phoneNumber = $this->getByNumber($primary); + + $entity->set(self::ATTR_PHONE_NUMBER, $primary); + + if ($phoneNumber) { + $update1 = $this->entityManager + ->getQueryBuilder() + ->update() + ->in('EntityPhoneNumber') + ->set(['primary' => false]) + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'primary' => true, + Attribute::DELETED => false, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($update1); + + $update2 = $this->entityManager + ->getQueryBuilder() + ->update() + ->in('EntityPhoneNumber') + ->set(['primary' => true]) + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'phoneNumberId' => $phoneNumber->getId(), + Attribute::DELETED => false, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($update2); + } + } + + if (!empty($revertData)) { + foreach ($phoneNumberData as $row) { + if (empty($revertData[$row->phoneNumber])) { + continue; + } + + $row->type = $revertData[$row->phoneNumber]['type']; + $row->optOut = $revertData[$row->phoneNumber]['optOut']; + $row->invalid = $revertData[$row->phoneNumber]['invalid']; + } + + $entity->set(self::ATTR_PHONE_NUMBER_DATA, $phoneNumberData); + } + } + + private function storePrimary(Entity $entity): void + { + if (!$entity->has(self::ATTR_PHONE_NUMBER)) { + return; + } + + $number = trim($entity->get(self::ATTR_PHONE_NUMBER) ?? ''); + + $entityRepository = $this->entityManager->getRDBRepository($entity->getEntityType()); + + if ($number) { + if ($number !== $entity->getFetched(self::ATTR_PHONE_NUMBER)) { + $this->storePrimaryNotEmpty($number, $entity); + + return; + } + + $this->storePrimaryNotChanged($entity, $number); + + return; + } + + $this->storePrimaryEmpty($entity); + } + + private function getByNumber(string $number): ?PhoneNumber + { + /** @var PhoneNumberRepository $repository */ + $repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE); + + return $repository->getByNumber($number); + } + + private function markNumberOptedOut(string $number, bool $isOptedOut = true): void + { + /** @var PhoneNumberRepository $repository */ + $repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE); + + $repository->markNumberOptedOut($number, $isOptedOut); + } + + private function markNumberInvalid(string $number, bool $isInvalid = true): void + { + /** @var PhoneNumberRepository $repository */ + $repository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE); + + $repository->markNumberInvalid($number, $isInvalid); + } + + private function checkChangeIsForbidden(PhoneNumber $phoneNumber, Entity $entity): bool + { + if (!$this->applicationState->hasUser()) { + return true; + } + + $user = $this->applicationState->getUser(); + + // @todo Check if not modified by system. + + return !$this->accessChecker->checkEdit($user, $phoneNumber, $entity); + } + + private function storePrimaryNotEmpty(string $phoneNumberValue, Entity $entity): void + { + $entityRepository = $this->entityManager->getRDBRepository($entity->getEntityType()); + + $phoneNumberNew = $this->entityManager + ->getRDBRepositoryByClass(PhoneNumber::class) + ->where([ + 'name' => $phoneNumberValue, + ]) + ->findOne(); + + if (!$phoneNumberNew) { + $phoneNumberNew = $this->entityManager->getRDBRepositoryByClass(PhoneNumber::class)->getNew(); + + $phoneNumberNew->setNumber($phoneNumberValue); + + if ($entity->has(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT)) { + $phoneNumberNew->setOptedOut((bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT)); + } + + if ($entity->has(self::ATTR_PHONE_NUMBER_IS_INVALID)) { + $phoneNumberNew->setInvalid((bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_INVALID)); + } + + $defaultType = $this->metadata + ->get("entityDefs.{$entity->getEntityType()}.fields.phoneNumber.defaultType"); + + $phoneNumberNew->setType($defaultType); + + $this->entityManager->saveEntity($phoneNumberNew); + } + + $phoneNumberValueOld = $entity->getFetched(self::ATTR_PHONE_NUMBER); + + if (!empty($phoneNumberValueOld)) { + $phoneNumberOld = $this->getByNumber($phoneNumberValueOld); + + if ($phoneNumberOld) { + $entityRepository + ->getRelation($entity, 'phoneNumbers') + ->unrelate($phoneNumberOld, [SaveOption::SKIP_HOOKS => true]); + } + } + + $entityRepository + ->getRelation($entity, 'phoneNumbers') + ->relate($phoneNumberNew, null, [SaveOption::SKIP_HOOKS => true]); + + if ($entity->has(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT)) { + $this->markNumberOptedOut($phoneNumberValue, (bool)$entity->get(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT)); + } + + if ($entity->has(self::ATTR_PHONE_NUMBER_IS_INVALID)) { + $this->markNumberInvalid($phoneNumberValue, (bool)$entity->get(self::ATTR_PHONE_NUMBER_IS_INVALID)); + } + + $update = $this->entityManager + ->getQueryBuilder() + ->update() + ->in(PhoneNumber::RELATION_ENTITY_PHONE_NUMBER) + ->set(['primary' => true]) + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'phoneNumberId' => $phoneNumberNew->getId(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($update); + } + + private function storePrimaryNotChanged(Entity $entity, string $phoneNumberValue): void + { + if ( + $entity->has(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) && + ( + $entity->isNew() || + ( + $entity->hasFetched(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) && + $entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT) + ) + ) + ) { + $this->markNumberOptedOut($phoneNumberValue, (bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_OPTED_OUT)); + } + + if ( + $entity->has(self::ATTR_PHONE_NUMBER_IS_INVALID) && + ( + $entity->isNew() || + ( + $entity->hasFetched(self::ATTR_PHONE_NUMBER_IS_INVALID) && + $entity->isAttributeChanged(self::ATTR_PHONE_NUMBER_IS_INVALID) + ) + ) + ) { + $this->markNumberInvalid($phoneNumberValue, (bool) $entity->get(self::ATTR_PHONE_NUMBER_IS_INVALID)); + } + } + + private function storePrimaryAndPrepareData(Entity $entity): void + { + $this->loader->process($entity, LoeaderParams::create()); + $previous = $entity->get(self::ATTR_PHONE_NUMBER_DATA); + + $this->storePrimary($entity); + + $this->loader->process($entity, LoeaderParams::create()); + $entity->setFetched(self::ATTR_PHONE_NUMBER_DATA, $previous); + } + + private function storePrimaryEmpty(Entity $entity): void + { + $fetchedNumber = $entity->getFetched(self::ATTR_PHONE_NUMBER); + + if (!$fetchedNumber) { + return; + } + + /** @var RDBRelation $relation */ + $relation = $this->entityManager->getRelation($entity, 'phoneNumbers'); + + $phoneNumberOld = $this->getByNumber($fetchedNumber); + + if ($phoneNumberOld) { + $relation->unrelate($phoneNumberOld, [SaveOption::SKIP_HOOKS => true]); + } + + $one = $relation->findOne(); + + if (!$one) { + return; + } + + $relation->updateColumns($one, ['primary' => true]); + } +} diff --git a/application/Espo/Core/FieldProcessing/ReadLoadProcessor.php b/application/Espo/Core/FieldProcessing/ReadLoadProcessor.php new file mode 100644 index 0000000..7b3b413 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/ReadLoadProcessor.php @@ -0,0 +1,146 @@ +. + * + * 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\FieldProcessing; + +use Espo\Core\Acl; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Entities\User; +use Espo\ORM\Defs; +use Espo\ORM\Entity; + +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +/** + * Processes loading special fields (before output). + */ +class ReadLoadProcessor +{ + /** @var array[]> */ + private array $loaderListMapCache = []; + + private BindingContainer $bindingContainer; + + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private Acl $acl, + private User $user, + private Defs $defs, + ) { + $this->bindingContainer = BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->bindInstance(Acl::class, $this->acl) + ->build(); + } + + public function process(Entity $entity, ?Params $params = null): void + { + if (!$params) { + $params = new Params(); + } + + foreach ($this->getLoaderList($entity->getEntityType()) as $processor) { + $processor->process($entity, $params); + } + } + + /** + * @return Loader[] + */ + private function getLoaderList(string $entityType): array + { + if (array_key_exists($entityType, $this->loaderListMapCache)) { + return $this->loaderListMapCache[$entityType]; + } + + $list = []; + + foreach ($this->getLoaderClassNameList($entityType) as $className) { + $list[] = $this->createLoader($className); + } + + $this->loaderListMapCache[$entityType] = $list; + + return $list; + } + + /** + * @return class-string>[] + */ + private function getLoaderClassNameList(string $entityType): array + { + $entityLevelList = $this->getEntityLevelClassNameList($entityType); + + $list = $this->metadata + ->get(['app', 'fieldProcessing', 'readLoaderClassNameList']) ?? []; + + $additionalList = $this->metadata + ->get(['recordDefs', $entityType, 'readLoaderClassNameList']) ?? []; + + /** @var class-string>[] $list */ + $list = array_merge($list, $additionalList, $entityLevelList); + + return array_values(array_unique($list)); + } + + /** + * @param class-string> $className + * @return Loader + */ + private function createLoader(string $className): Loader + { + return $this->injectableFactory->createWithBinding($className, $this->bindingContainer); + } + + /** + * @return class-string>[] + */ + private function getEntityLevelClassNameList(string $entityType): array + { + $entityLevelList = []; + + $fieldList = $this->defs->getEntity($entityType)->getFieldList(); + + foreach ($fieldList as $fieldDefs) { + $className = $fieldDefs->getParam('loaderClassName'); + + if (!$className || in_array($className, $entityLevelList)) { + continue; + } + + $entityLevelList[] = $className; + } + + return $entityLevelList; + } +} diff --git a/application/Espo/Core/FieldProcessing/Relation/LinkMultipleSaver.php b/application/Espo/Core/FieldProcessing/Relation/LinkMultipleSaver.php new file mode 100644 index 0000000..d1b5954 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Relation/LinkMultipleSaver.php @@ -0,0 +1,260 @@ +. + * + * 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\FieldProcessing\Relation; + +use Espo\Core\ORM\Entity as CoreEntity; + +use Espo\Core\FieldProcessing\Saver\Params; +use Espo\Core\ORM\EntityManager; +use Espo\ORM\Repository\Option\SaveContext; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\ORM\Defs\Params\RelationParam; +use RuntimeException; + +/** + * Saves a link-multiple field or has-many relation set in a link stub attribute. + * In case of a stab attribute, only processed for a new record. + */ +class LinkMultipleSaver +{ + private const RELATE_OPTION = 'linkMultiple'; + + public function __construct(private EntityManager $entityManager) + {} + + public function process(CoreEntity $entity, string $name, Params $params): void + { + $idListAttribute = $name . 'Ids'; + $columnsAttribute = $name . 'Columns'; + + if ( + !$entity->isNew() && + !$entity->hasLinkMultipleField($name) + ) { + $entity->clear($idListAttribute); + + return; + } + + $isChanged = $entity->isAttributeChanged($idListAttribute) || $entity->isAttributeChanged($columnsAttribute); + + if (!$isChanged) { + return; + } + + $defs = $this->entityManager->getDefs()->getEntity($entity->getEntityType()); + + $skipCreate = $params->getOption('skipLinkMultipleCreate') ?? false; + $skipRemove = $params->getOption('skipLinkMultipleRemove') ?? false; + $skipUpdate = $params->getOption('skipLinkMultipleUpdate') ?? false; + $skipHooks = $params->getOption('skipLinkMultipleHooks') ?? false; + + if ($entity->isNew()) { + $skipRemove = true; + $skipUpdate = true; + } + + if ($entity->has($idListAttribute)) { + $specifiedIdList = $entity->get($idListAttribute); + } else if ($entity->has($columnsAttribute)) { + $skipRemove = true; + + $specifiedIdList = array_keys( + get_object_vars( + $entity->get($columnsAttribute) ?? (object) [] + ) + ); + } else { + return; + } + + if (!is_array($specifiedIdList)) { + return; + } + + $toRemoveIdList = []; + $existingIdList = []; + $toUpdateIdList = []; + $toCreateIdList = []; + + $existingColumnsData = (object) []; + + $columns = null; + + if ($defs->hasField($name)) { + $columns = $defs->getField($name)->getParam('columns'); + } + + $allColumns = $columns; + + if (is_array($columns)) { + $additionalColumns = $defs->getRelation($name)->getParam(RelationParam::ADDITIONAL_COLUMNS) ?? []; + + foreach ($columns as $column => $field) { + if (!array_key_exists($column, $additionalColumns)) { + unset($columns[$column]); + } + } + } + + $repository = $this->entityManager->getRDBRepository($entity->getEntityType()); + + $columnData = !empty($columns) ? + $entity->get($columnsAttribute) : + null; + + if (!$skipRemove || !$skipUpdate) { + $foreignEntityList = $repository->getRelation($entity, $name)->find(); + + foreach ($foreignEntityList as $foreignEntity) { + $existingIdList[] = $foreignEntity->getId(); + + if (empty($allColumns)) { + continue; + } + + $data = (object) []; + + $foreignId = $foreignEntity->getId(); + + foreach ($allColumns as $columnName => $columnField) { + $data->$columnName = $foreignEntity->get($columnField); + } + + $existingColumnsData->$foreignId = $data; + + if (!$entity->isNew()) { + $entity->setFetched($columnsAttribute, $existingColumnsData); + } + } + } + + if (!$entity->isNew()) { + if ($entity->has($idListAttribute) && !$entity->hasFetched($idListAttribute)) { + $entity->setFetched($idListAttribute, $existingIdList); + } + + if ($entity->has($columnsAttribute) && !empty($allColumns)) { + $entity->setFetched($columnsAttribute, $existingColumnsData); + } + } + + foreach ($existingIdList as $id) { + if (!in_array($id, $specifiedIdList)) { + if (!$skipRemove) { + $toRemoveIdList[] = $id; + } + + continue; + } + + if ($skipUpdate || empty($columns)) { + continue; + } + + foreach ($columns as $columnName => $columnField) { + if (!isset($columnData->$id) || !is_object($columnData->$id)) { + continue; + } + + if ( + property_exists($columnData->$id, $columnName) && + ( + !property_exists($existingColumnsData->$id, $columnName) || + $columnData->$id->$columnName !== $existingColumnsData->$id->$columnName + ) + ) { + $toUpdateIdList[] = $id; + } + } + } + + if (!$skipCreate) { + foreach ($specifiedIdList as $id) { + if (!in_array($id, $existingIdList)) { + $toCreateIdList[] = $id; + } + + if (!is_string($id)) { + throw new RuntimeException("Non-string ID in link-multiple."); + } + + if ($id === '') { + throw new RuntimeException("An entity ID value in link-multiple."); + } + } + } + + $saveContext = SaveContext::obtainFromRawOptions($params->getRawOptions()); + + foreach ($toCreateIdList as $id) { + $data = null; + + if (is_array($columns) && isset($columnData->$id)) { + $data = (array) $columnData->$id; + + foreach ($data as $column => $v) { + if (!array_key_exists($column, $columns)) { + unset($data[$column]); + } + } + } + + $repository->getRelation($entity, $name)->relateById($id, $data, [ + SaveOption::SKIP_HOOKS => $skipHooks, + SaveOption::SILENT => $entity->isNew(), + self::RELATE_OPTION => $entity->hasLinkMultipleField($name), + SaveContext::NAME => $saveContext, + ]); + } + + foreach ($toRemoveIdList as $id) { + $repository->getRelation($entity, $name)->unrelateById($id, [ + SaveOption::SKIP_HOOKS => $skipHooks, + self::RELATE_OPTION => $entity->hasLinkMultipleField($name), + SaveContext::NAME => $saveContext, + ]); + } + + foreach ($toUpdateIdList as $id) { + $data = (array) $columnData->$id; + + if (is_array($columns)) { + foreach ($data as $column => $v) { + if (!array_key_exists($column, $columns)) { + unset($data[$column]); + } + } + } + + $repository->getRelation($entity, $name)->updateColumnsById($id, $data); + } + } +} diff --git a/application/Espo/Core/FieldProcessing/Relation/Saver.php b/application/Espo/Core/FieldProcessing/Relation/Saver.php new file mode 100644 index 0000000..2840951 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Relation/Saver.php @@ -0,0 +1,356 @@ +. + * + * 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\FieldProcessing\Relation; + +use Espo\Core\ORM\Defs\AttributeParam; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; + +use Espo\Core\FieldProcessing\Saver as SaverInterface; +use Espo\Core\FieldProcessing\Saver\Params; +use Espo\Core\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Repository\Option\SaveOption; + +/** + * @implements SaverInterface + */ +class Saver implements SaverInterface +{ + private EntityManager $entityManager; + private LinkMultipleSaver $linkMultipleSaver; + + /** @var array */ + private $manyRelationListMapCache = []; + /** @var array */ + private $hasOneRelationListMapCache = []; + /** @var array */ + private $belongsToHasOneRelationListMapCache = []; + + public function __construct( + EntityManager $entityManager, + LinkMultipleSaver $linkMultipleSaver + ) { + $this->entityManager = $entityManager; + $this->linkMultipleSaver = $linkMultipleSaver; + } + + public function process(Entity $entity, Params $params): void + { + $this->processMany($entity, $params); + $this->processHasOne($entity); + $this->processBelongsToHasOne($entity); + } + + private function processMany(Entity $entity, Params $params): void + { + $entityType = $entity->getEntityType(); + + foreach ($this->getManyRelationList($entityType) as $name) { + $this->processManyItem($entity, $name, $params); + } + } + + private function processManyItem(Entity $entity, string $name, Params $params): void + { + $idsAttribute = $name . 'Ids'; + $columnsAttribute = $name . 'Columns'; + + if (!$entity->has($idsAttribute) && !$entity->has($columnsAttribute)) { + return; + } + + if (!$entity instanceof CoreEntity) { + return; + } + + $this->linkMultipleSaver->process($entity, $name, $params); + } + + private function processHasOne(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + foreach ($this->getHasOneRelationList($entityType) as $name) { + $this->processHasOneItem($entity, $name); + } + } + + private function processHasOneItem(Entity $entity, string $name): void + { + $entityType = $entity->getEntityType(); + $idAttribute = $name . 'Id'; + + if (!$entity->has($idAttribute) || !$entity->isAttributeChanged($idAttribute)) { + return; + } + + /** @var ?string $id */ + $id = $entity->get($idAttribute); + + $defs = $this->entityManager->getDefs()->getEntity($entityType); + $relationDefs = $defs->getRelation($name); + + $foreignKey = $relationDefs->getForeignKey(); + $foreignEntityType = $relationDefs->getForeignEntityType(); + + $previous = $this->entityManager + ->getRDBRepository($foreignEntityType) + ->select([Attribute::ID]) + ->where([$foreignKey => $entity->getId()]) + ->findOne(); + + if (!$entity->isNew() && !$entity->hasFetched($idAttribute)) { + $entity->setFetched($idAttribute, $previous ? $previous->getId() : null); + } + + if ($previous) { + if (!$id) { + $this->entityManager + ->getRelation($entity, $name) + ->unrelate($previous); + + return; + } + + if ($previous->getId() === $id) { + return; + } + } + + if (!$id) { + return; + } + + $this->entityManager + ->getRelation($entity, $name) + ->relateById($id); + } + + private function processBelongsToHasOne(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + foreach ($this->getBelongsToHasOneRelationList($entityType) as $name) { + $this->processBelongsToHasOneItem($entity, $name); + } + } + + private function processBelongsToHasOneItem(Entity $entity, string $name): void + { + $entityType = $entity->getEntityType(); + + $idAttribute = $name . 'Id'; + + if (!$entity->get($idAttribute)) { + return; + } + + if (!$entity->isAttributeChanged($idAttribute)) { + return; + } + + $anotherEntity = $this->entityManager + ->getRDBRepository($entityType) + ->select([Attribute::ID]) + ->where([ + $idAttribute => $entity->get($idAttribute), + Attribute::ID . '!=' => $entity->getId(), + ]) + ->findOne(); + + if (!$anotherEntity) { + return; + } + + $anotherEntity->set($idAttribute, null); + + $this->entityManager->saveEntity($anotherEntity, [ + SaveOption::SKIP_ALL => true, + ]); + } + + /** + * @return string[] + */ + private function getManyRelationList(string $entityType): array + { + if (array_key_exists($entityType, $this->manyRelationListMapCache)) { + return $this->manyRelationListMapCache[$entityType]; + } + + $typeList = [ + Entity::HAS_MANY, + Entity::MANY_MANY, + Entity::HAS_CHILDREN, + ]; + + $defs = $this->entityManager->getDefs()->getEntity($entityType); + + $list = []; + + foreach ($defs->getRelationNameList() as $name) { + $type = $defs->getRelation($name)->getType(); + + if (!in_array($type, $typeList)) { + continue; + } + + $idsAttribute = $name . 'Ids'; + + if (!$defs->hasAttribute($idsAttribute)) { + continue; + } + + $attributeDefs = $defs->getAttribute($idsAttribute); + + if ( + !$attributeDefs->getParam(AttributeParam::IS_LINK_MULTIPLE_ID_LIST) && + !$attributeDefs->getParam(AttributeParam::IS_LINK_STUB) + ) { + continue; + } + + if ($defs->hasField($name) && $defs->getField($name)->getParam('noSave')) { + continue; + } + + $list[] = $name; + } + + $this->manyRelationListMapCache[$entityType] = $list; + + return $list; + } + + /** + * @return string[] + */ + private function getHasOneRelationList(string $entityType): array + { + if (array_key_exists($entityType, $this->hasOneRelationListMapCache)) { + return $this->hasOneRelationListMapCache[$entityType]; + } + + $ormDefs = $this->entityManager->getDefs(); + + $defs = $ormDefs->getEntity($entityType); + + $list = []; + + foreach ($defs->getRelationNameList() as $name) { + $relationDefs = $defs->getRelation($name); + + $type = $relationDefs->getType(); + + if ($type !== Entity::HAS_ONE) { + continue; + } + + if (!$relationDefs->hasForeignEntityType()) { + continue; + } + + if (!$relationDefs->hasForeignKey()) { + continue; + } + + if (!$defs->hasAttribute($name . 'Id')) { + continue; + } + + if ($defs->hasField($name) && $defs->getField($name)->getParam('noSave')) { + continue; + } + + $list[] = $name; + } + + $this->hasOneRelationListMapCache[$entityType] = $list; + + return $list; + } + + /** + * @return string[] + */ + private function getBelongsToHasOneRelationList(string $entityType): array + { + if (array_key_exists($entityType, $this->belongsToHasOneRelationListMapCache)) { + return $this->belongsToHasOneRelationListMapCache[$entityType]; + } + + $ormDefs = $this->entityManager->getDefs(); + + $defs = $ormDefs->getEntity($entityType); + + $list = []; + + foreach ($defs->getRelationNameList() as $name) { + $relationDefs = $defs->getRelation($name); + + $type = $relationDefs->getType(); + + if ($type !== Entity::BELONGS_TO) { + continue; + } + + if (!$relationDefs->hasForeignRelationName()) { + continue; + } + + if (!$relationDefs->hasForeignEntityType()) { + continue; + } + + if (!$defs->hasAttribute($name . 'Id')) { + continue; + } + + $foreignEntityType = $relationDefs->getForeignEntityType(); + $foreignRelationName = $relationDefs->getForeignRelationName(); + + $foreignType = $ormDefs + ->getEntity($foreignEntityType) + ->tryGetRelation($foreignRelationName) + ?->getType(); + + if ($foreignType !== Entity::HAS_ONE) { + continue; + } + + $list[] = $name; + } + + $this->belongsToHasOneRelationListMapCache[$entityType] = $list; + + return $list; + } +} diff --git a/application/Espo/Core/FieldProcessing/Reminder/Loader.php b/application/Espo/Core/FieldProcessing/Reminder/Loader.php new file mode 100644 index 0000000..5c4ab8f --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Reminder/Loader.php @@ -0,0 +1,97 @@ +. + * + * 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\FieldProcessing\Reminder; + +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Reminder; +use Espo\ORM\Entity; +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\ORM\EntityManager; + +/** + * @internal This class should not be removed as it's used by custom entities. + * @implements LoaderInterface + */ +class Loader implements LoaderInterface +{ + public function __construct( + private EntityManager $entityManager, + private User $user + ) {} + + public function process(Entity $entity, Params $params): void + { + $hasReminder = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()) + ->hasField('reminders'); + + if (!$hasReminder) { + return; + } + + if ($params->hasSelect() && !$params->hasInSelect('reminders')) { + return; + } + + $entity->set('reminders', $this->fetchReminderDataList($entity)); + } + + /** + * @return object{seconds: int, type: string}[] + */ + private function fetchReminderDataList(Entity $entity): array + { + $list = []; + + /** @var iterable $collection */ + $collection = $this->entityManager + ->getRDBRepository(Reminder::ENTITY_TYPE) + ->select(['seconds', 'type']) + ->where([ + 'entityType' => $entity->getEntityType(), + 'entityId' => $entity->getId(), + 'userId' => $this->user->getId(), + ]) + ->distinct() + ->order('seconds') + ->find(); + + foreach ($collection as $reminder) { + $list[] = (object) [ + 'seconds' => $reminder->getSeconds(), + 'type' => $reminder->getType(), + ]; + } + + return $list; + } +} diff --git a/application/Espo/Core/FieldProcessing/Reminder/Saver.php b/application/Espo/Core/FieldProcessing/Reminder/Saver.php new file mode 100644 index 0000000..91dedea --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Reminder/Saver.php @@ -0,0 +1,500 @@ +. + * + * 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\FieldProcessing\Reminder; + +use Espo\Core\Field\DateTime; +use Espo\Core\Name\Field; +use Espo\Core\Utils\DateTime\Clock; +use Espo\Core\Utils\Id\RecordIdGenerator; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Preferences; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Reminder; +use Espo\Modules\Crm\Entities\Task; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\FieldProcessing\Saver as SaverInterface; +use Espo\Core\FieldProcessing\Saver\Params; +use Espo\Core\ORM\EntityManager; +use stdClass; + +/** + * @internal This class should not be removed as it's used by custom entities. + * + * @implements SaverInterface + */ +class Saver implements SaverInterface +{ + protected string $dateAttribute = 'dateStart'; + + public function __construct( + private EntityManager $entityManager, + private RecordIdGenerator $idGenerator, + private User $user, + private Metadata $metadata, + private Clock $clock, + ) {} + + public function process(Entity $entity, Params $params): void + { + $entityType = $entity->getEntityType(); + + if (!$this->hasRemindersField($entityType)) { + return; + } + + $dateAttribute = $this->getDateAttribute($entityType); + + if ($this->toRemove($entity)) { + $this->deleteAll($entity); + + return; + } + + if (!$this->toProcess($entity, $dateAttribute)) { + return; + } + + $typeList = $this->getTypeList(); + + $onlyRemindersFieldChanged = $this->onlyRemindersFieldChanged($entity, $dateAttribute); + + if (!$entity->isNew() && !$onlyRemindersFieldChanged) { + $this->deleteAll($entity); + } + + if (!$entity->isNew() && $onlyRemindersFieldChanged) { + $this->deleteAllForUser($entity); + } + + $startString = $this->getStartString($entity, $dateAttribute); + + if (!$startString) { + return; + } + + $userIdList = $this->getUserIdList($entity); + + if ($userIdList === []) { + return; + } + + if ($onlyRemindersFieldChanged && in_array($this->user->getId(), $userIdList)) { + $userIdList = [$this->user->getId()]; + } + + $start = DateTime::fromString($startString); + + foreach ($userIdList as $userId) { + $usePreferences = $userId !== $this->user->getId() || + !$entity->has('reminders') && $entity->isNew(); + + $reminderList = $usePreferences ? + $this->getPreferencesReminderList($typeList, $userId, $entityType) : + $this->getReminderList($entity, $typeList); + + $this->createReminders($reminderList, $entity, $userId, $start); + } + } + + /** + * @return array{seconds: int, type: string}[] + */ + private function getEntityReminderDataList(CoreEntity $entity): array + { + $dataList = []; + + /** @var iterable $collection */ + $collection = $this->entityManager + ->getRDBRepository(Reminder::ENTITY_TYPE) + ->select(['seconds', 'type']) + ->where([ + 'entityType' => $entity->getEntityType(), + 'entityId' => $entity->getId(), + 'userId' => $this->user->getId(), + ]) + ->distinct() + ->order('seconds') + ->find(); + + foreach ($collection as $reminder) { + $dataList[] = [ + 'seconds' => $reminder->getSeconds(), + 'type' => $reminder->getType(), + ]; + } + + return $dataList; + } + + private function getDateAttribute(string $entityType): string + { + return $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->getField('reminders') + ->getParam('dateField') ?? + $this->dateAttribute; + } + + private function hasRemindersField(string $entityType): bool + { + return $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->hasField('reminders'); + } + + private function isNewOrChanged(CoreEntity $entity, string $dateAttribute): bool + { + return $entity->isNew() || + $this->toReCreate($entity) || + $entity->isAttributeChanged('assignedUserId') || + ( + $entity->hasLinkMultipleField(Field::ASSIGNED_USERS) && + $entity->isAttributeChanged(Field::ASSIGNED_USERS . 'Ids') + ) || + ($entity->hasLinkMultipleField('users') && $entity->isAttributeChanged('usersIds')) || + $entity->isAttributeChanged($dateAttribute); + } + + private function toProcess(CoreEntity $entity, string $dateAttribute): bool + { + return $this->isNewOrChanged($entity, $dateAttribute) || $entity->has('reminders'); + } + + private function onlyRemindersFieldChanged(CoreEntity $entity, string $dateAttribute): bool + { + if ($this->isNewOrChanged($entity, $dateAttribute)) { + return false; + } + + return $entity->isAttributeChanged('reminders'); + } + + /** + * @return string[] + */ + private function getTypeList(): array + { + return $this->entityManager + ->getDefs() + ->getEntity(Reminder::ENTITY_TYPE) + ->getField('type') + ->getParam('options') ?? []; + } + + private function deleteAll(CoreEntity $entity): void + { + $query = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(Reminder::ENTITY_TYPE) + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } + + private function deleteAllForUser(CoreEntity $entity): void + { + $query = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(Reminder::ENTITY_TYPE) + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'userId' => $this->user->getId(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } + + /** + * @return string[] + */ + private function getUserIdList(CoreEntity $entity): array + { + if ($entity->hasLinkMultipleField('users')) { + return $entity->getLinkMultipleIdList('users'); + } + + if ($entity->hasLinkMultipleField(Field::ASSIGNED_USERS)) { + return $entity->getLinkMultipleIdList(Field::ASSIGNED_USERS); + } + + $userIdList = []; + + if ($entity->get('assignedUserId')) { + $userIdList[] = $entity->get('assignedUserId'); + } + + return $userIdList; + } + + private function getStartString(CoreEntity $entity, string $dateAttribute): ?string + { + $dateValue = $entity->get($dateAttribute); + + if (!$entity->has($dateAttribute)) { + $reloadedEntity = $this->entityManager->getEntityById($entity->getEntityType(), $entity->getId()); + + if ($reloadedEntity) { + $dateValue = $reloadedEntity->get($dateAttribute); + } + + } + return $dateValue; + } + + /** + * @param string[] $typeList + * @return array{seconds: int, type: string}[] + */ + private function getReminderList(CoreEntity $entity, array $typeList): array + { + if ($entity->has('reminders')) { + /** @var ?stdClass[] $list */ + $list = $entity->get('reminders'); + + if ($list === null) { + return []; + } + + return $this->sanitizeList($list, $typeList); + } + + return $this->getEntityReminderDataList($entity); + } + + /** + * @param string[] $typeList + * @return array{seconds: int, type: string}[] + */ + private function getPreferencesReminderList(array $typeList, string $userId, string $entityType): array + { + $preferences = $this->entityManager->getRepositoryByClass(Preferences::class)->getById($userId); + + if (!$preferences) { + return []; + } + + $param = 'defaultReminders'; + + // @todo Refactor. + if ($entityType === Task::ENTITY_TYPE) { + $param = 'defaultRemindersTask'; + } + + /** @var stdClass[] $list */ + $list = $preferences->get($param) ?? []; + + return $this->sanitizeList($list, $typeList); + } + + /** + * @param stdClass[] $list + * @param string[] $typeList + * @return array{seconds: int, type: string}[] + */ + private function sanitizeList(array $list, array $typeList): array + { + $result = []; + + foreach ($list as $item) { + $seconds = ($item->seconds ?? null); + $type = ($item->type ?? null); + + if (!is_int($seconds) || !in_array($type, $typeList)) { + continue; + } + + $result[] = [ + 'seconds' => $seconds, + 'type' => $type, + ]; + } + + return $result; + } + + /** + * @param array{seconds: int|null, type: string} $item + */ + private function createReminder( + CoreEntity $entity, + string $userId, + DateTime $start, + array $item, + ): bool { + + $seconds = $item['seconds']; + $type = $item['type']; + + $now = DateTime::fromDateTime($this->clock->now()); + + if ($seconds !== null) { + $remindAt = $start->addSeconds(- $seconds); + + if ($remindAt->isLessThan($now)) { + return false; + } + } else { + $remindAt = $now; + } + + $query = $this->entityManager + ->getQueryBuilder() + ->insert() + ->into(Reminder::ENTITY_TYPE) + ->columns([ + 'id', + 'entityId', + 'entityType', + 'type', + 'userId', + 'remindAt', + 'startAt', + 'seconds', + ]) + ->values([ + 'id' => $this->idGenerator->generate(), + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'type' => $type, + 'userId' => $userId, + 'remindAt' => $remindAt->toString(), + 'startAt' => $start->toString(), + 'seconds' => $seconds, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + + return true; + } + + private function toRemove(CoreEntity $entity): bool + { + if (!$entity->isAttributeChanged('status')) { + return false; + } + + $entityType = $entity->getEntityType(); + + $status = $entity->get('status'); + + $ignoreStatusList = [ + ...($this->metadata->get("scopes.$entityType.completedStatusList") ?? []), + ...($this->metadata->get("scopes.$entityType.canceledStatusList") ?? []), + ]; + + return in_array($status, $ignoreStatusList); + } + + private function toReCreate(CoreEntity $entity): bool + { + if (!$entity->isAttributeChanged('status')) { + return false; + } + + $entityType = $entity->getEntityType(); + + $statusFetched = $entity->getFetched('status'); + $status = $entity->get('status'); + + $ignoreStatusList = [ + ...($this->metadata->get("scopes.$entityType.completedStatusList") ?? []), + ...($this->metadata->get("scopes.$entityType.canceledStatusList") ?? []), + ]; + + return in_array($statusFetched, $ignoreStatusList) && !in_array($status, $ignoreStatusList); + } + + /** + * @param array{seconds: int, type: string}[] $list + */ + private function createReminders( + array $list, + CoreEntity $entity, + string $userId, + DateTime $start, + ): void { + + if ($list === []) { + return; + } + + /** @var array $hasTypeMap */ + $hasTypeMap = []; + + /** @var array $createdTypeMap */ + $createdTypeMap = []; + + foreach ($list as $item) { + $type = $item['type']; + $hasTypeMap[$type] = true; + + $isCreated = $this->createReminder( + entity: $entity, + userId: $userId, + start: $start, + item: $item, + ); + + if (!$isCreated) { + continue; + } + + $createdTypeMap[$type] = true; + } + + foreach (array_keys($hasTypeMap) as $type) { + if ($createdTypeMap[$type] ?? false) { + continue; + } + + $this->createReminder( + entity: $entity, + userId: $userId, + start: $start, + item: [ + 'type' => $type, + 'seconds' => null, + ], + ); + } + } +} diff --git a/application/Espo/Core/FieldProcessing/SaveProcessor.php b/application/Espo/Core/FieldProcessing/SaveProcessor.php new file mode 100644 index 0000000..7320e83 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/SaveProcessor.php @@ -0,0 +1,105 @@ +. + * + * 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\FieldProcessing; + +use Espo\Core\ORM\Entity; +use Espo\Core\FieldProcessing\Saver\Params; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +/** + * Processes saving special fields. + */ +class SaveProcessor +{ + /** @var array[]> */ + private $saverListMapCache = []; + + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata + ) {} + + /** + * @param array $options + */ + public function process(Entity $entity, array $options): void + { + $params = Params::create()->withRawOptions($options); + + foreach ($this->getSaverList($entity->getEntityType()) as $processor) { + $processor->process($entity, $params); + } + } + + /** + * @return Saver[] + */ + private function getSaverList(string $entityType): array + { + if (array_key_exists($entityType, $this->saverListMapCache)) { + return $this->saverListMapCache[$entityType]; + } + + $list = []; + + foreach ($this->getSaverClassNameList($entityType) as $className) { + $list[] = $this->createSaver($className); + } + + $this->saverListMapCache[$entityType] = $list; + + return $list; + } + + /** + * @return class-string>[] + */ + private function getSaverClassNameList(string $entityType): array + { + $list = $this->metadata + ->get(['app', 'fieldProcessing', 'saverClassNameList']) ?? []; + + $additionalList = $this->metadata + ->get(['recordDefs', $entityType, 'saverClassNameList']) ?? []; + + /** @var class-string>[] */ + return array_merge($list, $additionalList); + } + + /** + * @param class-string> $className + * @return Saver + */ + private function createSaver(string $className): Saver + { + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/FieldProcessing/Saver.php b/application/Espo/Core/FieldProcessing/Saver.php new file mode 100644 index 0000000..112c3a7 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Saver.php @@ -0,0 +1,46 @@ +. + * + * 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\FieldProcessing; + +use Espo\ORM\Entity; +use Espo\Core\FieldProcessing\Saver\Params; + +/** + * Processes saving special fields. + * + * @template TEntity of Entity + */ +interface Saver +{ + /** + * @param TEntity $entity + */ + public function process(Entity $entity, Params $params): void; +} diff --git a/application/Espo/Core/FieldProcessing/Saver/Params.php b/application/Espo/Core/FieldProcessing/Saver/Params.php new file mode 100644 index 0000000..a4bbef7 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Saver/Params.php @@ -0,0 +1,83 @@ +. + * + * 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\FieldProcessing\Saver; + +/** + * Immutable. + */ +class Params +{ + /** + * @var array + */ + private $options = []; + + public function __construct() + { + } + + public function hasOption(string $option): bool + { + return array_key_exists($option, $this->options); + } + + /** + * @return mixed + */ + public function getOption(string $option) + { + return $this->options[$option] ?? null; + } + + /** + * @return array + */ + public function getRawOptions(): array + { + return $this->options; + } + + /** + * @param array $options + */ + public function withRawOptions(array $options): self + { + $obj = clone $this; + + $obj->options = $options; + + return $obj; + } + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/FieldProcessing/SpecificFieldLoader.php b/application/Espo/Core/FieldProcessing/SpecificFieldLoader.php new file mode 100644 index 0000000..2f10add --- /dev/null +++ b/application/Espo/Core/FieldProcessing/SpecificFieldLoader.php @@ -0,0 +1,68 @@ +. + * + * 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\FieldProcessing; + +use Espo\Core\InjectableFactory; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use RuntimeException; + +/** + * @since 9.1.0 + * @internal Yet experimental. + */ +class SpecificFieldLoader +{ + public function __construct( + private Defs $defs, + private InjectableFactory $injectableFactory, + ) {} + + public function process(Entity $entity, string $field): void + { + /** @var ?class-string> $loaderClassName */ + $loaderClassName = $this->defs + ->getEntity($entity->getEntityType()) + ->tryGetField($field) + ?->getParam('loaderClassName'); + + if (!$loaderClassName) { + return; + } + + $loader = $this->injectableFactory->create($loaderClassName); + + if (!$loader instanceof Loader) { + throw new RuntimeException("Bad field loader."); + } + + $loader->process($entity, Loader\Params::create()); + } +} diff --git a/application/Espo/Core/FieldProcessing/Stars/StarLoader.php b/application/Espo/Core/FieldProcessing/Stars/StarLoader.php new file mode 100644 index 0000000..dd5208a --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Stars/StarLoader.php @@ -0,0 +1,60 @@ +. + * + * 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\FieldProcessing\Stars; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\Name\Field; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\Tools\Stars\StarService; + +/** + * @implements Loader + */ +class StarLoader implements Loader +{ + public function __construct( + private StarService $service, + private User $user + ) {} + + public function process(Entity $entity, Params $params): void + { + if ( + !$entity->hasAttribute(Field::IS_STARRED) || + !$this->service->isEnabled($entity->getEntityType()) + ) { + return; + } + + $entity->set(Field::IS_STARRED, $this->service->isStarred($entity, $this->user)); + } +} diff --git a/application/Espo/Core/FieldProcessing/Stream/FollowersLoader.php b/application/Espo/Core/FieldProcessing/Stream/FollowersLoader.php new file mode 100644 index 0000000..ccfbca8 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Stream/FollowersLoader.php @@ -0,0 +1,95 @@ +. + * + * 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\FieldProcessing\Stream; + +use Espo\Core\Name\Field; +use Espo\ORM\Entity; +use Espo\Core\Acl; +use Espo\Core\FieldProcessing\Loader as LoaderInterface; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\Tools\Stream\Service as StreamService; + +/** + * @implements LoaderInterface + */ +class FollowersLoader implements LoaderInterface +{ + private const FOLLOWERS_LIMIT = 6; + + public function __construct( + private StreamService $streamService, + private Metadata $metadata, + private User $user, + private Acl $acl, + private Config $config + ) {} + + public function process(Entity $entity, Params $params): void + { + $this->processIsFollowed($entity); + $this->processFollowers($entity); + } + + public function processIsFollowed(Entity $entity): void + { + if (!$entity->hasAttribute(Field::IS_FOLLOWED)) { + return; + } + + $isFollowed = $this->streamService->checkIsFollowed($entity); + + $entity->set(Field::IS_FOLLOWED, $isFollowed); + } + + public function processFollowers(Entity $entity): void + { + if ($this->user->isPortal()) { + return; + } + + if (!$this->metadata->get(['scopes', $entity->getEntityType(), 'stream'])) { + return; + } + + if (!$this->acl->checkEntityStream($entity)) { + return; + } + + $limit = $this->config->get('recordFollowersLoadLimit') ?? self::FOLLOWERS_LIMIT; + + $data = $this->streamService->getEntityFollowers($entity, 0, $limit); + + $entity->set(Field::FOLLOWERS . 'Ids', $data['idList']); + $entity->set(Field::FOLLOWERS . 'Names', $data['nameMap']); + } +} diff --git a/application/Espo/Core/FieldProcessing/VersionNumber/BeforeSaveProcessor.php b/application/Espo/Core/FieldProcessing/VersionNumber/BeforeSaveProcessor.php new file mode 100644 index 0000000..96d2b02 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/VersionNumber/BeforeSaveProcessor.php @@ -0,0 +1,74 @@ +. + * + * 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\FieldProcessing\VersionNumber; + +use Espo\Core\Name\Field; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; + +class BeforeSaveProcessor +{ + private const ATTRIBUTE_VERSION_NUMBER = Field::VERSION_NUMBER; + + public function __construct(private Metadata $metadata) + {} + + public function process(Entity $entity): void + { + $optimisticConcurrencyControl = $this->metadata + ->get(['entityDefs', $entity->getEntityType(), 'optimisticConcurrencyControl']); + + if (!$optimisticConcurrencyControl) { + return; + } + + if ($entity->isNew()) { + $entity->set(self::ATTRIBUTE_VERSION_NUMBER, 1); + + return; + } + + $entity->clear(self::ATTRIBUTE_VERSION_NUMBER); + + if (!$entity->hasFetched(self::ATTRIBUTE_VERSION_NUMBER)) { + return; + } + + $versionNumber = $entity->getFetched(self::ATTRIBUTE_VERSION_NUMBER); + + if ($versionNumber === null) { + $versionNumber = 0; + } + + $versionNumber++; + + $entity->set(self::ATTRIBUTE_VERSION_NUMBER, $versionNumber); + } +} diff --git a/application/Espo/Core/FieldProcessing/Wysiwyg/Saver.php b/application/Espo/Core/FieldProcessing/Wysiwyg/Saver.php new file mode 100644 index 0000000..df774c1 --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Wysiwyg/Saver.php @@ -0,0 +1,134 @@ +. + * + * 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\FieldProcessing\Wysiwyg; + +use Espo\Core\ORM\Type\FieldType; +use Espo\Entities\Attachment; +use Espo\ORM\Entity; +use Espo\Core\FieldProcessing\Saver as SaverInterface; +use Espo\Core\FieldProcessing\Saver\Params; +use Espo\Core\ORM\EntityManager; + +/** + * @implements SaverInterface + */ +class Saver implements SaverInterface +{ + /** @var array */ + private $fieldListMapCache = []; + + public function __construct(private EntityManager $entityManager) + {} + + public function process(Entity $entity, Params $params): void + { + foreach ($this->getFieldList($entity->getEntityType()) as $name) { + $this->processItem($entity, $name); + } + } + + private function processItem(Entity $entity, string $name): void + { + if (!$entity->has($name)) { + return; + } + + if (!$entity->isAttributeChanged($name)) { + return; + } + + $contents = $entity->get($name); + + if (!$contents) { + return; + } + + $matches = []; + + $matchResult = preg_match_all("/\?entryPoint=attachment&id=([^&=\"']+)/", $contents, $matches); + + if (!$matchResult) { + return; + } + + if (empty($matches[1]) || !is_array($matches[1])) { + return; + } + + foreach ($matches[1] as $id) { + $attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getById($id); + + if (!$attachment) { + continue; + } + + if ($attachment->getRelated()) { + continue; + } + + $attachment->set([ + 'relatedId' => $entity->getId(), + 'relatedType' => $entity->getEntityType(), + ]); + + $this->entityManager->saveEntity($attachment); + } + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType): array + { + if (array_key_exists($entityType, $this->fieldListMapCache)) { + return $this->fieldListMapCache[$entityType]; + } + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType); + + $list = []; + + foreach ($entityDefs->getFieldNameList() as $name) { + $defs = $entityDefs->getField($name); + + if ($defs->getType() !== FieldType::WYSIWYG) { + continue; + } + + $list[] = $name; + } + + $this->fieldListMapCache[$entityType] = $list; + + return $list; + } +} diff --git a/application/Espo/Core/FieldSanitize/SanitizeManager.php b/application/Espo/Core/FieldSanitize/SanitizeManager.php new file mode 100644 index 0000000..3d6fb8e --- /dev/null +++ b/application/Espo/Core/FieldSanitize/SanitizeManager.php @@ -0,0 +1,135 @@ +. + * + * 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\FieldSanitize; + +use Espo\Core\FieldSanitize\Sanitizer\Data; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\FieldUtil; +use Espo\Core\Utils\Metadata; +use stdClass; + +class SanitizeManager +{ + public function __construct( + private Metadata $metadata, + private FieldUtil $fieldUtil, + private InjectableFactory $injectableFactory + ) {} + + public function process(string $entityType, stdClass $rawData): void + { + $data = new Data($rawData); + + foreach ($this->fieldUtil->getEntityTypeFieldList($entityType) as $field) { + if (!$this->isFieldSetInData($entityType, $field, $rawData)) { + continue; + } + + $this->processField($entityType, $field, $data); + } + } + + private function processField(string $entityType, string $field, Data $data): void + { + foreach ($this->getSanitizerList($entityType, $field) as $sanitizer) { + $sanitizer->sanitize($data, $field); + } + } + + private function isFieldSetInData(string $entityType, string $field, stdClass $data): bool + { + $attributeList = $this->fieldUtil->getActualAttributeList($entityType, $field); + + $isSet = false; + + foreach ($attributeList as $attribute) { + if (property_exists($data, $attribute)) { + $isSet = true; + + break; + } + } + + return $isSet; + } + + /** + * @return Sanitizer[] + */ + private function getSanitizerList(string $entityType, string $field): array + { + $classNameList = $this->getClassNameList($entityType, $field); + + return array_map( + fn ($className) => $this->injectableFactory->createWith($className, ['entityType' => $entityType]), + $classNameList + ); + } + + /** + * @return class-string[] + */ + private function getClassNameList(string $entityType, string $field): array + { + $fieldType = $this->fieldUtil->getFieldType($entityType, $field); + + if (!$fieldType) { + return []; + } + + $classNameList = []; + + /** @var ?class-string $className */ + $className = $this->metadata->get("fields.$fieldType.sanitizerClassName"); + + if ($className) { + $classNameList[] = $className; + } + + /** @var class-string[] $typeClassNameList */ + $typeClassNameList = $this->metadata->get("fields.$fieldType.sanitizerClassNameList") ?? []; + + $classNameList = array_merge($classNameList, $typeClassNameList); + + /** @var class-string[] $fieldClassNameList */ + $fieldClassNameList = $this->metadata->get("entityDefs.$entityType.fields.$field.sanitizerClassNameList") ?? []; + $ignoreList = $this->metadata->get("entityDefs.$entityType.fields.$field.sanitizerSuppressClassNameList") ?? []; + + $list = array_merge($classNameList, $fieldClassNameList); + + if ($ignoreList === []) { + return $list; + } + + $list = array_diff($list, $ignoreList); + + return array_values($list); + } +} diff --git a/application/Espo/Core/FieldSanitize/Sanitizer.php b/application/Espo/Core/FieldSanitize/Sanitizer.php new file mode 100644 index 0000000..4795978 --- /dev/null +++ b/application/Espo/Core/FieldSanitize/Sanitizer.php @@ -0,0 +1,37 @@ +. + * + * 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\FieldSanitize; + +use Espo\Core\FieldSanitize\Sanitizer\Data; + +interface Sanitizer +{ + public function sanitize(Data $data, string $field): void; +} diff --git a/application/Espo/Core/FieldSanitize/Sanitizer/Data.php b/application/Espo/Core/FieldSanitize/Sanitizer/Data.php new file mode 100644 index 0000000..7131cc9 --- /dev/null +++ b/application/Espo/Core/FieldSanitize/Sanitizer/Data.php @@ -0,0 +1,78 @@ +. + * + * 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\FieldSanitize\Sanitizer; + +use stdClass; + +/** + * Input data. No 'clear' method, as unsetting is not supposed to happen in sanitization. + */ +class Data +{ + public function __construct(private stdClass $data) + {} + + /** + * Get a value. + */ + public function get(string $attribute): mixed + { + return $this->data->$attribute ?? null; + } + + + /** + * Whether a value is set. + */ + public function has(string $attribute): bool + { + return property_exists($this->data, $attribute); + } + + /** + * Update a value. + */ + public function set(string $attribute, mixed $value): self + { + $this->data->$attribute = $value; + + return $this; + } + + /** + * Unset an attribute. + */ + public function clear(string $attribute): self + { + unset($this->data->$attribute); + + return $this; + } +} diff --git a/application/Espo/Core/FieldValidation/CheckerFactory.php b/application/Espo/Core/FieldValidation/CheckerFactory.php new file mode 100644 index 0000000..c19f4f9 --- /dev/null +++ b/application/Espo/Core/FieldValidation/CheckerFactory.php @@ -0,0 +1,115 @@ +. + * + * 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\FieldValidation; + +use Espo\Core\Utils\Metadata; +use Espo\Core\InjectableFactory; + +use RuntimeException; + +class CheckerFactory +{ + /** @var array */ + private $classNameCache = []; + + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + /** + * Whether a field validator can be created. + */ + public function isCreatable(string $entityType, string $field): bool + { + return (bool) $this->getClassName($entityType, $field); + } + + /** + * Create a validator (checker). + * + * @throws RuntimeException + */ + public function create(string $entityType, string $field): object + { + $className = $this->getClassName($entityType, $field); + + if (!$className) { + throw new RuntimeException("Validator for '{$entityType}.{$field}' does not exist."); + } + + return $this->injectableFactory->create($className); + } + + /** + * @return ?class-string + */ + private function getClassName(string $entityType, string $field): ?string + { + $key = $entityType . '_' . $field; + + if (!array_key_exists($key, $this->classNameCache)) { + $this->classNameCache[$key] = $this->getClassNameNoCache($entityType, $field); + } + + return $this->classNameCache[$key]; + } + + /** + * @return ?class-string + */ + private function getClassNameNoCache(string $entityType, string $field): ?string + { + $className1 = $this->metadata + ->get(['entityDefs', $entityType, 'fields', $field, 'validatorClassName']); + + if ($className1) { + return $className1; + } + + $fieldType = $this->metadata + ->get(['entityDefs', $entityType, 'fields', $field, 'type']); + + $className2 = $this->metadata + ->get(['fields', $fieldType, 'validatorClassName']); + + if ($className2) { + return $className2; + } + + $className3 = 'Espo\\Classes\\FieldValidators\\' . ucfirst($fieldType) . 'Type'; + + if (class_exists($className3)) { + return $className3; + } + + return null; + } +} diff --git a/application/Espo/Core/FieldValidation/Exceptions/ValidationError.php b/application/Espo/Core/FieldValidation/Exceptions/ValidationError.php new file mode 100644 index 0000000..295d086 --- /dev/null +++ b/application/Espo/Core/FieldValidation/Exceptions/ValidationError.php @@ -0,0 +1,109 @@ +. + * + * 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\FieldValidation\Exceptions; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Exceptions\HasLogMessage; +use Espo\Core\FieldValidation\Failure; + +use LogicException; + +final class ValidationError extends BadRequest implements HasLogMessage +{ + private ?Failure $failure = null; + + /** + * Create with a body (supposed to be sent to the frontend). + * Body object is supported since v8.1. + */ + public static function createWithBody(string $reason, string|Body $body): self + { + if ($body instanceof Body) { + $body = $body->encode(); + } + + $exception = parent::createWithBody($reason, $body); + + if (!$exception instanceof self) { + throw new LogicException(); + } + + return $exception; + } + + public static function create(Failure $failure): self + { + $exception = self::createWithBody( + 'validationFailure', + Body::create() + ->withMessageTranslation('validationFailure', null, [ + 'field' => $failure->getField(), + 'type' => $failure->getType(), + ]) + ->encode() + ); + + $exception->failure = $failure; + + return $exception; + } + + /** + * @since 10.0.0 + */ + public function hasFailure(): bool + { + return $this->failure !== null; + } + + public function getFailure(): Failure + { + if (!$this->failure) { + throw new LogicException(); + } + + return $this->failure; + } + + public function getLogMessage(): string + { + if (!$this->failure) { + return "Field validation failure."; + } + + $entityType = $this->failure->getEntityType(); + $field = $this->failure->getField(); + $type = $this->failure->getType(); + + return "Field validation failure; " . + "entityType: {$entityType}, field: {$field}, type: {$type}."; + } +} diff --git a/application/Espo/Core/FieldValidation/Failure.php b/application/Espo/Core/FieldValidation/Failure.php new file mode 100644 index 0000000..75322d9 --- /dev/null +++ b/application/Espo/Core/FieldValidation/Failure.php @@ -0,0 +1,54 @@ +. + * + * 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\FieldValidation; + +class Failure +{ + public function __construct( + private string $entityType, + private string $field, + private string $type + ) {} + + public function getEntityType(): string + { + return $this->entityType; + } + + public function getField(): string + { + return $this->field; + } + + public function getType(): string + { + return $this->type; + } +} diff --git a/application/Espo/Core/FieldValidation/FieldValidationManager.php b/application/Espo/Core/FieldValidation/FieldValidationManager.php new file mode 100644 index 0000000..473dd91 --- /dev/null +++ b/application/Espo/Core/FieldValidation/FieldValidationManager.php @@ -0,0 +1,516 @@ +. + * + * 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\FieldValidation; + +use Espo\Core\Utils\Log; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use Espo\Core\FieldValidation\Exceptions\ValidationError; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\FieldUtil; +use Espo\Tools\DynamicLogic\ConditionCheckerFactory; +use Espo\Tools\DynamicLogic\Exceptions\BadCondition; +use Espo\Tools\DynamicLogic\Item as LogicItem; + +use LogicException; +use stdClass; +use ReflectionClass; + +/** + * A field validation manager. + */ +class FieldValidationManager +{ + /** @var array */ + private array $checkerCache = []; + /** @var array> */ + private array $validatorCache = []; + + private CheckerFactory $checkerFactory; + + public function __construct( + private Metadata $metadata, + private FieldUtil $fieldUtil, + CheckerFactory $factory, + private ValidatorFactory $validatorFactory, + private Defs $defs, + private ConditionCheckerFactory $conditionCheckerFactory, + private Log $log, + ) { + $this->checkerFactory = $factory; + } + + /** + * Process validation. + * + * @param Entity $entity An entity. + * @param ?stdClass $data Raw request payload data. + * @param ?FieldValidationParams $params Validation additional parameters. + * + * @throws ValidationError On the first invalid check. + */ + public function process(Entity $entity, ?stdClass $data = null, ?FieldValidationParams $params = null): void + { + $this->processInternal($entity, $data, $params, true); + } + + /** + * Process validation w/o exception throwing. + * + * @param Entity $entity An entity. + * @param ?stdClass $data Raw request payload data. + * @param ?FieldValidationParams $params Validation additional parameters. + * @return Failure[] A list of validation failures. + */ + public function processAll(Entity $entity, ?stdClass $data = null, ?FieldValidationParams $params = null): array + { + try { + return $this->processInternal($entity, $data, $params, false); + } catch (ValidationError) { + throw new LogicException(); + } + } + + /** + * @return Failure[] + * @throws ValidationError On the first invalid check. + */ + private function processInternal( + Entity $entity, + ?stdClass $data, + ?FieldValidationParams $params, + bool $throw + ): array { + + $dataIsSet = $data !== null; + $entityType = $entity->getEntityType(); + + $data ??= (object) []; + $params ??= new FieldValidationParams(); + $failureList = []; + $fieldList = $this->getFieldList($entityType, $params); + $entityDefs = $this->defs->getEntity($entityType); + + foreach ($fieldList as $field) { + $typeList = null; + + $fieldDefs = $entityDefs->tryGetField($field); + + if ( + !$entity->isNew() && + $dataIsSet && + !$fieldDefs?->getParam('forceValidation') && + !$this->isFieldSetInData($entityType, $field, $data, $fieldDefs) + ) { + if (!$this->hasRequiredLogic($entityType, $field)) { + continue; + } + + $typeList = [Type::REQUIRED]; + } + + $itemFailureList = $this->processField( + entity: $entity, + field: $field, + params: $params, + data: $data, + throw: $throw, + typeList: $typeList, + ); + + $failureList = array_merge($failureList, $itemFailureList); + } + + return $failureList; + } + + /** + * @return string[] + */ + private function getMandatoryValidationList(string $entityType, string $field): array + { + /** @var ?string $fieldType */ + $fieldType = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, 'type'); + + return + $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'mandatoryValidationList']) ?? + $this->metadata->get(['fields', $fieldType ?? '', 'mandatoryValidationList']) ?? []; + } + + /** + * @return string[] + */ + private function getValidationList(string $entityType, string $field): array + { + /** @var ?string $fieldType */ + $fieldType = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, 'type'); + + return + $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'validationList']) ?? + $this->metadata->get(['fields', $fieldType ?? '', 'validationList']) ?? []; + } + + /** + * Check a specific field for a specific validation type. + * + * @param Entity $entity An entity to check. + * @param string $field A field to check. + * @param string $type A validation type. + * @param ?stdClass $data A payload. + * @param mixed $value To override a validation value. + */ + public function check( + Entity $entity, + string $field, + string $type, + ?stdClass $data = null, + mixed $value = null + ): bool { + + $data ??= (object) []; + $entityType = $entity->getEntityType(); + + $validationValue = $value ?? $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, $type); + $isMandatory = in_array($type, $this->getMandatoryValidationList($entityType, $field)); + + if ( + $type === Type::REQUIRED && + !$validationValue && + $this->checkDynamicLogicRequired($entity, $field) + ) { + $validationValue = true; + } + + if ( + !$isMandatory && + ($validationValue === null || $validationValue === false) + ) { + return true; + } + + $result = $this->processTypeValidator($entity, $field, $type, new Data($data)); + + if (!$result) { + return false; + } + + $result1 = $this->processFieldCheck($entityType, $type, $entity, $field, $validationValue); + + if (!$result1) { + return false; + } + + $result2 = $this->processFieldRawCheck($entityType, $type, $data, $field, $validationValue); + + if (!$result2) { + return false; + } + + return true; + } + + private function processTypeValidator(Entity $entity, string $field, string $type, Data $data): bool + { + $validator = $this->getValidator($entity->getEntityType(), $field, $type); + + if (!$validator) { + return true; + } + + $failure = $validator->validate($entity, $field, $data); + + if ($failure) { + return false; + } + + return true; + } + + /** + * @return ?Validator + */ + private function getValidator(string $entityType, string $field, string $type): ?Validator + { + $key = $entityType . '_' . $field . '_' . $type; + + if (array_key_exists($key, $this->validatorCache)) { + return $this->validatorCache[$key]; + } + + if (!$this->validatorFactory->isCreatable($entityType, $field, $type)) { + $this->validatorCache[$key] = null; + + return null; + } + + $validator = $this->validatorFactory->create($entityType, $field, $type); + + $this->validatorCache[$key] = $validator; + + return $validator; + } + + /** + * @param ?string[] $typeList + * @return Failure[] + * @throws ValidationError + */ + private function processField( + Entity $entity, + string $field, + FieldValidationParams $params, + stdClass $data, + bool $throw, + ?array $typeList = null, + ): array { + + $validationList = $this->getAllValidationList($entity->getEntityType(), $field, $params); + + foreach ($validationList as $type) { + if ($typeList !== null && !in_array($type, $typeList)) { + continue; + } + + $result = $this->check($entity, $field, $type, $data); + + if ($result) { + continue; + } + + $failure = new Failure($entity->getEntityType(), $field, $type); + + if ($throw) { + throw ValidationError::create($failure); + } + + return [$failure]; + } + + $failure = $this->checkAdditional($entity, $field, new Data($data)); + + if (!$failure) { + return []; + } + + if ($throw) { + throw ValidationError::create($failure); + } + + return [$failure]; + } + + /** + * @return string[] + */ + private function getAllValidationList(string $entityType, string $field, FieldValidationParams $params): array + { + $validationList = array_unique(array_merge( + $this->getValidationList($entityType, $field), + $this->getMandatoryValidationList($entityType, $field) + )); + + /** @var string[] $suppressList */ + $suppressList = $this->metadata->get("entityDefs.$entityType.fields.$field.suppressValidationList") ?? []; + + $validationList = array_filter( + $validationList, + fn ($type) => !in_array($type, $suppressList) + ); + + $validationList = array_filter( + $validationList, + fn ($type) => !in_array($field, $params->getTypeSkipFieldList($type)) + ); + + return array_values($validationList); + } + + /** + * @param mixed $validationValue + */ + private function processFieldCheck( + string $entityType, + string $type, + Entity $entity, + string $field, + $validationValue + ): bool { + + $checker = $this->getFieldTypeChecker($entityType, $field); + + if (!$checker) { + return true; + } + + $methodName = 'check' . ucfirst($type); + + if (!method_exists($checker, $methodName)) { + return true; + } + + return $checker->$methodName($entity, $field, $validationValue); + } + + /** + * @param mixed $validationValue + */ + private function processFieldRawCheck( + string $entityType, + string $type, + stdClass $data, + string $field, + $validationValue + ): bool { + + $checker = $this->getFieldTypeChecker($entityType, $field); + + if (!$checker) { + return true; + } + + $methodName = 'rawCheck' . ucfirst($type); + + if (!method_exists($checker, $methodName)) { + return true; + } + + return $checker->$methodName($data, $field, $validationValue); + } + + private function getFieldTypeChecker(string $entityType, string $field): ?object + { + $key = $entityType . '_' . $field; + + if (!array_key_exists($key, $this->checkerCache)) { + $this->loadFieldTypeChecker($entityType, $field); + } + + return $this->checkerCache[$key]; + } + + private function loadFieldTypeChecker(string $entityType, string $field): void + { + $key = $entityType . '_' . $field; + + if (!$this->checkerFactory->isCreatable($entityType, $field)) { + $this->checkerCache[$key] = null; + + return; + } + + $this->checkerCache[$key] = $this->checkerFactory->create($entityType, $field); + } + + private function isFieldSetInData(string $entityType, string $field, stdClass $data, ?Defs\FieldDefs $defs): bool + { + $attributeList = $this->fieldUtil->getActualAttributeList($entityType, $field); + + $additionalFieldList = $defs?->getParam('validationDependsOnFieldList') ?? []; + + foreach ($additionalFieldList as $additionalField) { + $attributeList = array_merge( + $attributeList, + $this->fieldUtil->getActualAttributeList($entityType, $additionalField) + ); + } + + $isSet = false; + + foreach ($attributeList as $attribute) { + if (property_exists($data, $attribute)) { + $isSet = true; + + break; + } + } + + return $isSet; + } + + private function checkAdditional(Entity $entity, string $field, Data $data): ?Failure + { + $validatorList = $this->validatorFactory->createAdditionalList($entity->getEntityType(), $field); + + foreach ($validatorList as $validator) { + $itemFailure = $validator->validate($entity, $field, $data); + + if (!$itemFailure) { + continue; + } + + $type = lcfirst((new ReflectionClass($validator))->getShortName()); + + return new Failure($entity->getEntityType(), $field, $type); + } + + return null; + } + + private function checkDynamicLogicRequired(Entity $entity, string $field): bool + { + $entityType = $entity->getEntityType(); + + /** @var stdClass[] $group */ + $group = $this->metadata->getObjects("logicDefs.$entityType.fields.$field.required.conditionGroup"); + + if (!is_array($group)) { + return false; + } + + $checker = $this->conditionCheckerFactory->create($entity); + + try { + $item = LogicItem::fromGroupDefinition($group); + + return $checker->check($item); + } catch (BadCondition $e) { + $this->log->warning("Bad logic condition for $entityType $field.", ['exception' => $e]); + } + + return false; + } + + /** + * @return string[] + */ + private function getFieldList(string $entityType, FieldValidationParams $params): array + { + return array_filter( + $this->fieldUtil->getEntityTypeFieldList($entityType), + fn($field) => !in_array($field, $params->getSkipFieldList()) + ); + } + + private function hasRequiredLogic(string $entityType, string $field): bool + { + return (bool) $this->metadata->get("logicDefs.$entityType.fields.$field.required"); + } +} diff --git a/application/Espo/Core/FieldValidation/FieldValidationParams.php b/application/Espo/Core/FieldValidation/FieldValidationParams.php new file mode 100644 index 0000000..bd49ce5 --- /dev/null +++ b/application/Espo/Core/FieldValidation/FieldValidationParams.php @@ -0,0 +1,96 @@ +. + * + * 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\FieldValidation; + +class FieldValidationParams +{ + /** @var string[] */ + private $skipFieldList = []; + /** @var array */ + private $typeSkipFieldListData = []; + + public function __construct() {} + + /** + * A field list that will be skipped when validating. + * + * @return string[] A field list. + */ + public function getSkipFieldList(): array + { + return $this->skipFieldList; + } + + /** + * A field list that will be skipped in validation for a specific validation type. + * + * @param string $type A validation type. + * @return string[] A field list. + */ + public function getTypeSkipFieldList(string $type): array + { + return $this->typeSkipFieldListData[$type] ?? []; + } + + /** + * Clone with a specified field list that will be skipped when validating. + * + * @param string[] $list A field list. + */ + public function withSkipFieldList(array $list): self + { + $obj = clone $this; + $obj->skipFieldList = $list; + + return $obj; + } + + /** + * Clone with a specified field list that will be skipped in validation for a specific validation type. + * + * @param string $type A validation type. + * @param string[] $list A field list. + */ + public function withTypeSkipFieldList(string $type, array $list): self + { + $obj = clone $this; + $obj->typeSkipFieldListData[$type] = $list; + + return $obj; + } + + /** + * Create an empty instance. + */ + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/FieldValidation/Type.php b/application/Espo/Core/FieldValidation/Type.php new file mode 100644 index 0000000..2d8b71d --- /dev/null +++ b/application/Espo/Core/FieldValidation/Type.php @@ -0,0 +1,38 @@ +. + * + * 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\FieldValidation; + +/** + * @since 9.1.0 + */ +class Type +{ + public const REQUIRED = 'required'; +} diff --git a/application/Espo/Core/FieldValidation/Validator.php b/application/Espo/Core/FieldValidation/Validator.php new file mode 100644 index 0000000..e19e267 --- /dev/null +++ b/application/Espo/Core/FieldValidation/Validator.php @@ -0,0 +1,50 @@ +. + * + * 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\FieldValidation; + +use Espo\ORM\Entity; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; + +/** + * A field validator. Checks whether field values are valid. Raw payload values are available in the `data`. + * Values that managed to be set to the Entity can be obtained from the `entity`. + * + * Note: Validators are not supposed to perform any access control checks. + * + * @template TEntity of Entity + */ +interface Validator +{ + /** + * @param TEntity $entity + */ + public function validate(Entity $entity, string $field, Data $data): ?Failure; +} diff --git a/application/Espo/Core/FieldValidation/Validator/Data.php b/application/Espo/Core/FieldValidation/Validator/Data.php new file mode 100644 index 0000000..d15bf41 --- /dev/null +++ b/application/Espo/Core/FieldValidation/Validator/Data.php @@ -0,0 +1,54 @@ +. + * + * 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\FieldValidation\Validator; + +use Espo\Core\Utils\ObjectUtil; +use stdClass; + +class Data +{ + public function __construct(private stdClass $data) + {} + + public function has(string $name): bool + { + return property_exists($this->data, $name); + } + + public function get(string $name): mixed + { + return $this->getClonedData()->$name ?? null; + } + + private function getClonedData(): stdClass + { + return ObjectUtil::clone($this->data); + } +} diff --git a/application/Espo/Core/FieldValidation/Validator/Failure.php b/application/Espo/Core/FieldValidation/Validator/Failure.php new file mode 100644 index 0000000..5fc4830 --- /dev/null +++ b/application/Espo/Core/FieldValidation/Validator/Failure.php @@ -0,0 +1,41 @@ +. + * + * 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\FieldValidation\Validator; + +class Failure +{ + private function __construct() + {} + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/FieldValidation/ValidatorFactory.php b/application/Espo/Core/FieldValidation/ValidatorFactory.php new file mode 100644 index 0000000..4ea2be0 --- /dev/null +++ b/application/Espo/Core/FieldValidation/ValidatorFactory.php @@ -0,0 +1,97 @@ +. + * + * 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\FieldValidation; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\FieldUtil; + +use Espo\ORM\Entity; +use RuntimeException; + +class ValidatorFactory +{ + + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private FieldUtil $fieldUtil + ) {} + + public function isCreatable(string $entityType, string $field, string $type): bool + { + return $this->getClassName($entityType, $field, $type) !== null; + } + + /** + * @return Validator + */ + public function create(string $entityType, string $field, string $type): Validator + { + $className = $this->getClassName($entityType, $field, $type); + + if (!$className) { + throw new RuntimeException("No validator."); + } + + return $this->injectableFactory->create($className); + } + + /** + * @return ?class-string> + */ + private function getClassName(string $entityType, string $field, string $type): ?string + { + /** @var ?string $fieldType */ + $fieldType = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, 'type'); + + return + $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'validatorClassNameMap', $type]) ?? + $this->metadata->get(['fields', $fieldType ?? '', 'validatorClassNameMap', $type]); + } + + /** + * @return Validator[] + */ + public function createAdditionalList(string $entityType, string $field): array + { + /** @var class-string>[] $classNameList */ + $classNameList = $this->metadata + ->get(['entityDefs', $entityType, 'fields', $field, 'validatorClassNameList']) ?? []; + + $list = []; + + foreach ($classNameList as $className) { + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } +} diff --git a/application/Espo/Core/FileStorage/Attachment.php b/application/Espo/Core/FileStorage/Attachment.php new file mode 100644 index 0000000..72cb5e0 --- /dev/null +++ b/application/Espo/Core/FileStorage/Attachment.php @@ -0,0 +1,38 @@ +. + * + * 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\FileStorage; + +interface Attachment +{ + /** + * Get a source ID. + */ + public function getSourceId(): string; +} diff --git a/application/Espo/Core/FileStorage/AttachmentEntityWrapper.php b/application/Espo/Core/FileStorage/AttachmentEntityWrapper.php new file mode 100644 index 0000000..697c051 --- /dev/null +++ b/application/Espo/Core/FileStorage/AttachmentEntityWrapper.php @@ -0,0 +1,59 @@ +. + * + * 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\FileStorage; + +use Espo\Entities\Attachment as AttachmentEntity; + +use RuntimeException; + +class AttachmentEntityWrapper implements Attachment +{ + private AttachmentEntity $attachment; + + public function __construct(AttachmentEntity $attachment) + { + if (!$attachment->getSourceId()) { + throw new RuntimeException("Attachment w/o a source ID."); + } + + $this->attachment = $attachment; + } + + public function getSourceId(): string + { + $sourceId = $this->attachment->getSourceId(); + + if (!$sourceId) { + throw new RuntimeException("Attachment w/o a source ID."); + } + + return $sourceId; + } +} diff --git a/application/Espo/Core/FileStorage/Factory.php b/application/Espo/Core/FileStorage/Factory.php new file mode 100644 index 0000000..881bf1e --- /dev/null +++ b/application/Espo/Core/FileStorage/Factory.php @@ -0,0 +1,55 @@ +. + * + * 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\FileStorage; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +use RuntimeException; + +class Factory +{ + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + public function create(string $name): Storage + { + /** @var ?class-string $className */ + $className = $this->metadata->get(['app', 'fileStorage', 'implementationClassNameMap', $name]); + + if (!$className) { + throw new RuntimeException("Unknown file storage '$name'."); + } + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/FileStorage/Local.php b/application/Espo/Core/FileStorage/Local.php new file mode 100644 index 0000000..95d709e --- /dev/null +++ b/application/Espo/Core/FileStorage/Local.php @@ -0,0 +1,41 @@ +. + * + * 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\FileStorage; + +/** + * Files are stored locally. + */ +interface Local +{ + /** + * Get a local file path. + */ + public function getLocalFilePath(Attachment $attachment): string; +} diff --git a/application/Espo/Core/FileStorage/Manager.php b/application/Espo/Core/FileStorage/Manager.php new file mode 100644 index 0000000..4a8805f --- /dev/null +++ b/application/Espo/Core/FileStorage/Manager.php @@ -0,0 +1,196 @@ +. + * + * 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\FileStorage; + +use Espo\Entities\Attachment as AttachmentEntity; +use Psr\Http\Message\StreamInterface; + +use GuzzleHttp\Psr7\Utils; + +use RuntimeException; + + +/** + * An access point for file storing and fetching. Files are represented as Attachment entities. + */ +class Manager +{ + private const DEFAULT_STORAGE = 'EspoUploadDir'; + + /** @var array */ + private array $implHash = []; + + /** + * @var array + * @noinspection PhpPropertyOnlyWrittenInspection + * @phpstan-ignore-next-line Used to prevent deleting from memory. + */ + private $resourceMap = []; + + public function __construct(private Factory $factory) + {} + + /** + * Whether a file exists in a storage. + */ + public function exists(AttachmentEntity $attachment): bool + { + $implementation = $this->getImplementation($attachment); + + return $implementation->exists(self::wrapAttachmentEntity($attachment)); + } + + /** + * Get a file size. + */ + public function getSize(AttachmentEntity $attachment): int + { + $implementation = $this->getImplementation($attachment); + + return $implementation->getSize(self::wrapAttachmentEntity($attachment)); + } + + /** + * Get file contents. + */ + public function getContents(AttachmentEntity $attachment): string + { + $implementation = $this->getImplementation($attachment); + + return $implementation->getStream(self::wrapAttachmentEntity($attachment))->getContents(); + } + + /** + * Get a file contents stream. + */ + public function getStream(AttachmentEntity $attachment): StreamInterface + { + $implementation = $this->getImplementation($attachment); + + return $implementation->getStream(self::wrapAttachmentEntity($attachment)); + } + + /** + * Store file contents represented as a stream. + */ + public function putStream(AttachmentEntity $attachment, StreamInterface $stream): void + { + $implementation = $this->getImplementation($attachment); + + $implementation->putStream(self::wrapAttachmentEntity($attachment), $stream); + } + + /** + * Store file contents. + */ + public function putContents(AttachmentEntity $attachment, string $contents): void + { + $implementation = $this->getImplementation($attachment); + + $stream = Utils::streamFor($contents); + + $implementation->putStream(self::wrapAttachmentEntity($attachment), $stream); + } + + /** + * Remove a file. + */ + public function unlink(AttachmentEntity $attachment): void + { + $implementation = $this->getImplementation($attachment); + + $implementation->unlink(self::wrapAttachmentEntity($attachment)); + } + + /** + * Whether an attachment storage is local. + */ + public function isLocal(AttachmentEntity $attachment): bool + { + $implementation = $this->getImplementation($attachment); + + return $implementation instanceof Local; + } + + /** + * Get a local file path. If a file is not stored locally, a temporary file will be created. + */ + public function getLocalFilePath(AttachmentEntity $attachment): string + { + $implementation = $this->getImplementation($attachment); + + if ($implementation instanceof Local) { + return $implementation->getLocalFilePath(self::wrapAttachmentEntity($attachment)); + } + + $contents = $this->getContents($attachment); + + $resource = tmpfile(); + + if ($resource === false) { + throw new RuntimeException("Could not create temp file."); + } + + fwrite($resource, $contents); + + // PhpStan's bug. Check later, remove 'ignore' if fixed. + /** @phpstan-ignore-next-line */ + $path = stream_get_meta_data($resource)['uri']; + + if (!$path) { + throw new RuntimeException("No uri."); + } + + // To prevent deleting. + $this->resourceMap[$path] = $resource; + + return $path; + } + + private static function wrapAttachmentEntity(AttachmentEntity $attachment): AttachmentEntityWrapper + { + return new AttachmentEntityWrapper($attachment); + } + + private function getImplementation(AttachmentEntity $attachment): Storage + { + $storage = $attachment->getStorage(); + + if (!$storage) { + $storage = self::DEFAULT_STORAGE; + } + + if (!array_key_exists($storage, $this->implHash)) { + $this->implHash[$storage] = $this->factory->create($storage); + } + + return $this->implHash[$storage]; + } +} diff --git a/application/Espo/Core/FileStorage/Storage.php b/application/Espo/Core/FileStorage/Storage.php new file mode 100644 index 0000000..970db7d --- /dev/null +++ b/application/Espo/Core/FileStorage/Storage.php @@ -0,0 +1,63 @@ +. + * + * 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\FileStorage; + +use Psr\Http\Message\StreamInterface; + +/** + * File storing and fetching. + */ +interface Storage +{ + /** + * Get file contents as a stream. + */ + public function getStream(Attachment $attachment): StreamInterface; + + /** + * Store file contents. + */ + public function putStream(Attachment $attachment, StreamInterface $stream): void; + + /** + * Whether a file exists. + */ + public function exists(Attachment $attachment): bool; + + /** + * Delete a file. + */ + public function unlink(Attachment $attachment): void; + + /** + * Get a file size. + */ + public function getSize(Attachment $attachment): int; +} diff --git a/application/Espo/Core/FileStorage/Storages/AwsS3.php b/application/Espo/Core/FileStorage/Storages/AwsS3.php new file mode 100644 index 0000000..c234015 --- /dev/null +++ b/application/Espo/Core/FileStorage/Storages/AwsS3.php @@ -0,0 +1,157 @@ +. + * + * 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\FileStorage\Storages; + +use Psr\Http\Message\StreamInterface; +use AsyncAws\S3\S3Client; +use League\Flysystem\FilesystemException; +use League\Flysystem\AsyncAwsS3\AsyncAwsS3Adapter; +use League\Flysystem\Filesystem; +use GuzzleHttp\Psr7\Stream; + +use Espo\Core\FileStorage\Attachment; +use Espo\Core\FileStorage\Storage; +use Espo\Core\Utils\Config; + +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class AwsS3 implements Storage +{ + private Filesystem $filesystem; + + public function __construct(Config $config) + { + $bucketName = $config->get('awsS3Storage.bucketName') ?? null; + $path = $config->get('awsS3Storage.path') ?? null; + $region = $config->get('awsS3Storage.region') ?? null; + $credentials = $config->get('awsS3Storage.credentials') ?? null; + $endpoint = $config->get('awsS3Storage.endpoint') ?? null; + $pathStyleEndpoint = $config->get('awsS3Storage.pathStyleEndpoint') ?? false; + $sendChunkedBody = $config->get('awsS3Storage.sendChunkedBody') ?? null; + + if (!$bucketName) { + throw new RuntimeException("AWS S3 bucket name is not specified in config."); + } + + /** @var array<\AsyncAws\Core\Configuration::OPTION_*, string|null> $clientOptions */ + $clientOptions = [ + 'region' => $region, + ]; + + if ($endpoint) { + $clientOptions['endpoint'] = $endpoint; + } + + if ($pathStyleEndpoint) { + $clientOptions['pathStyleEndpoint'] = (bool) $pathStyleEndpoint; + } + + // Defaulted to true in the library, but the docs is not clear enough. + if ($sendChunkedBody !== null) { + $clientOptions['sendChunkedBody'] = (bool) $sendChunkedBody; + } + + if ($credentials && is_array($credentials)) { + $clientOptions['accessKeyId'] = $credentials['key'] ?? null; + $clientOptions['accessKeySecret'] = $credentials['secret'] ?? null; + } + + $client = new S3Client($clientOptions); + $adapter = new AsyncAwsS3Adapter($client, $bucketName, $path); + + $this->filesystem = new Filesystem($adapter); + } + + public function unlink(Attachment $attachment): void + { + try { + $this->filesystem->delete($attachment->getSourceId()); + } catch (FilesystemException $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + } + + public function exists(Attachment $attachment): bool + { + try { + return $this->filesystem->fileExists($attachment->getSourceId()); + } catch (FilesystemException $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + } + + public function getSize(Attachment $attachment): int + { + try { + return $this->filesystem->fileSize($attachment->getSourceId()); + } catch (FilesystemException $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + } + + public function getStream(Attachment $attachment): StreamInterface + { + try { + $resource = $this->filesystem->readStream($attachment->getSourceId()); + } catch (FilesystemException $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + + return new Stream($resource); + } + + public function putStream(Attachment $attachment, StreamInterface $stream): void + { + // League\Flysystem does not support StreamInterface. + // Need to pass a resource. + + $resource = fopen('php://temp', 'r+'); + + if ($resource === false) { + throw new RuntimeException("Could not open temp."); + } + + $stream->rewind(); + + fwrite($resource, $stream->getContents()); + rewind($resource); + + try { + $this->filesystem->writeStream($attachment->getSourceId(), $resource); + } catch (FilesystemException $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + + fclose($resource); + } +} diff --git a/application/Espo/Core/FileStorage/Storages/EspoUploadDir.php b/application/Espo/Core/FileStorage/Storages/EspoUploadDir.php new file mode 100644 index 0000000..85a06db --- /dev/null +++ b/application/Espo/Core/FileStorage/Storages/EspoUploadDir.php @@ -0,0 +1,122 @@ +. + * + * 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\FileStorage\Storages; + +use Espo\Core\FileStorage\Attachment; +use Espo\Core\FileStorage\Local; +use Espo\Core\FileStorage\Storage; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\File\Exceptions\FileError; + +use Psr\Http\Message\StreamInterface; +use GuzzleHttp\Psr7\Stream; + +class EspoUploadDir implements Storage, Local +{ + + public const NAME = 'EspoUploadDir'; + + public function __construct(protected FileManager $fileManager) + {} + + public function unlink(Attachment $attachment): void + { + $this->fileManager->unlink( + $this->getFilePath($attachment) + ); + } + + public function exists(Attachment $attachment): bool + { + $filePath = $this->getFilePath($attachment); + + return $this->fileManager->isFile($filePath); + } + + public function getSize(Attachment $attachment): int + { + $filePath = $this->getFilePath($attachment); + + if (!$this->exists($attachment)) { + throw new FileError("Could not get size for non-existing file '$filePath'."); + } + + return $this->fileManager->getSize($filePath); + } + + public function getStream(Attachment $attachment): StreamInterface + { + $filePath = $this->getFilePath($attachment); + + if (!$this->exists($attachment)) { + throw new FileError("Could not get stream for non-existing '$filePath'."); + } + + $resource = fopen($filePath, 'r'); + + if ($resource === false) { + throw new FileError("Could not open '$filePath'."); + } + + return new Stream($resource); + } + + public function putStream(Attachment $attachment, StreamInterface $stream): void + { + $filePath = $this->getFilePath($attachment); + + $stream->rewind(); + + // @todo Use a resource to write a file (add a method to the file manager). + $contents = $stream->getContents(); + + $result = $this->fileManager->putContents($filePath, $contents); + + if (!$result) { + throw new FileError("Could not store a file '$filePath'."); + } + } + + public function getLocalFilePath(Attachment $attachment): string + { + return $this->getFilePath($attachment); + } + + /** + * @return string + */ + protected function getFilePath(Attachment $attachment) + { + $sourceId = $attachment->getSourceId(); + $file = basename($sourceId); + + return 'data/upload/' . $file; + } +} diff --git a/application/Espo/Core/Formula/Argument.php b/application/Espo/Core/Formula/Argument.php new file mode 100644 index 0000000..e14ecd0 --- /dev/null +++ b/application/Espo/Core/Formula/Argument.php @@ -0,0 +1,107 @@ +. + * + * 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\Formula; + +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Parser\Ast\Attribute; +use Espo\Core\Formula\Parser\Ast\Node; +use Espo\Core\Formula\Parser\Ast\Value; +use Espo\Core\Formula\Parser\Ast\Variable; + +/** + * A function argument. + */ +class Argument implements Evaluatable +{ + public function __construct(private mixed $data) + {} + + /** + * Get an argument type (function name). + * + * @throws Error + */ + public function getType(): string + { + if ($this->data instanceof Node) { + return $this->data->getType(); + } + + if ($this->data instanceof Value) { + return 'value'; + } + + if ($this->data instanceof Variable) { + return 'variable'; + } + + if ($this->data instanceof Attribute) { + return 'attribute'; + } + + throw new Error("Can't get type from scalar."); + } + + /** + * Get a nested argument list. + * + * @throws Error + */ + public function getArgumentList(): ArgumentList + { + if ($this->data instanceof Node) { + return new ArgumentList($this->data->getChildNodes()); + } + + if ($this->data instanceof Value) { + return new ArgumentList([$this->data->getValue()]); + } + + if ($this->data instanceof Variable) { + $value = new Value($this->data->getName()); + + return new ArgumentList([$value]); + } + + if ($this->data instanceof Attribute) { + return new ArgumentList([$this->data->getName()]); + } + + throw new Error("Can't get argument list from a non-node item."); + } + + /** + * Get data. + */ + public function getData(): mixed + { + return $this->data; + } +} diff --git a/application/Espo/Core/Formula/ArgumentList.php b/application/Espo/Core/Formula/ArgumentList.php new file mode 100644 index 0000000..fd79043 --- /dev/null +++ b/application/Espo/Core/Formula/ArgumentList.php @@ -0,0 +1,188 @@ +. + * + * 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\Formula; + +use BadMethodCallException; +use OutOfBoundsException; +use Iterator; +use Countable; +use ArrayAccess; +use SeekableIterator; + +/** + * A list of function arguments. + * + * @implements ArrayAccess + * @implements Iterator + * @implements SeekableIterator + */ +class ArgumentList implements Evaluatable, Iterator, Countable, ArrayAccess, SeekableIterator +{ + private int $position = 0; + + /** + * @param array $dataList + */ + public function __construct(private array $dataList) + {} + + private function getLastValidKey(): int + { + $keys = array_keys($this->dataList); + + $i = end($keys); + + if ($i === false) { + return -1; + } + + while ($i > 0) { + if (array_key_exists($i, $this->dataList)) { + break; + } + + $i--; + } + + return $i; + } + + public function rewind(): void + { + $this->position = 0; + + while (!$this->valid() && $this->position <= $this->getLastValidKey()) { + $this->position ++; + } + } + + private function getArgumentByIndex(int $index): Argument + { + return new Argument($this->dataList[$index]); + } + + /** + * @return mixed + */ + #[\ReturnTypeWillChange] + public function current() + { + if (!$this->valid()) { + return null; + } + + return $this->getArgumentByIndex($this->position); + } + + /** + * @return mixed + */ + #[\ReturnTypeWillChange] + public function key() + { + return $this->position; + } + + public function next(): void + { + do { + $this->position ++; + $next = false; + + if ( + !$this->valid() && + $this->position <= $this->getLastValidKey() + ) { + $next = true; + } + } while ($next); + } + + public function valid(): bool + { + return array_key_exists($this->position, $this->dataList); + } + + /** + * @param mixed $offset + */ + public function offsetExists($offset): bool + { + return array_key_exists($offset, $this->dataList); + } + + /** + * @param mixed $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + if (!$this->offsetExists($offset)) { + return null; + } + + return $this->getArgumentByIndex($offset); + } + + /** + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + throw new BadMethodCallException('Setting is not allowed.'); + } + + /** + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + throw new BadMethodCallException('Unsetting is not allowed.'); + } + + public function count(): int + { + return count($this->dataList); + } + + /** + * @param int $offset + */ + public function seek($offset): void + { + $this->position = $offset; + + if (!$this->valid()) { + throw new OutOfBoundsException("Invalid seek offset ($offset)."); + } + } +} diff --git a/application/Espo/Core/Formula/AttributeFetcher.php b/application/Espo/Core/Formula/AttributeFetcher.php new file mode 100644 index 0000000..67bd52f --- /dev/null +++ b/application/Espo/Core/Formula/AttributeFetcher.php @@ -0,0 +1,212 @@ +. + * + * 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\Formula; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\FieldProcessing\SpecificFieldLoader; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\ORM\Defs\AttributeParam; +use Espo\Core\Utils\FieldUtil; +use Espo\Entities\EmailAddress; +use Espo\Entities\PhoneNumber; +use Espo\ORM\Defs\Params\AttributeParam as OrmAttributeParam; +use Espo\ORM\Entity; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\EntityManager; +use Espo\Repositories\EmailAddress as EmailAddressRepository; +use Espo\Repositories\PhoneNumber as PhoneNumberRepository; + +/** + * Fetches attributes from an entity. + */ +class AttributeFetcher +{ + /** @var array */ + private $relatedEntitiesCacheMap = []; + + public function __construct( + private EntityManager $entityManager, + private FieldUtil $fieldUtil, + private SpecificFieldLoader $specificFieldLoader, + private SystemRestriction $systemRestriction, + ) {} + + /** + * @throws NotAllowedUsage + */ + public function fetch(Entity $entity, string $attribute, bool $getFetchedAttribute = false): mixed + { + if (str_contains($attribute, '.')) { + $arr = explode('.', $attribute); + + $link = $arr[0]; + $relatedAttribute = $arr[1] ?? null; + + if (!$relatedAttribute) { + return null; + } + + return $this->fetchRelated($entity, $link, $relatedAttribute); + } + + if (!$this->systemRestriction->checkAttributeRead($entity->getEntityType(), $attribute)) { + throw new NotAllowedUsage("Cannot read restricted attribute {$entity->getEntityType()}.$attribute."); + } + + if ($getFetchedAttribute) { + return $entity->getFetched($attribute); + } + + if ( + $entity instanceof CoreEntity && + !$entity->has($attribute) + ) { + $this->load($entity, $attribute); + } + + return $entity->get($attribute); + } + + private function load(CoreEntity $entity, string $attribute): void + { + if ($entity->getAttributeParam($attribute, 'isParentName')) { + /** @var ?string $relationName */ + $relationName = $entity->getAttributeParam($attribute, OrmAttributeParam::RELATION); + + if ($relationName) { + $entity->loadParentNameField($relationName); + } + + return; + } + + if ($entity->getAttributeParam($attribute, AttributeParam::IS_LINK_MULTIPLE_ID_LIST)) { + /** @var ?string $relationName */ + $relationName = $entity->getAttributeParam($attribute, OrmAttributeParam::RELATION); + + if ($relationName) { + $entity->loadLinkMultipleField($relationName); + } + + return; + } + + if ($entity->getAttributeParam($attribute, 'isEmailAddressData')) { + /** @var ?string $fieldName */ + $fieldName = $entity->getAttributeParam($attribute, 'field'); + + if (!$fieldName) { + return; + } + + /** @var EmailAddressRepository $emailAddressRepository */ + $emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + $data = $emailAddressRepository->getEmailAddressData($entity); + + $entity->set($attribute, $data); + $entity->setFetched($attribute, $data); + + return; + } + + if ($entity->getAttributeParam($attribute, 'isPhoneNumberData')) { + /** @var ?string $fieldName */ + $fieldName = $entity->getAttributeParam($attribute, 'field'); + + if (!$fieldName) { + return; + } + + /** @var PhoneNumberRepository $phoneNumberRepository */ + $phoneNumberRepository = $this->entityManager->getRepository(PhoneNumber::ENTITY_TYPE); + + $data = $phoneNumberRepository->getPhoneNumberData($entity); + + $entity->set($attribute, $data); + $entity->setFetched($attribute, $data); + + return; + } + + $field = $this->fieldUtil->getFieldOfAttribute($entity->getEntityType(), $attribute); + + if (!$field) { + return; + } + + $this->specificFieldLoader->process($entity, $field); + } + + public function resetRuntimeCache(): void + { + $this->relatedEntitiesCacheMap = []; + } + + private function buildKey(Entity $entity, string $link): string + { + return spl_object_hash($entity) . '-' . $link; + } + + /** + * @throws NotAllowedUsage + */ + private function fetchRelated(Entity $entity, string $link, string $attribute): mixed + { + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkLinkRead($entityType, $link) ) { + throw new NotAllowedUsage("Cannot read restricted link $entityType.$link."); + } + + $key = $this->buildKey($entity, $link); + + if ( + !array_key_exists($key, $this->relatedEntitiesCacheMap) && + $entity->hasRelation($link) && + !in_array( + $entity->getRelationType($link), + [Entity::MANY_MANY, Entity::HAS_MANY, Entity::HAS_CHILDREN] + ) + ) { + $this->relatedEntitiesCacheMap[$key] = $this->entityManager + ->getRelation($entity, $link) + ->findOne(); + } + + $relatedEntity = $this->relatedEntitiesCacheMap[$key] ?? null; + + if (!$relatedEntity instanceof Entity) { + return null; + } + + return $this->fetch($relatedEntity, $attribute); + } +} diff --git a/application/Espo/Core/Formula/Evaluatable.php b/application/Espo/Core/Formula/Evaluatable.php new file mode 100644 index 0000000..fd6d2f0 --- /dev/null +++ b/application/Espo/Core/Formula/Evaluatable.php @@ -0,0 +1,37 @@ +. + * + * 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\Formula; + +/** + * A data that can be evaluated. + */ +interface Evaluatable +{ +} diff --git a/application/Espo/Core/Formula/EvaluatedArgumentList.php b/application/Espo/Core/Formula/EvaluatedArgumentList.php new file mode 100644 index 0000000..812b359 --- /dev/null +++ b/application/Espo/Core/Formula/EvaluatedArgumentList.php @@ -0,0 +1,188 @@ +. + * + * 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\Formula; + +use BadMethodCallException; +use OutOfBoundsException; +use Iterator; +use Countable; +use ArrayAccess; +use SeekableIterator; + +/** + * A list of evaluated function arguments. + * + * @implements ArrayAccess + * @implements Iterator + * @implements SeekableIterator + */ +class EvaluatedArgumentList implements Iterator, Countable, ArrayAccess, SeekableIterator +{ + private int $position = 0; + + /** + * @param array $dataList + */ + public function __construct(private array $dataList) + {} + + private function getLastValidKey(): int + { + $keys = array_keys($this->dataList); + + $i = end($keys); + + if ($i === false) { + return -1; + } + + while ($i > 0) { + if (array_key_exists($i, $this->dataList)) { + break; + } + + $i--; + } + + return $i; + } + + public function rewind(): void + { + $this->position = 0; + + while (!$this->valid() && $this->position <= $this->getLastValidKey()) { + $this->position ++; + } + } + + private function getArgumentByIndex(int $index): mixed + { + return $this->dataList[$index]; + } + + /** + * @return mixed + */ + #[\ReturnTypeWillChange] + public function current() + { + if (!$this->valid()) { + return null; + } + + return $this->getArgumentByIndex($this->position); + } + + /** + * @return mixed + */ + #[\ReturnTypeWillChange] + public function key() + { + return $this->position; + } + + public function next(): void + { + do { + $this->position ++; + $next = false; + + if ( + !$this->valid() && + $this->position <= $this->getLastValidKey() + ) { + $next = true; + } + } while ($next); + } + + public function valid(): bool + { + return array_key_exists($this->position, $this->dataList); + } + + /** + * @param mixed $offset + */ + public function offsetExists($offset): bool + { + return array_key_exists($offset, $this->dataList); + } + + /** + * @param mixed $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + if (!$this->offsetExists($offset)) { + return null; + } + + return $this->getArgumentByIndex($offset); + } + + /** + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + throw new BadMethodCallException('Setting is not allowed.'); + } + + /** + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + throw new BadMethodCallException('Unsetting is not allowed.'); + } + + public function count(): int + { + return count($this->dataList); + } + + /** + * @param int $offset + */ + public function seek($offset): void + { + $this->position = $offset; + + if (!$this->valid()) { + throw new OutOfBoundsException("Invalid seek offset ($offset)."); + } + } +} diff --git a/application/Espo/Core/Formula/Evaluator.php b/application/Espo/Core/Formula/Evaluator.php new file mode 100644 index 0000000..863b998 --- /dev/null +++ b/application/Espo/Core/Formula/Evaluator.php @@ -0,0 +1,163 @@ +. + * + * 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\Formula; + +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\ExecutionException; +use Espo\Core\Formula\Exceptions\SyntaxError; +use Espo\Core\Formula\Exceptions\UnsafeFunction; +use Espo\Core\Formula\Functions\Base as DeprecatedBaseFunction; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\Parser\Ast\Attribute; +use Espo\Core\Formula\Parser\Ast\Node; +use Espo\Core\Formula\Parser\Ast\Value; +use Espo\Core\Formula\Parser\Ast\Variable; +use Espo\ORM\Entity; +use Espo\Core\InjectableFactory; + +use LogicException; +use stdClass; + +/** + * Creates an instance of Processor and executes a script. + * + * @internal + */ +class Evaluator +{ + private Parser $parser; + private AttributeFetcher $attributeFetcher; + /** @var array */ + private $parsedHash; + + /** + * @param array> $functionClassNameMap + * @param string[] $unsafeFunctionList + */ + public function __construct( + private InjectableFactory $injectableFactory, + private array $functionClassNameMap = [], + private array $unsafeFunctionList = [] + ) { + $this->attributeFetcher = $injectableFactory->create(AttributeFetcher::class); + $this->parser = new Parser(); + $this->parsedHash = []; + } + + /** + * Process expression. + * + * @throws SyntaxError + * @throws Error + */ + public function process(string $expression, ?Entity $entity = null, ?stdClass $variables = null): mixed + { + return $this->processInternal($expression, $entity, $variables, false); + } + + /** + * Process expression in safe mode. + * + * @throws SyntaxError + * @throws Error + */ + public function processSafe(string $expression, ?Entity $entity = null, ?stdClass $variables = null): mixed + { + return $this->processInternal($expression, $entity, $variables, true); + } + + /** + * @throws SyntaxError + * @throws Error + */ + private function processInternal( + string $expression, + ?Entity $entity, + ?stdClass $variables, + bool $safeMode, + ): mixed { + + $processor = new Processor( + $this->injectableFactory, + $this->attributeFetcher, + $this->functionClassNameMap, + $entity, + $variables + ); + + $item = $this->getParsedExpression($expression); + + if ($safeMode) { + $this->checkIsSafe($item->getData()); + } + + try { + $result = $processor->process($item); + } catch (ExecutionException $e) { + throw new LogicException('Unexpected ExecutionException.', 0, $e); + } + + $this->attributeFetcher->resetRuntimeCache(); + + return $result; + } + + /** + * @throws SyntaxError + */ + private function getParsedExpression(string $expression): Argument + { + if (!array_key_exists($expression, $this->parsedHash)) { + $this->parsedHash[$expression] = $this->parser->parse($expression); + } + + return new Argument($this->parsedHash[$expression]); + } + + /** + * @throws UnsafeFunction + */ + private function checkIsSafe(mixed $data): void + { + if (!$data instanceof Node) { + return; + } + + $name = $data->getType(); + + if (in_array($name, $this->unsafeFunctionList)) { + throw new UnsafeFunction("$name is not safe."); + } + + foreach ($data->getChildNodes() as $subData) { + $this->checkIsSafe($subData); + } + } +} diff --git a/application/Espo/Core/Formula/Exceptions/BadArgumentType.php b/application/Espo/Core/Formula/Exceptions/BadArgumentType.php new file mode 100644 index 0000000..f9fdeeb --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/BadArgumentType.php @@ -0,0 +1,62 @@ +. + * + * 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\Formula\Exceptions; + +/** + * A bad argument type. + */ +class BadArgumentType extends Error +{ + private ?int $position = null; + private ?string $type = null; + + /** + * Create. + * + * @param int $position An argument position. Starts from 1. + * @param string $type A required argument type. + */ + public static function create(int $position, string $type): self + { + $obj = new self(); + $obj->position = $position; + $obj->type = $type; + + return $obj; + } + + public function getLogMessage(): string + { + $position = (string) ($this->position ?? '?'); + $type = $this->type ?? '?'; + + return "Bad argument type at position {$position}, must be {$type}."; + } +} diff --git a/application/Espo/Core/Formula/Exceptions/BadArgumentValue.php b/application/Espo/Core/Formula/Exceptions/BadArgumentValue.php new file mode 100644 index 0000000..fb754f8 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/BadArgumentValue.php @@ -0,0 +1,66 @@ +. + * + * 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\Formula\Exceptions; + +/** + * A bad argument value. + */ +class BadArgumentValue extends Error +{ + private ?int $position = null; + private ?string $logMessage = null; + + /** + * Create. + * + * @param int $position An argument position. + */ + public static function create(int $position, ?string $message = null): self + { + $obj = new self(); + $obj->position = $position; + $obj->logMessage = $message; + + return $obj; + } + + public function getLogMessage(): string + { + $position = (string) ($this->position ?? '?'); + + $message = "Bad argument value at position $position."; + + if ($this->logMessage) { + $message .= " " . $this->logMessage; + } + + return $message; + } +} diff --git a/application/Espo/Core/Formula/Exceptions/BreakLoop.php b/application/Espo/Core/Formula/Exceptions/BreakLoop.php new file mode 100644 index 0000000..b53aa06 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/BreakLoop.php @@ -0,0 +1,33 @@ +. + * + * 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\Formula\Exceptions; + +class BreakLoop extends ExecutionException +{} diff --git a/application/Espo/Core/Formula/Exceptions/ContinueLoop.php b/application/Espo/Core/Formula/Exceptions/ContinueLoop.php new file mode 100644 index 0000000..a499849 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/ContinueLoop.php @@ -0,0 +1,33 @@ +. + * + * 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\Formula\Exceptions; + +class ContinueLoop extends ExecutionException +{} diff --git a/application/Espo/Core/Formula/Exceptions/Error.php b/application/Espo/Core/Formula/Exceptions/Error.php new file mode 100644 index 0000000..ba77b5c --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/Error.php @@ -0,0 +1,38 @@ +. + * + * 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\Formula\Exceptions; + +class Error extends \Exception +{ + /** + * @var int + */ + protected $code = 500; +} diff --git a/application/Espo/Core/Formula/Exceptions/ExecutionException.php b/application/Espo/Core/Formula/Exceptions/ExecutionException.php new file mode 100644 index 0000000..19cd3c6 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/ExecutionException.php @@ -0,0 +1,33 @@ +. + * + * 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\Formula\Exceptions; + +class ExecutionException extends \Exception +{} diff --git a/application/Espo/Core/Formula/Exceptions/FunctionRuntimeError.php b/application/Espo/Core/Formula/Exceptions/FunctionRuntimeError.php new file mode 100644 index 0000000..bd06ae8 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/FunctionRuntimeError.php @@ -0,0 +1,38 @@ +. + * + * 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\Formula\Exceptions; + +/** + * For various errors happened inside a function in runtime. + *. + * @since 10.0.0 + */ +class FunctionRuntimeError extends Error +{} diff --git a/application/Espo/Core/Formula/Exceptions/NotAllowedUsage.php b/application/Espo/Core/Formula/Exceptions/NotAllowedUsage.php new file mode 100644 index 0000000..43fa8cc --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/NotAllowedUsage.php @@ -0,0 +1,36 @@ +. + * + * 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\Formula\Exceptions; + +/** + * @since 9.3.0 + */ +class NotAllowedUsage extends Error +{} diff --git a/application/Espo/Core/Formula/Exceptions/NotPassedEntity.php b/application/Espo/Core/Formula/Exceptions/NotPassedEntity.php new file mode 100644 index 0000000..6bb9b44 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/NotPassedEntity.php @@ -0,0 +1,34 @@ +. + * + * 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\Formula\Exceptions; + +class NotPassedEntity extends Error +{ +} diff --git a/application/Espo/Core/Formula/Exceptions/SyntaxError.php b/application/Espo/Core/Formula/Exceptions/SyntaxError.php new file mode 100644 index 0000000..6658992 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/SyntaxError.php @@ -0,0 +1,58 @@ +. + * + * 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\Formula\Exceptions; + +use Throwable; + +class SyntaxError extends Error +{ + /** + * @var ?string + */ + private $shortMessage = null; + + final public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + public static function create(string $message, ?string $shortMessage = null): self + { + $obj = new static($message); + $obj->shortMessage = $shortMessage; + + return $obj; + } + + public function getShortMessage(): ?string + { + return $this->shortMessage ?? $this->getMessage(); + } +} diff --git a/application/Espo/Core/Formula/Exceptions/TooFewArguments.php b/application/Espo/Core/Formula/Exceptions/TooFewArguments.php new file mode 100644 index 0000000..68822a4 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/TooFewArguments.php @@ -0,0 +1,58 @@ +. + * + * 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\Formula\Exceptions; + +/** + * Too few function arguments passed. + */ +class TooFewArguments extends Error +{ + private ?int $number = null; + + /** + * Create. + * + * @param int $number A required number of arguments. + */ + public static function create(int $number): self + { + $obj = new self(); + $obj->number = $number; + + return $obj; + } + + public function getLogMessage(): string + { + $number = (string) ($this->number ?? '?'); + + return "Too few arguments passed, must be {$number}."; + } +} diff --git a/application/Espo/Core/Formula/Exceptions/UndefinedKey.php b/application/Espo/Core/Formula/Exceptions/UndefinedKey.php new file mode 100644 index 0000000..f975807 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/UndefinedKey.php @@ -0,0 +1,49 @@ +. + * + * 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\Formula\Exceptions; + +class UndefinedKey extends Error +{ + private int $levelsRisen = 0; + + public function getLevelsRisen(): int + { + return $this->levelsRisen; + } + + public static function cloneWithLevelRisen(self $exception): UndefinedKey + { + $obj = new UndefinedKey($exception->getMessage(), $exception->getCode(), $exception); + + $obj->levelsRisen = $exception->getLevelsRisen() + 1; + + return $obj; + } +} diff --git a/application/Espo/Core/Formula/Exceptions/UnknownFunction.php b/application/Espo/Core/Formula/Exceptions/UnknownFunction.php new file mode 100644 index 0000000..2514795 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/UnknownFunction.php @@ -0,0 +1,34 @@ +. + * + * 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\Formula\Exceptions; + +class UnknownFunction extends Error +{ +} diff --git a/application/Espo/Core/Formula/Exceptions/UnsafeFunction.php b/application/Espo/Core/Formula/Exceptions/UnsafeFunction.php new file mode 100644 index 0000000..71928c4 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/UnsafeFunction.php @@ -0,0 +1,34 @@ +. + * + * 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\Formula\Exceptions; + +class UnsafeFunction extends Error +{ +} diff --git a/application/Espo/Core/Formula/Exceptions/ValidationFunctionException.php b/application/Espo/Core/Formula/Exceptions/ValidationFunctionException.php new file mode 100644 index 0000000..b816fd5 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/ValidationFunctionException.php @@ -0,0 +1,38 @@ +. + * + * 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\Formula\Exceptions; + +/** + * To be thrown by an exception function. + * + * @internal + */ +class ValidationFunctionException extends Error +{} diff --git a/application/Espo/Core/Formula/Exceptions/WrapperException.php b/application/Espo/Core/Formula/Exceptions/WrapperException.php new file mode 100644 index 0000000..346ec8f --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/WrapperException.php @@ -0,0 +1,60 @@ +. + * + * 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\Formula\Exceptions; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; + +/** + * @internal + */ +class WrapperException extends Error +{ + private Conflict|BadRequest|Forbidden $wrappedException; + + private function __construct() + { + parent::__construct(); + } + + public static function create(Conflict|BadRequest|Forbidden $wrappedException): self + { + $created = new self(); + $created->wrappedException = $wrappedException; + + return $created; + } + + public function getWrappedException(): Conflict|BadRequest|Forbidden + { + return $this->wrappedException; + } +} diff --git a/application/Espo/Core/Formula/Func.php b/application/Espo/Core/Formula/Func.php new file mode 100644 index 0000000..6532ac9 --- /dev/null +++ b/application/Espo/Core/Formula/Func.php @@ -0,0 +1,48 @@ +. + * + * 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\Formula; + +use Espo\Core\Formula\Exceptions\Error; + +/** + * A function. + * + * An entity passed to the constructor as of v10.0.0. But it is not an officially guaranteed contract. + */ +interface Func +{ + /** + * Process. + * + * @param EvaluatedArgumentList $arguments + * @throws Error + */ + public function process(EvaluatedArgumentList $arguments): mixed; +} diff --git a/application/Espo/Core/Formula/FuncVariablesAware.php b/application/Espo/Core/Formula/FuncVariablesAware.php new file mode 100644 index 0000000..a52ec3d --- /dev/null +++ b/application/Espo/Core/Formula/FuncVariablesAware.php @@ -0,0 +1,47 @@ +. + * + * 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\Formula; + +use Espo\Core\Formula\Exceptions\Error; + +/** + * A function aware of variables. + * + * @since 8.3.0 + */ +interface FuncVariablesAware +{ + /** + * Process. + * + * @throws Error + */ + public function process(EvaluatedArgumentList $arguments, Variables $variables): mixed; +} diff --git a/application/Espo/Core/Formula/FunctionFactory.php b/application/Espo/Core/Formula/FunctionFactory.php new file mode 100644 index 0000000..8f83fea --- /dev/null +++ b/application/Espo/Core/Formula/FunctionFactory.php @@ -0,0 +1,121 @@ +. + * + * 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\Formula; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Formula\Exceptions\UnknownFunction; +use Espo\Core\Formula\Functions\Base; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\ORM\Entity; +use Espo\Core\InjectableFactory; + +use ReflectionClass; +use stdClass; + +class FunctionFactory +{ + /** @var array> */ + private $classNameMap; + + /** + * @param array> $classNameMap + */ + public function __construct( + private Processor $processor, + private InjectableFactory $injectableFactory, + private AttributeFetcher $attributeFetcher, + ?array $classNameMap = null + ) { + $this->classNameMap = $classNameMap ?? []; + } + + /** + * @throws UnknownFunction + */ + public function create( + string $name, + ?Entity $entity = null, + ?stdClass $variables = null + ): Func|FuncVariablesAware|BaseFunction|Base { + + if ($this->classNameMap && array_key_exists($name, $this->classNameMap)) { + $className = $this->classNameMap[$name]; + } else { + $arr = explode('\\', $name); + + foreach ($arr as $i => $part) { + if ($i < count($arr) - 1) { + $part = $part . 'Group'; + } + + $arr[$i] = ucfirst($part); + } + + $typeName = implode('\\', $arr); + + /** @var class-string $className */ + $className = 'Espo\\Core\\Formula\\Functions\\' . $typeName . 'Type'; + } + + if (!class_exists($className)) { + throw new UnknownFunction("Unknown function: " . $name); + } + + $class = new ReflectionClass($className); + + if ( + $class->implementsInterface(Func::class) || + $class->implementsInterface(FuncVariablesAware::class) + ) { + $binding = new BindingContainerBuilder(); + + if ($entity) { + $binding->bindInstance(Entity::class, $entity); + } + + return $this->injectableFactory->createWithBinding($className, $binding->build()); + } + + $object = $this->injectableFactory->createWith($className, [ + 'name' => $name, + 'processor' => $this->processor, + 'entity' => $entity, + 'variables' => $variables, + 'attributeFetcher' => $this->attributeFetcher, + ]); + + if (method_exists($object, 'setAttributeFetcher')) { + $object->setAttributeFetcher($this->attributeFetcher); + } + + /** @var BaseFunction|Base */ + return $object; + } +} diff --git a/application/Espo/Core/Formula/Functions/ArrayAppendType.php b/application/Espo/Core/Formula/Functions/ArrayAppendType.php new file mode 100644 index 0000000..f94f919 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ArrayAppendType.php @@ -0,0 +1,66 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\Error; + +/** + * @noinspection PhpUnused + */ +class ArrayAppendType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $name = $this->evaluate($args[0]); + + if (!is_string($name)) { + $this->throwBadArgumentValue(1, 'string'); + } + + $value = $this->evaluate($args[1]); + + if (!property_exists($this->getVariables(), $name)) { + throw new Error("Cannot array-append to not existing variable."); + } + + $array =& $this->getVariables()->$name; + + if (!is_array($array)) { + throw new Error("Cannot array-append to non-array variable."); + } + + $array[] = $value; + } +} diff --git a/application/Espo/Core/Formula/Functions/ArrayGroup/AtType.php b/application/Espo/Core/Formula/Functions/ArrayGroup/AtType.php new file mode 100644 index 0000000..951c3e1 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ArrayGroup/AtType.php @@ -0,0 +1,63 @@ +. + * + * 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\Formula\Functions\ArrayGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\FunctionRuntimeError; +use Espo\Core\Formula\Functions\BaseFunction; + +class AtType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $array = $args[0] ?? []; + $index = $args[1]; + + if (!is_array($array)) { + $this->throwBadArgumentType(1, 'array'); + } + + if (!is_int($index)) { + $this->throwBadArgumentType(2, 'int'); + } + + if (!array_key_exists($index, $array)) { + throw new FunctionRuntimeError("Cannot access array value by non-existing index $index."); + } + + return $array[$index]; + } +} diff --git a/application/Espo/Core/Formula/Functions/ArrayGroup/IncludesType.php b/application/Espo/Core/Formula/Functions/ArrayGroup/IncludesType.php new file mode 100644 index 0000000..ae128d3 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ArrayGroup/IncludesType.php @@ -0,0 +1,56 @@ +. + * + * 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\Formula\Functions\ArrayGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class IncludesType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $list = $args[0] ?? []; + $needle = $args[1]; + + if (!is_array($list)) { + return false; + } + + return in_array($needle, $list); + } +} diff --git a/application/Espo/Core/Formula/Functions/ArrayGroup/IndexOfType.php b/application/Espo/Core/Formula/Functions/ArrayGroup/IndexOfType.php new file mode 100644 index 0000000..1a64c38 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ArrayGroup/IndexOfType.php @@ -0,0 +1,62 @@ +. + * + * 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\Formula\Functions\ArrayGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class IndexOfType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $evaluatedArgs = $this->evaluate($args); + + if (count($evaluatedArgs) < 2) { + $this->throwTooFewArguments(); + } + + $array = $evaluatedArgs[0] ?? []; + $needle = $evaluatedArgs[1]; + + if (!is_array($array)) { + $this->throwBadArgumentType(1, 'array'); + } + + $result = array_search($needle, $array, true); + + if ($result === false || is_string($result)) { + return null; + } + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/ArrayGroup/JoinType.php b/application/Espo/Core/Formula/Functions/ArrayGroup/JoinType.php new file mode 100644 index 0000000..df4c463 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ArrayGroup/JoinType.php @@ -0,0 +1,55 @@ +. + * + * 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\Formula\Functions\ArrayGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class JoinType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $list = $this->evaluate($args[0]) ?? []; + + $separator = $this->evaluate($args[1]); + + if (!is_string($separator)) { + $this->throwBadArgumentValue(2, 'string'); + } + + return implode($separator, $list); + } +} diff --git a/application/Espo/Core/Formula/Functions/ArrayGroup/LengthType.php b/application/Espo/Core/Formula/Functions/ArrayGroup/LengthType.php new file mode 100644 index 0000000..483c751 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ArrayGroup/LengthType.php @@ -0,0 +1,55 @@ +. + * + * 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\Formula\Functions\ArrayGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class LengthType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $list = $args[0]; + + if (!is_array($list)) { + return 0; + } + + return count($list); + } +} diff --git a/application/Espo/Core/Formula/Functions/ArrayGroup/PushType.php b/application/Espo/Core/Formula/Functions/ArrayGroup/PushType.php new file mode 100644 index 0000000..127e25d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ArrayGroup/PushType.php @@ -0,0 +1,63 @@ +. + * + * 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\Formula\Functions\ArrayGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class PushType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $list = $this->evaluate($args[0]) ?? []; + + if (!is_array($list)) { + $this->throwError("Argument is non-array."); + } + + foreach ($args as $i => $v) { + if ($i === 0) { + continue; + } + + $element = $this->evaluate($args[$i]); + + $list[] = $element; + } + + return $list; + } +} diff --git a/application/Espo/Core/Formula/Functions/ArrayGroup/RemoveAtType.php b/application/Espo/Core/Formula/Functions/ArrayGroup/RemoveAtType.php new file mode 100644 index 0000000..24d09b0 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ArrayGroup/RemoveAtType.php @@ -0,0 +1,70 @@ +. + * + * 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\Formula\Functions\ArrayGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class RemoveAtType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $evaluatedArgs = $this->evaluate($args); + + if (count($evaluatedArgs) < 2) { + $this->throwTooFewArguments(); + } + + $array = $evaluatedArgs[0] ?? []; + $index = $evaluatedArgs[1]; + + if ($index === null) { + return $array; + } + + if (!is_array($array)) { + $this->throwBadArgumentType(1, 'array'); + } + + if (!is_int($index)) { + $this->throwBadArgumentType(2, 'int'); + } + + if (!array_key_exists($index, $array)) { + return $array; + } + + unset($array[$index]); + + return array_values($array); + } +} diff --git a/application/Espo/Core/Formula/Functions/ArrayGroup/UniqueType.php b/application/Espo/Core/Formula/Functions/ArrayGroup/UniqueType.php new file mode 100644 index 0000000..843f076 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ArrayGroup/UniqueType.php @@ -0,0 +1,55 @@ +. + * + * 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\Formula\Functions\ArrayGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class UniqueType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $evaluatedArgs = $this->evaluate($args); + + if (count($evaluatedArgs) < 1) { + $this->throwTooFewArguments(); + } + + $array = $evaluatedArgs[0] ?? []; + + if (!is_array($array)) { + $this->throwBadArgumentType(1, 'array'); + } + + return array_values(array_unique($array)); + } +} diff --git a/application/Espo/Core/Formula/Functions/AssignType.php b/application/Espo/Core/Formula/Functions/AssignType.php new file mode 100644 index 0000000..cb76cc3 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/AssignType.php @@ -0,0 +1,54 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; + +class AssignType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $name = $this->evaluate($args[0]); + + if (!is_string($name)) { + $this->throwBadArgumentValue(1, 'string'); + } + + $value = $this->evaluate($args[1]); + + $this->getVariables()->$name = $value; + + return $value; + } +} diff --git a/application/Espo/Core/Formula/Functions/AttributeType.php b/application/Espo/Core/Formula/Functions/AttributeType.php new file mode 100644 index 0000000..755e370 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/AttributeType.php @@ -0,0 +1,76 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\AttributeFetcher; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use stdClass; + +class AttributeType extends Base +{ + /** + * @var AttributeFetcher + */ + protected $attributeFetcher; + + /** + * @return void + */ + public function setAttributeFetcher(AttributeFetcher $attributeFetcher) + { + $this->attributeFetcher = $attributeFetcher; + } + + /** + * @return mixed + * @throws NotAllowedUsage + * @throws Error + */ + public function process(stdClass $item) + { + if (!property_exists($item, 'value')) { + throw new Error(); + } + + return $this->getAttributeValue($item->value); + } + + /** + * @param string $attribute + * @return mixed + * @throws NotAllowedUsage + * @throws Error + */ + protected function getAttributeValue($attribute) + { + return $this->attributeFetcher->fetch($this->getEntity(), $attribute); + } +} diff --git a/application/Espo/Core/Formula/Functions/Base.php b/application/Espo/Core/Formula/Functions/Base.php new file mode 100644 index 0000000..154d60d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/Base.php @@ -0,0 +1,136 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\Exceptions\Error; +use Espo\ORM\Entity; +use Espo\Core\Formula\Processor; +use Espo\Core\Formula\Argument; + +use stdClass; + +/** + * @deprecated Use Func interface instead. + * @todo Remove in v11.0. + */ +abstract class Base +{ + /** + * @var ?string + */ + protected $name; + + /** + * @var Processor + */ + protected $processor; + + /** + * @var ?Entity + */ + private $entity; + + /** + * @var ?\stdClass + */ + private $variables; + + public function __construct( + string $name, + Processor $processor, + ?Entity $entity = null, + ?stdClass $variables = null, + ) { + $this->name = $name; + $this->processor = $processor; + $this->entity = $entity; + $this->variables = $variables; + } + + protected function getVariables(): stdClass + { + return $this->variables ?? (object) []; + } + + /** + * @throws Error + */ + protected function getEntity() /** @phpstan-ignore-line */ + { + if (!$this->entity) { + throw new Error('Formula: Entity required but not passed.'); + } + + return $this->entity; + } + + /** + * @return mixed + * @throws Error + */ + public abstract function process(stdClass $item); + + /** + * @param mixed $item + * @return mixed + * @throws Error + */ + protected function evaluate($item) + { + $item = new Argument($item); + + return $this->processor->process($item); + } + + /** + * @return mixed[] + * @throws Error + */ + protected function fetchArguments(stdClass $item): array + { + $args = $item->value ?? []; + + $eArgs = []; + + foreach ($args as $item) { + $eArgs[] = $this->evaluate($item); + } + + return $eArgs; + } + + /** + * @return mixed[] + */ + protected function fetchRawArguments(stdClass $item): array + { + return $item->value ?? []; + } +} diff --git a/application/Espo/Core/Formula/Functions/BaseFunction.php b/application/Espo/Core/Formula/Functions/BaseFunction.php new file mode 100644 index 0000000..a0e8824 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/BaseFunction.php @@ -0,0 +1,199 @@ +. + * + * 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\Formula\Functions; + +use Espo\ORM\Entity; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Evaluatable; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\BadArgumentValue; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\ExecutionException; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Processor; +use Espo\Core\Utils\Log; + +use stdClass; + +/** + * A base abstract function. Avoid extending. Use Func interface instead. + */ +abstract class BaseFunction +{ + protected function getVariables(): stdClass + { + return $this->variables ?? (object) []; + } + + /** + * Get a target entity. + * + * @throws NotPassedEntity + */ + protected function getEntity(): Entity + { + if (!$this->entity) { + throw new NotPassedEntity('function: ' . $this->name); + } + + return $this->entity; + } + + public function __construct( + protected string $name, + protected Processor $processor, + private ?Entity $entity = null, + private ?stdClass $variables = null, + protected ?Log $log = null + ) {} + + /** + * Evaluates a function. + * + * @return mixed A result of the function. + * @throws Error + * @throws ExecutionException + */ + public abstract function process(ArgumentList $args); + + /** + * Evaluates an argument or argument list. + * + * @param Evaluatable $item Argument or ArgumentList. + * @return mixed A result of evaluation. An array if an argument list was passed. + * @throws Error + * @throws ExecutionException + */ + protected function evaluate(Evaluatable $item) + { + return $this->processor->process($item); + } + + /** + * Throws TooFewArguments exception. + * + * @return never + * @throws TooFewArguments + */ + protected function throwTooFewArguments(?int $number = null) + { + $msg = 'function: ' . $this->name; + + if ($number !== null) { + $msg .= ', needs: ' . $number; + } + + throw new TooFewArguments($msg); + } + + /** + * Throw BadArgumentType exception. + * + * @return never + * @throws BadArgumentType + */ + protected function throwBadArgumentType(?int $index = null, ?string $type = null) + { + $msg = 'function: ' . $this->name; + + if ($index !== null) { + $msg .= ', index: ' . $index; + + if ($type) { + $msg .= ', should be: ' . $type; + } + } + + throw new BadArgumentType($msg); + } + + /** + * Throw BadArgumentValue exception. + * + * @return never + * @throws BadArgumentValue + */ + protected function throwBadArgumentValue(?int $index = null, ?string $msg = null) + { + $string = 'function: ' . $this->name; + + if ($index !== null) { + $string .= ', index: ' . $index; + + if ($msg) { + $string .= ', ' . $msg; + } + } + + throw new BadArgumentValue($string); + } + + /** + * Throw Error exception. + * + * @return never + * @throws Error + */ + protected function throwError(?string $msg = null) + { + $string = 'function: ' . $this->name; + + if ($msg) { + $string .= ', ' . $msg; + } + + throw new Error($string); + } + + /** + * Log a bad argument type. + */ + protected function logBadArgumentType(int $index, string $type): void + { + if (!$this->log) { + return; + } + + $this->log->warning("Formula function: $this->name, argument $index should be '$type'."); + } + + /** + * Log a message. + */ + protected function log(string $msg, string $level = 'notice'): void + { + if (!$this->log) { + return; + } + + $this->log->log($level, 'Formula function: ' . $this->name . ', ' . $msg); + } +} diff --git a/application/Espo/Core/Formula/Functions/BreakType.php b/application/Espo/Core/Formula/Functions/BreakType.php new file mode 100644 index 0000000..1b70207 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/BreakType.php @@ -0,0 +1,41 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\BreakLoop; + +class BreakType extends BaseFunction +{ + public function process(ArgumentList $args) + { + throw new BreakLoop(); + } +} diff --git a/application/Espo/Core/Formula/Functions/BundleType.php b/application/Espo/Core/Formula/Functions/BundleType.php new file mode 100644 index 0000000..6286793 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/BundleType.php @@ -0,0 +1,49 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; + +/** + * @noinspection PhpUnused + */ +class BundleType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $value = null; + + foreach ($args as $item) { + $value = $this->evaluate($item); + } + + return $value; + } +} diff --git a/application/Espo/Core/Formula/Functions/ComparisonGroup/Base.php b/application/Espo/Core/Formula/Functions/ComparisonGroup/Base.php new file mode 100644 index 0000000..9337a2e --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ComparisonGroup/Base.php @@ -0,0 +1,62 @@ +. + * + * 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\Formula\Functions\ComparisonGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +abstract class Base extends BaseFunction +{ + /** + * @return bool + * @throws \Espo\Core\Formula\Exceptions\TooFewArguments + * @throws \Espo\Core\Formula\Exceptions\Error + */ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $left = $this->evaluate($args[0]); + $right = $this->evaluate($args[1]); + + return $this->compare($left, $right); + } + + /** + * @param mixed $left + * @param mixed $right + * @return bool + */ + abstract protected function compare($left, $right); +} diff --git a/application/Espo/Core/Formula/Functions/ComparisonGroup/EqualsType.php b/application/Espo/Core/Formula/Functions/ComparisonGroup/EqualsType.php new file mode 100644 index 0000000..e0bb456 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ComparisonGroup/EqualsType.php @@ -0,0 +1,63 @@ +. + * + * 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\Formula\Functions\ComparisonGroup; + +class EqualsType extends Base +{ + /** + * @param mixed $left + * @param mixed $right + * @return bool + */ + protected function compare($left, $right) + { + if (is_array($left) && is_array($right)) { + $result = true; + + foreach ($left as $i => $value) { + if (!array_key_exists($i, $right)) { + $result = false; + + break; + } + + if ($value !== $right[$i]) { + $result = false; + + break; + } + } + + return $result; + } + + return $left === $right; + } +} diff --git a/application/Espo/Core/Formula/Functions/ComparisonGroup/GreaterThanOrEqualsType.php b/application/Espo/Core/Formula/Functions/ComparisonGroup/GreaterThanOrEqualsType.php new file mode 100644 index 0000000..e81e1ed --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ComparisonGroup/GreaterThanOrEqualsType.php @@ -0,0 +1,43 @@ +. + * + * 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\Formula\Functions\ComparisonGroup; + +class GreaterThanOrEqualsType extends Base +{ + /** + * @param mixed $left + * @param mixed $right + * @return bool + */ + protected function compare($left, $right) + { + return $left >= $right; + } +} diff --git a/application/Espo/Core/Formula/Functions/ComparisonGroup/GreaterThanType.php b/application/Espo/Core/Formula/Functions/ComparisonGroup/GreaterThanType.php new file mode 100644 index 0000000..e37e649 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ComparisonGroup/GreaterThanType.php @@ -0,0 +1,43 @@ +. + * + * 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\Formula\Functions\ComparisonGroup; + +class GreaterThanType extends Base +{ + /** + * @param mixed $left + * @param mixed $right + * @return bool + */ + protected function compare($left, $right) + { + return $left > $right; + } +} diff --git a/application/Espo/Core/Formula/Functions/ComparisonGroup/LessThanOrEqualsType.php b/application/Espo/Core/Formula/Functions/ComparisonGroup/LessThanOrEqualsType.php new file mode 100644 index 0000000..d8a0a01 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ComparisonGroup/LessThanOrEqualsType.php @@ -0,0 +1,43 @@ +. + * + * 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\Formula\Functions\ComparisonGroup; + +class LessThanOrEqualsType extends Base +{ + /** + * @param mixed $left + * @param mixed $right + * @return bool + */ + protected function compare($left, $right) + { + return $left <= $right; + } +} diff --git a/application/Espo/Core/Formula/Functions/ComparisonGroup/LessThanType.php b/application/Espo/Core/Formula/Functions/ComparisonGroup/LessThanType.php new file mode 100644 index 0000000..37c319a --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ComparisonGroup/LessThanType.php @@ -0,0 +1,43 @@ +. + * + * 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\Formula\Functions\ComparisonGroup; + +class LessThanType extends Base +{ + /** + * @param mixed $left + * @param mixed $right + * @return bool + */ + protected function compare($left, $right) + { + return $left < $right; + } +} diff --git a/application/Espo/Core/Formula/Functions/ComparisonGroup/NotEqualsType.php b/application/Espo/Core/Formula/Functions/ComparisonGroup/NotEqualsType.php new file mode 100644 index 0000000..cd811ab --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ComparisonGroup/NotEqualsType.php @@ -0,0 +1,43 @@ +. + * + * 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\Formula\Functions\ComparisonGroup; + +class NotEqualsType extends EqualsType +{ + /** + * @param mixed $left + * @param mixed $right + * @return bool + */ + protected function compare($left, $right) + { + return !parent::compare($left, $right); + } +} diff --git a/application/Espo/Core/Formula/Functions/ComparisonGroup/NullCoalescingType.php b/application/Espo/Core/Formula/Functions/ComparisonGroup/NullCoalescingType.php new file mode 100644 index 0000000..f95eb8e --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ComparisonGroup/NullCoalescingType.php @@ -0,0 +1,69 @@ +. + * + * 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\Formula\Functions\ComparisonGroup; + +use Espo\Core\Formula\Exceptions\UndefinedKey; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\ArgumentList; + +class NullCoalescingType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $array = []; + + foreach ($args as $arg) { + $array[] = $arg; + } + + foreach (array_slice($array, 0, -1) as $arg) { + try { + $value = $this->evaluate($arg); + } catch (UndefinedKey $e) { + if ($e->getLevelsRisen() > 1) { + throw $e; + } + + $value = null; + } + + + if ($value !== null) { + return $value; + } + } + + return $this->evaluate($array[count($array) - 1]); + } +} diff --git a/application/Espo/Core/Formula/Functions/ConditionType.php b/application/Espo/Core/Formula/Functions/ConditionType.php new file mode 100644 index 0000000..ef094c6 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ConditionType.php @@ -0,0 +1,43 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class ConditionType extends BaseFunction +{ + public function process(ArgumentList $args) + { + return $this->evaluate($args[0]); + } +} diff --git a/application/Espo/Core/Formula/Functions/ContinueType.php b/application/Espo/Core/Formula/Functions/ContinueType.php new file mode 100644 index 0000000..38b8124 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ContinueType.php @@ -0,0 +1,41 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\ContinueLoop; + +class ContinueType extends BaseFunction +{ + public function process(ArgumentList $args) + { + throw new ContinueLoop(); + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/AddDaysType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddDaysType.php new file mode 100644 index 0000000..9cb1dc6 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddDaysType.php @@ -0,0 +1,35 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +class AddDaysType extends AddIntervalType +{ + protected string $intervalTypeString = 'days'; +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/AddHoursType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddHoursType.php new file mode 100644 index 0000000..a513468 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddHoursType.php @@ -0,0 +1,36 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +class AddHoursType extends AddIntervalType +{ + protected string $intervalTypeString = 'hours'; + protected bool $timeOnly = true; +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/AddIntervalType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddIntervalType.php new file mode 100644 index 0000000..a271fa4 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddIntervalType.php @@ -0,0 +1,102 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Di; +use Espo\Core\Formula\Exceptions\FunctionRuntimeError; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; +use DateTime; +use DateMalformedStringException; +use Exception; + +abstract class AddIntervalType extends BaseFunction implements Di\DateTimeAware +{ + use Di\DateTimeSetter; + + protected bool $timeOnly = false; + protected string $intervalTypeString; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $dateTimeString = $args[0]; + + if (!$dateTimeString) { + return null; + } + + if (!is_string($dateTimeString)) { + $this->throwBadArgumentType(1, 'string'); + } + + $interval = $args[1]; + + if (!is_numeric($interval)) { + $this->throwBadArgumentType(2, 'numeric'); + } + + $isTime = false; + if (strlen($dateTimeString) > 10) { + $isTime = true; + } + + if ($this->timeOnly && !$isTime) { + $dateTimeString .= ' 00:00:00'; + $isTime = true; + } + + try { + $dateTime = new DateTime($dateTimeString); + } catch (Exception) { + throw new FunctionRuntimeError("Bad date-time value '$dateTimeString'."); + } + + $modifier = ($interval > 0 ? '+' : '') . $interval . ' ' . $this->intervalTypeString; + + try { + $dateTime->modify($modifier); + } catch (DateMalformedStringException $e) { + throw new FunctionRuntimeError($e->getMessage(), previous: $e); + } + + if ($isTime) { + return $dateTime->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } else { + return $dateTime->format(DateTimeUtil::SYSTEM_DATE_FORMAT); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/AddMinutesType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddMinutesType.php new file mode 100644 index 0000000..863a4db --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddMinutesType.php @@ -0,0 +1,37 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +class AddMinutesType extends AddIntervalType +{ + + protected string $intervalTypeString = 'minutes'; + protected bool $timeOnly = true; +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/AddMonthsType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddMonthsType.php new file mode 100644 index 0000000..c5d91de --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddMonthsType.php @@ -0,0 +1,35 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +class AddMonthsType extends AddIntervalType +{ + protected string $intervalTypeString = 'months'; +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/AddSecondsType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddSecondsType.php new file mode 100644 index 0000000..3c68f67 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddSecondsType.php @@ -0,0 +1,36 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +class AddSecondsType extends AddIntervalType +{ + protected string $intervalTypeString = 'seconds'; + protected bool $timeOnly = true; +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/AddWeeksType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddWeeksType.php new file mode 100644 index 0000000..711f44b --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddWeeksType.php @@ -0,0 +1,35 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +class AddWeeksType extends AddIntervalType +{ + protected string $intervalTypeString = 'weeks'; +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/AddYearsType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddYearsType.php new file mode 100644 index 0000000..0089063 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/AddYearsType.php @@ -0,0 +1,35 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +class AddYearsType extends AddIntervalType +{ + protected string $intervalTypeString = 'years'; +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/ClosestType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/ClosestType.php new file mode 100644 index 0000000..340b78c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/ClosestType.php @@ -0,0 +1,249 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Di; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +use DateTime; +use DateTimeZone; + +class ClosestType extends BaseFunction implements Di\ConfigAware +{ + use Di\ConfigSetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 3) { + $this->throwTooFewArguments(); + } + + $value = $args[0]; + $type = $args[1]; + $target = $args[2]; + + if (!in_array($type, ['time', 'minute', 'hour', 'date', 'dayOfWeek', 'month'])) { + $this->throwBadArgumentType(1); + } + + $inPast = false; + + if (count($args) > 3) { + $inPast = $args[3]; + } + + $timezone = null; + + if (count($args) > 4) { + $timezone = $args[4]; + } + + if (!$value) { + return null; + } + + if (!is_string($value)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!$timezone) { + $timezone = $this->config->get('timeZone'); + } + + $isDate = false; + + if (strlen($value) === 10) { + $isDate = true; + $value .= ' 00:00:00'; + } + + if (strlen($value) === 16) { + $value .= ':00'; + } + + $format = 'Y-m-d H:i:s'; + + /** @var DateTime $dt */ + $dt = DateTime::createFromFormat($format, $value, new DateTimeZone($timezone)); + + $valueTimestamp = $dt->getTimestamp(); + + if ($type === 'time') { + if (!is_string($target)) { + $this->throwBadArgumentType(3, 'string'); + } + + list($hour, $minute) = explode(':', $target); + + if (!$hour) { + $hour = 0; + } + + if (!$minute) { + $minute = 0; + } + + $dt->setTime((int) $hour, (int) $minute, 0); + + if ($valueTimestamp < $dt->getTimestamp()) { + if ($inPast) { + $dt->modify('-1 day'); + } + } else if ($valueTimestamp > $dt->getTimestamp()) { + if (!$inPast) { + $dt->modify('+1 day'); + } + } + } else if ($type === 'hour') { + $target = intval($target); + $dt->setTime($target, 0, 0); + + if ($valueTimestamp < $dt->getTimestamp()) { + if ($inPast) { + $dt->modify('-1 day'); + } + } else if ($valueTimestamp > $dt->getTimestamp()) { + if (!$inPast) { + $dt->modify('+1 day'); + } + } + } else if ($type === 'minute') { + $target = intval($target); + + $dt->setTime(intval($dt->format('G')), intval($target), 0); + + if ($valueTimestamp < $dt->getTimestamp()) { + if ($inPast) { + $dt->modify('-1 hour'); + } + } else if ($valueTimestamp > $dt->getTimestamp()) { + if (!$inPast) { + $dt->modify('+1 hour'); + } + } + } else if ($type === 'dayOfWeek') { + $target = intval($target); + $dt->setTime(0, 0, 0); + + $dayOfWeek = $dt->format('w'); + $dt->modify('-' . $dayOfWeek . ' days'); + $dt->modify('+' . $target . ' days'); + + if ($valueTimestamp < $dt->getTimestamp()) { + if ($inPast) { + $dt->modify('-1 week'); + } + } else if ($valueTimestamp > $dt->getTimestamp()) { + if (!$inPast) { + $dt->modify('+1 week'); + } + } + } else if ($type === 'date') { + $target = intval($target); + $dt->setTime(0, 0, 0); + + if ($inPast) { + while (true) { + $date = intval($dt->format('d')); + + if ($date === $target) { + break; + } + + $dt->modify('-1 day'); + } + } else { + if ($valueTimestamp > $dt->getTimestamp()) { + $dt->modify('+1 day'); + } + + while (true) { + $date = intval($dt->format('d')); + + if ($date === $target) { + break; + } + + $dt->modify('+1 day'); + } + } + } else if ($type === 'month') { + $target = intval($target); + + $dt->setTime(0, 0, 0); + $days = intval($dt->format('d')) - 1; + $dt->modify('-' . $days . ' days'); + + if ($inPast) { + while (true) { + $month = intval($dt->format('m')); + + if ($month === $target) { + break; + } + + $dt->modify('-1 month'); + } + } else { + if ($valueTimestamp > $dt->getTimestamp()) { + $dt->modify('+1 month'); + } + + while (true) { + $month = intval($dt->format('m')); + + if ($month === $target) { + break; + } + + $dt->modify('+1 month'); + } + } + } + + if ($isDate && in_array($type, ['time', 'minute', 'hour'])) { + $isDate = false; + } + + if (!$isDate) { + $dt->setTimezone(new DateTimeZone('UTC')); + + return $dt->format('Y-m-d H:i'); + } + + return $dt->format('Y-m-d'); + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/DateType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/DateType.php new file mode 100644 index 0000000..7200613 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/DateType.php @@ -0,0 +1,70 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Di; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class DateType extends BaseFunction implements Di\DateTimeAware +{ + use Di\DateTimeSetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $args[0]; + + $timezone = null; + if (count($args) > 1) { + $timezone = $args[1]; + } + + if (empty($value)) { + return 0; + } + + if (strlen($value) > 11) { + $resultString = $this->dateTime->convertSystemDateTime($value, $timezone, 'D'); + } else { + $resultString = $this->dateTime->convertSystemDate($value, 'D'); + } + + return intval($resultString); + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/DayOfWeekType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/DayOfWeekType.php new file mode 100644 index 0000000..e446fd8 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/DayOfWeekType.php @@ -0,0 +1,72 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Di; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class DayOfWeekType extends BaseFunction implements Di\DateTimeAware +{ + use Di\DateTimeSetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $args[0]; + + $timezone = null; + if (count($args) > 1) { + $timezone = $args[1]; + } + + if (empty($value)) { + return -1; + } + + if (strlen($value) > 11) { + $resultString = $this->dateTime->convertSystemDateTime($value, $timezone, 'd'); + } else { + $resultString = $this->dateTime->convertSystemDate($value, 'd'); + } + + $result = intval($resultString); + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/DiffType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/DiffType.php new file mode 100644 index 0000000..005ad2c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/DiffType.php @@ -0,0 +1,143 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\ExecutionException; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Functions\BaseFunction; + +use DateTime; + +class DiffType extends BaseFunction +{ + /** + * @var array + */ + protected $intervalTypePropertyMap = [ + 'years' => 'y', + 'months' => 'm', + 'days' => 'd', + 'hours' => 'h', + 'minutes' => 'i', + 'seconds' => 's', + ]; + + /** + * @return ?int + * @throws TooFewArguments + * @throws Error + * @throws ExecutionException + */ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $dateTime1String = $args[0]; + $dateTime2String = $args[1]; + + if (!$dateTime1String) { + return null; + } + + if (!$dateTime2String) { + return null; + } + + if (!is_string($dateTime1String)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_string($dateTime2String)) { + $this->throwBadArgumentType(2, 'string'); + } + + $intervalType = 'days'; + + if (count($args) > 2) { + $intervalType = $args[2]; + } + + if (!is_string($intervalType)) { + $this->throwBadArgumentType(3, 'string'); + } + + if (!array_key_exists($intervalType, $this->intervalTypePropertyMap)) { + $this->throwBadArgumentValue(3, "not supported interval type '{$intervalType}'"); + } + + $isTime = false; + + if (strlen($dateTime1String) > 10) { + $isTime = true; + } + + try { + $dateTime1 = new DateTime($dateTime1String); + $dateTime2 = new DateTime($dateTime2String); + } catch (\Exception $e) { + return null; + } + + $t1 = $dateTime1->getTimestamp(); + $t2 = $dateTime2->getTimestamp(); + + $secondsDiff = $t1 - $t2; + + if ($intervalType === 'seconds') { + $number = $secondsDiff; + } else if ($intervalType === 'minutes') { + $number = (int) floor($secondsDiff / 60); + } else if ($intervalType === 'hours') { + $number = (int) floor($secondsDiff / (60 * 60)); + } else if ($intervalType === 'days') { + $number = (int) floor($secondsDiff / (60 * 60 * 24)); + } else { + $property = $this->intervalTypePropertyMap[$intervalType]; + $interval = $dateTime2->diff($dateTime1); + $number = $interval->$property; + + if ($intervalType === 'months') { + $number += $interval->y * 12; + } + + if ($interval->invert) { + $number *= -1; + } + } + + return $number; + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/FormatType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/FormatType.php new file mode 100644 index 0000000..4a275df --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/FormatType.php @@ -0,0 +1,73 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Di; + +use Espo\Core\Formula\{ + Exceptions\Error, + Functions\BaseFunction, + ArgumentList}; +use RuntimeException; + +class FormatType extends BaseFunction implements Di\DateTimeAware +{ + use Di\DateTimeSetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $timezone = null; + if (count($args) > 1) { + $timezone = $args[1]; + } + $value = $args[0]; + + $format = null; + if (count($args) > 2) { + $format = $args[2]; + } + + try { + if (strlen($value) > 11) { + return $this->dateTime->convertSystemDateTime($value, $timezone, $format); + } else { + return $this->dateTime->convertSystemDate($value, $format); + } + } catch (RuntimeException $e) { + throw new Error($e->getMessage(), 500, $e); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/HourType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/HourType.php new file mode 100644 index 0000000..07bcb30 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/HourType.php @@ -0,0 +1,70 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Di; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class HourType extends BaseFunction implements Di\DateTimeAware +{ + use Di\DateTimeSetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $args[0]; + + $timezone = null; + if (count($args) > 1) { + $timezone = $args[1]; + } + + if (empty($value)) { + return -1; + } + + if (strlen($value) > 11) { + $resultString = $this->dateTime->convertSystemDateTime($value, $timezone, 'H'); + } else { + return 0; + } + + return intval($resultString); + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/MinuteType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/MinuteType.php new file mode 100644 index 0000000..c97dcfd --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/MinuteType.php @@ -0,0 +1,70 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Di; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class MinuteType extends BaseFunction implements Di\DateTimeAware +{ + use Di\DateTimeSetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $args[0]; + + $timezone = null; + if (count($args) > 1) { + $timezone = $args[1]; + } + + if (empty($value)) { + return -1; + } + + if (strlen($value) > 11) { + $resultString = $this->dateTime->convertSystemDateTime($value, $timezone, 'm'); + } else { + return 0; + } + + return intval($resultString); + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/MonthType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/MonthType.php new file mode 100644 index 0000000..3266536 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/MonthType.php @@ -0,0 +1,70 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Di; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class MonthType extends BaseFunction implements Di\DateTimeAware +{ + use Di\DateTimeSetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $args[0]; + + $timezone = null; + if (count($args) > 1) { + $timezone = $args[1]; + } + + if (empty($value)) { + return 0; + } + + if (strlen($value) > 11) { + $resultString = $this->dateTime->convertSystemDateTime($value, $timezone, 'M'); + } else { + $resultString = $this->dateTime->convertSystemDate($value, 'M'); + } + + return intval($resultString); + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/NowType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/NowType.php new file mode 100644 index 0000000..a788a9f --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/NowType.php @@ -0,0 +1,45 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Utils\DateTime; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class NowType extends BaseFunction +{ + public function process(ArgumentList $args) + { + return DateTime::getSystemNowString(); + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/TodayType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/TodayType.php new file mode 100644 index 0000000..5fa4a0f --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/TodayType.php @@ -0,0 +1,62 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use DateTimeZone; +use Espo\Core\Field\DateTime; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Func; +use Espo\Core\Utils\Config\ApplicationConfig; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Exception; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class TodayType implements Func +{ + public function __construct( + private ApplicationConfig $applicationConfig + ) {} + + public function process(EvaluatedArgumentList $arguments): string + { + $timezone = $this->applicationConfig->getTimeZone(); + + try { + $today = DateTime::createNow()->withTimezone(new DateTimeZone($timezone)); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); + } + + return $today->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT); + } +} diff --git a/application/Espo/Core/Formula/Functions/DatetimeGroup/YearType.php b/application/Espo/Core/Formula/Functions/DatetimeGroup/YearType.php new file mode 100644 index 0000000..e68eec0 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/DatetimeGroup/YearType.php @@ -0,0 +1,70 @@ +. + * + * 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\Formula\Functions\DatetimeGroup; + +use Espo\Core\Di; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class YearType extends BaseFunction implements Di\DateTimeAware +{ + use Di\DateTimeSetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $args[0]; + + $timezone = null; + if (count($args) > 1) { + $timezone = $args[1]; + } + + if (empty($value)) { + return 0; + } + + if (strlen($value) > 11) { + $resultString = $this->dateTime->convertSystemDateTime($value, $timezone, 'YYYY'); + } else { + $resultString = $this->dateTime->convertSystemDate($value, 'YYYY'); + } + + return intval($resultString); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/AddLinkMultipleIdType.php b/application/Espo/Core/Formula/Functions/EntityGroup/AddLinkMultipleIdType.php new file mode 100644 index 0000000..39511bb --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/AddLinkMultipleIdType.php @@ -0,0 +1,100 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; + +/** + * @noinspection PhpUnused + */ +class AddLinkMultipleIdType implements Func +{ + public function __construct( + private SystemRestriction $systemRestriction, + private ?Entity $entity = null, + ) {} + + public function process(EvaluatedArgumentList $arguments): null + { + $entity = $this->entity ?? throw new NotPassedEntity(); + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $link = $arguments[0]; + $id = $arguments[1]; + + if (!is_string($link)) { + throw BadArgumentType::create(1, 'string'); + } + + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkFieldWrite($entityType, $link)) { + throw new NotAllowedUsage("Cannot write restricted field $entityType.$link."); + } + + if (is_array($id)) { + $idList = $id; + + foreach ($idList as $id) { + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string[]'); + } + + $entity->addLinkMultipleId($link, $id); + } + + return null; + } + + if (!is_string($id)) { + return null; + } + + $entity->addLinkMultipleId($link, $id); + + return null; + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/AttributeFetchedType.php b/application/Espo/Core/Formula/Functions/EntityGroup/AttributeFetchedType.php new file mode 100644 index 0000000..5de0520 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/AttributeFetchedType.php @@ -0,0 +1,45 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; + +class AttributeFetchedType extends AttributeType +{ + /** + * @throws NotAllowedUsage + * @throws Error + */ + protected function getAttributeValue($attribute) + { + return $this->attributeFetcher->fetch($this->getEntity(), $attribute, true); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/AttributeType.php b/application/Espo/Core/Formula/Functions/EntityGroup/AttributeType.php new file mode 100644 index 0000000..360fd17 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/AttributeType.php @@ -0,0 +1,51 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; + +class AttributeType extends \Espo\Core\Formula\Functions\AttributeType +{ + public function process(\stdClass $item) + { + if (count($item->value) < 1) { + throw TooFewArguments::create(1); + } + + $attribute = $this->evaluate($item->value[0]); + + if (!is_string($attribute)) { + throw BadArgumentType::create(1, 'string'); + } + + return $this->getAttributeValue($attribute); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/ClearAttributeType.php b/application/Espo/Core/Formula/Functions/EntityGroup/ClearAttributeType.php new file mode 100644 index 0000000..188b298 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/ClearAttributeType.php @@ -0,0 +1,81 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; + +/** + * @noinspection PhpUnused + */ +class ClearAttributeType implements Func +{ + public function __construct( + private SystemRestriction $systemRestriction, + private ?Entity $entity = null, + ) {} + + public function process(EvaluatedArgumentList $arguments): null + { + $entity = $this->entity ?? throw new NotPassedEntity(); + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $attribute = $arguments[0]; + + if (!is_string($attribute)) { + throw BadArgumentType::create(1, 'string'); + } + + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkAttributeWrite($entityType, $attribute)) { + throw new NotAllowedUsage("Cannot write restricted attribute $entityType.$attribute."); + } + + $entity->clear($attribute); + + return null; + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/CountRelatedType.php b/application/Espo/Core/Formula/Functions/EntityGroup/CountRelatedType.php new file mode 100644 index 0000000..b95b47e --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/CountRelatedType.php @@ -0,0 +1,118 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Functions\RecordGroup\Util\FindQueryUtil; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class CountRelatedType implements Func +{ + public function __construct( + private SystemRestriction $systemRestriction, + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private FindQueryUtil $findQueryUtil, + private ?Entity $entity = null, + ) {} + + public function process(EvaluatedArgumentList $arguments): int + { + $entity = $this->entity ?? throw new NotPassedEntity(); + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $link = $arguments[0]; + + if (!is_string($link)) { + throw BadArgumentType::create(1, 'string'); + } + + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkLinkRead($entityType, $link)) { + throw new NotAllowedUsage("Cannot read restricted field $entityType.$link."); + } + + $filter = null; + + if (count($arguments) > 1) { + $filter = $arguments[1]; + } + + $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY); + + if (empty($foreignEntityType)) { + throw new Error("Not supported link $link."); + } + + $builder = $this->selectBuilderFactory + ->create() + ->from($foreignEntityType); + + if ($filter) { + $this->findQueryUtil->applyFilter($builder, $filter, 2); + } + + try { + $query = $builder->build(); + } catch (BadRequest|Forbidden $e) { + throw new Error($e->getMessage()); + } + + return $this->entityManager + ->getRDBRepository($entity->getEntityType()) + ->getRelation($entity, $link) + ->clone($query) + ->count(); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/GetLinkColumnType.php b/application/Espo/Core/Formula/Functions/EntityGroup/GetLinkColumnType.php new file mode 100644 index 0000000..63a9874 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/GetLinkColumnType.php @@ -0,0 +1,95 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class GetLinkColumnType implements Func +{ + public function __construct( + private SystemRestriction $systemRestriction, + private EntityManager $entityManager, + private ?Entity $entity = null, + ) {} + + public function process(EvaluatedArgumentList $arguments): mixed + { + $entity = $this->entity ?? throw new NotPassedEntity(); + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + if (count($arguments) < 3) { + throw TooFewArguments::create(3); + } + + $link = $arguments[0]; + $id = $arguments[1]; + $column = $arguments[2]; + + if (!is_string($link)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($column)) { + throw BadArgumentType::create(3, 'string'); + } + + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkFieldRead($entityType, $link)) { + throw new NotAllowedUsage("Cannot read restricted field $entityType.$link."); + } + + $repository = $this->entityManager->getRDBRepository($entityType); + + return $repository + ->getRelation($entity, $link) + ->getColumnById($id, $column); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/HasLinkMultipleIdType.php b/application/Espo/Core/Formula/Functions/EntityGroup/HasLinkMultipleIdType.php new file mode 100644 index 0000000..4c7d888 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/HasLinkMultipleIdType.php @@ -0,0 +1,84 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; + +/** + * @noinspection PhpUnused + */ +class HasLinkMultipleIdType implements Func +{ + public function __construct( + private SystemRestriction $systemRestriction, + private ?Entity $entity = null, + ) {} + + public function process(EvaluatedArgumentList $arguments): bool + { + $entity = $this->entity ?? throw new NotPassedEntity(); + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $link = $arguments[0]; + $id = $arguments[1]; + + if (!is_string($link)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkFieldRead($entityType, $link)) { + throw new NotAllowedUsage("Cannot read restricted field $entityType.$link."); + } + + return $entity->hasLinkMultipleId($link, $id); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/IsAttributeChangedType.php b/application/Espo/Core/Formula/Functions/EntityGroup/IsAttributeChangedType.php new file mode 100644 index 0000000..c1b087f --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/IsAttributeChangedType.php @@ -0,0 +1,67 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Functions\Base; + +class IsAttributeChangedType extends Base +{ + /** + * @return bool + * @throws Error + */ + public function process(\stdClass $item) + { + if (count($item->value) < 1) { + throw TooFewArguments::create(1); + } + + $attribute = $this->evaluate($item->value[0]); + + if (!is_string($attribute)) { + throw BadArgumentType::create(1, 'string'); + } + + return $this->check($attribute); + } + + /** + * @param string $attribute + * @return bool + * @throws Error + */ + protected function check($attribute) + { + return $this->getEntity()->isAttributeChanged($attribute); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/IsAttributeNotChangedType.php b/application/Espo/Core/Formula/Functions/EntityGroup/IsAttributeNotChangedType.php new file mode 100644 index 0000000..44eb699 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/IsAttributeNotChangedType.php @@ -0,0 +1,45 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Formula\Exceptions\Error; + +class IsAttributeNotChangedType extends IsAttributeChangedType +{ + /** + * @param string $attribute + * @return bool + * @throws Error + */ + protected function check($attribute) + { + return !parent::check($attribute); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/IsNewType.php b/application/Espo/Core/Formula/Functions/EntityGroup/IsNewType.php new file mode 100644 index 0000000..5b82237 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/IsNewType.php @@ -0,0 +1,44 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Formula\Exceptions\Error; + +class IsNewType extends \Espo\Core\Formula\Functions\Base +{ + /** + * @return bool + * @throws Error + */ + public function process(\stdClass $item) + { + return $this->getEntity()->isNew(); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/IsRelatedType.php b/application/Espo/Core/Formula/Functions/EntityGroup/IsRelatedType.php new file mode 100644 index 0000000..f54a65d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/IsRelatedType.php @@ -0,0 +1,88 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class IsRelatedType implements Func +{ + public function __construct( + private SystemRestriction $systemRestriction, + private EntityManager $entityManager, + private ?Entity $entity = null, + ) {} + + public function process(EvaluatedArgumentList $arguments): bool + { + $entity = $this->entity ?? throw new NotPassedEntity(); + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + if (count($arguments) < 2) { + throw TooFewArguments::create(1); + } + + $link = $arguments[0]; + $id = $arguments[1]; + + if (!is_string($link)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkLinkRead($entityType, $link)) { + throw new NotAllowedUsage("Cannot read restricted field $entityType.$link."); + } + + return $this->entityManager + ->getRelation($entity, $link) + ->isRelatedById($id); + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/RemoveLinkMultipleIdType.php b/application/Espo/Core/Formula/Functions/EntityGroup/RemoveLinkMultipleIdType.php new file mode 100644 index 0000000..679975d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/RemoveLinkMultipleIdType.php @@ -0,0 +1,86 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; + +/** + * @noinspection PhpUnused + */ +class RemoveLinkMultipleIdType implements Func +{ + public function __construct( + private SystemRestriction $systemRestriction, + private ?Entity $entity = null, + ) {} + + public function process(EvaluatedArgumentList $arguments): null + { + $entity = $this->entity ?? throw new NotPassedEntity(); + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $link = $arguments[0]; + $id = $arguments[1]; + + if (!is_string($link)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkFieldWrite($entityType, $link)) { + throw new NotAllowedUsage("Cannot write restricted field $entityType.$link."); + } + + $entity->removeLinkMultipleId($link, $id); + + return null; + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/SetAttributeType.php b/application/Espo/Core/Formula/Functions/EntityGroup/SetAttributeType.php new file mode 100644 index 0000000..c50ba89 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/SetAttributeType.php @@ -0,0 +1,34 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +class SetAttributeType extends \Espo\Core\Formula\Functions\SetAttributeType +{ +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/SetLinkMultipleColumnType.php b/application/Espo/Core/Formula/Functions/EntityGroup/SetLinkMultipleColumnType.php new file mode 100644 index 0000000..6542742 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/SetLinkMultipleColumnType.php @@ -0,0 +1,92 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; + +/** + * @noinspection PhpUnused + */ +class SetLinkMultipleColumnType implements Func +{ + public function __construct( + private SystemRestriction $systemRestriction, + private ?Entity $entity = null, + ) {} + + public function process(EvaluatedArgumentList $arguments): null + { + $entity = $this->entity ?? throw new NotPassedEntity(); + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + if (count($arguments) < 4) { + throw TooFewArguments::create(4); + } + + $link = $arguments[0]; + $id = $arguments[1]; + $column = $arguments[2]; + $value = $arguments[3]; + + if (!is_string($link)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($column)) { + throw BadArgumentType::create(3, 'string'); + } + + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkFieldWrite($entityType, $link)) { + throw new NotAllowedUsage("Cannot write restricted field $entityType.$link."); + } + + $entity->setLinkMultipleColumn($link, $column, $id, $value); + + return null; + } +} diff --git a/application/Espo/Core/Formula/Functions/EntityGroup/SumRelatedType.php b/application/Espo/Core/Formula/Functions/EntityGroup/SumRelatedType.php new file mode 100644 index 0000000..47f1959 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EntityGroup/SumRelatedType.php @@ -0,0 +1,185 @@ +. + * + * 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\Formula\Functions\EntityGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\NotPassedEntity; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Functions\RecordGroup\Util\FindQueryUtil; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use PDO; + +/** + * @noinspection PhpUnused + */ +class SumRelatedType implements Func +{ + public function __construct( + private SystemRestriction $systemRestriction, + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private FindQueryUtil $findQueryUtil, + private ?Entity $entity = null, + ) {} + + public function process(EvaluatedArgumentList $arguments): int|float + { + $entity = $this->entity ?? throw new NotPassedEntity(); + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + if (count($arguments) < 2) { + throw TooFewArguments::create(1); + } + + $link = $arguments[0]; + $field = $arguments[1]; + + if (!is_string($link)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($field)) { + throw BadArgumentType::create(2, 'string'); + } + + $filter = null; + + if (count($arguments) > 2) { + $filter = $arguments[2]; + } + + $entityType = $entity->getEntityType(); + + if (!$this->systemRestriction->checkLinkRead($entityType, $link)) { + throw new NotAllowedUsage("Cannot read restricted field $entityType.$link."); + } + + $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY); + + if (!$foreignEntityType) { + throw new Error("Not supported link '$link'."); + } + + if (!$this->systemRestriction->checkLinkRead($foreignEntityType, $field)) { + throw new NotAllowedUsage("Cannot read restricted field $foreignEntityType.$field."); + } + + $foreignLink = $entity->getRelationParam($link, RelationParam::FOREIGN); + $foreignLinkAlias = $foreignLink . 'SumRelated'; + + if (empty($foreignLink)) { + throw new Error("No foreign link for link $link."); + } + + $builder = $this->selectBuilderFactory + ->create() + ->from($foreignEntityType); + + if ($filter) { + $this->findQueryUtil->applyFilter($builder, $filter, 3); + } + + try { + $queryBuilder = $builder->buildQueryBuilder(); + } catch (BadRequest|Forbidden $e) { + throw new Error($e->getMessage(), $e->getCode(), $e); + } + + $queryBuilder->select([ + [$foreignLinkAlias . '.id', 'foreignId'], + 'SUM:' . $field, + ]); + + if ($entity->getRelationType($link) === 'hasChildren') { + $queryBuilder + ->join( + $entity->getEntityType(), + $foreignLinkAlias, + [ + $foreignLinkAlias . '.id:' => $foreignLink . 'Id', + Attribute::DELETED => false, + $foreignLinkAlias . '.id!=' => null, + ] + ) + ->where([ + $foreignLink . 'Type' => $entity->getEntityType(), + ]); + } else { + $queryBuilder->join($foreignLink, $foreignLinkAlias); + } + + $queryBuilder->where([ + $foreignLinkAlias . '.id' => $entity->getId(), + ]); + + if ($queryBuilder->build()->isDistinct()) { + // Use a sub-query to weed out duplicate rows. + + $sqQueryBuilder = clone $queryBuilder; + + $sqQueryBuilder + ->order([]) + ->select([Attribute::ID]); + + $queryBuilder->where([ + 'id=s' => $sqQueryBuilder->build(), + ]); + } + + $queryBuilder->group($foreignLinkAlias . '.id'); + + $sth = $this->entityManager->getQueryExecutor()->execute($queryBuilder->build()); + + $rowList = $sth->fetchAll(PDO::FETCH_ASSOC); + + if (empty($rowList)) { + return 0.0; + } + + $stringValue = $rowList[0]['SUM:' . $field]; + + return floatval($stringValue); + } +} diff --git a/application/Espo/Core/Formula/Functions/EnvGroup/UserAttributeSafeType.php b/application/Espo/Core/Formula/Functions/EnvGroup/UserAttributeSafeType.php new file mode 100644 index 0000000..1fa074d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EnvGroup/UserAttributeSafeType.php @@ -0,0 +1,73 @@ +. + * + * 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\Formula\Functions\EnvGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\BadArgumentValue; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Entities\User; + +class UserAttributeSafeType implements Func +{ + /** @var string[] */ + private array $allowedAttributeList = [ + 'id', + 'userName', + 'name', + 'teamsIds', + 'defaultTeamId', + 'type', + ]; + + public function __construct( + private User $user + ) {} + + public function process(EvaluatedArgumentList $arguments): mixed + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $attribute = $arguments[0]; + + if (!is_string($attribute)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!in_array($attribute, $this->allowedAttributeList)) { + throw BadArgumentValue::create(1, "No allowed attribute."); + } + + return $this->user->get($attribute); + } +} diff --git a/application/Espo/Core/Formula/Functions/EnvGroup/UserAttributeType.php b/application/Espo/Core/Formula/Functions/EnvGroup/UserAttributeType.php new file mode 100644 index 0000000..228452f --- /dev/null +++ b/application/Espo/Core/Formula/Functions/EnvGroup/UserAttributeType.php @@ -0,0 +1,69 @@ +. + * + * 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\Formula\Functions\EnvGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Entities\User; + +class UserAttributeType implements Func +{ + /** @var string[] */ + private array $forbiddenAttributeList = [ + 'password', + 'apiKey', + 'secretKey', + ]; + + public function __construct( + private User $user + ) {} + + public function process(EvaluatedArgumentList $arguments): mixed + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $attribute = $arguments[0]; + + if (!is_string($attribute)) { + throw BadArgumentType::create(1, 'string'); + } + + if (in_array($attribute, $this->forbiddenAttributeList)) { + return null; + } + + return $this->user->get($attribute); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExceptionGroup/ThrowInvalidType.php b/application/Espo/Core/Formula/Functions/ExceptionGroup/ThrowInvalidType.php new file mode 100644 index 0000000..d5afaa5 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExceptionGroup/ThrowInvalidType.php @@ -0,0 +1,52 @@ +. + * + * 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\Formula\Functions\ExceptionGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentValue; +use Espo\Core\Formula\Exceptions\ValidationFunctionException; +use Espo\Core\Formula\Func; + +/** + * @noinspection PhpUnused + */ +class ThrowInvalidType implements Func +{ + public function process(EvaluatedArgumentList $arguments): never + { + $message = $arguments[0] ?? null; + + if ($message !== null && !is_string($message)) { + throw BadArgumentValue::create(1, 'string'); + } + + throw new ValidationFunctionException($message ?? ''); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckEntityType.php b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckEntityType.php new file mode 100644 index 0000000..01301b3 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckEntityType.php @@ -0,0 +1,94 @@ +. + * + * 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\Formula\Functions\ExtGroup\AclGroup; + +use Espo\Core\Acl\Table; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Utils\Acl\UserAclManagerProvider; +use Espo\Entities\User; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class CheckEntityType implements Func +{ + public function __construct( + private UserAclManagerProvider $userAclManagerProvider, + private EntityManager $entityManager, + ) {} + + public function process(EvaluatedArgumentList $arguments): bool + { + if (count($arguments) < 3) { + throw TooFewArguments::create(3); + } + + $userId = $arguments[0]; + $entityType = $arguments[1]; + $id = $arguments[2]; + + /** @var Table::ACTION_*|null $action */ + $action = $arguments[3] ?? Table::ACTION_READ; + + if (!is_string($userId)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($entityType)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(3, 'string'); + } + + if (!is_string($action)) { + throw BadArgumentType::create(4, 'string'); + } + + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + return false; + } + + $entity = $this->entityManager->getEntityById($entityType, $id); + + if (!$entity) { + return false; + } + + return $this->userAclManagerProvider->get($user)->checkEntity($user, $entity, $action); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckScopeType.php b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckScopeType.php new file mode 100644 index 0000000..97b8fcc --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckScopeType.php @@ -0,0 +1,80 @@ +. + * + * 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\Formula\Functions\ExtGroup\AclGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Utils\Acl\UserAclManagerProvider; +use Espo\Entities\User; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class CheckScopeType implements Func +{ + public function __construct( + private UserAclManagerProvider $userAclManagerProvider, + private EntityManager $entityManager, + ) {} + + public function process(EvaluatedArgumentList $arguments): bool + { + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $userId = $arguments[0]; + $scope = $arguments[1]; + $action = $arguments[2] ?? null; + + if (!is_string($userId)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($scope)) { + throw BadArgumentType::create(2, 'string'); + } + + if ($action !== null && !is_string($action)) { + throw BadArgumentType::create(3, 'string'); + } + + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + return false; + } + + return $this->userAclManagerProvider->get($user)->checkScope($user, $scope, $action); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/GetLevelType.php b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/GetLevelType.php new file mode 100644 index 0000000..01334d4 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/GetLevelType.php @@ -0,0 +1,96 @@ +. + * + * 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\Formula\Functions\ExtGroup\AclGroup; + +use Espo\Core\Acl\Table; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\BadArgumentValue; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Utils\Acl\UserAclManagerProvider; +use Espo\Entities\User; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class GetLevelType implements Func +{ + public function __construct( + private UserAclManagerProvider $userAclManagerProvider, + private EntityManager $entityManager, + ) {} + + public function process(EvaluatedArgumentList $arguments): string + { + if (count($arguments) < 3) { + throw TooFewArguments::create(3); + } + + $userId = $arguments[0]; + $scope = $arguments[1]; + $action = $arguments[2]; + + if (!is_string($userId)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($scope)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($action)) { + throw BadArgumentType::create(3, 'string'); + } + + if ( + !in_array($action, [ + Table::ACTION_READ, + Table::ACTION_CREATE, + Table::ACTION_EDIT, + Table::ACTION_STREAM, + Table::ACTION_DELETE, + ]) + ) { + throw BadArgumentValue::create(3); + } + + /** @var Table::ACTION_* $action */ + + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + return Table::LEVEL_NO; + } + + return $this->userAclManagerProvider->get($user)->getLevel($user, $scope, $action); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/GetPermissionLevelType.php b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/GetPermissionLevelType.php new file mode 100644 index 0000000..3372d80 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/GetPermissionLevelType.php @@ -0,0 +1,76 @@ +. + * + * 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\Formula\Functions\ExtGroup\AclGroup; + +use Espo\Core\Acl\Table; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Utils\Acl\UserAclManagerProvider; +use Espo\Entities\User; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class GetPermissionLevelType implements Func +{ + public function __construct( + private UserAclManagerProvider $userAclManagerProvider, + private EntityManager $entityManager, + ) {} + + public function process(EvaluatedArgumentList $arguments): string + { + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $userId = $arguments[0]; + $permission = $arguments[1]; + + if (!is_string($userId)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($permission)) { + throw BadArgumentType::create(2, 'string'); + } + + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + return Table::LEVEL_NO; + } + + return $this->userAclManagerProvider->get($user)->getPermissionLevel($user, $permission); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/CurrencyGroup/ConvertType.php b/application/Espo/Core/Formula/Functions/ExtGroup/CurrencyGroup/ConvertType.php new file mode 100644 index 0000000..cb83385 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/CurrencyGroup/ConvertType.php @@ -0,0 +1,81 @@ +. + * + * 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\Formula\Functions\ExtGroup\CurrencyGroup; + +use Espo\Core\Currency\ConfigDataProvider; +use Espo\Core\Currency\Converter; +use Espo\Core\Field\Currency; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; + +class ConvertType implements Func +{ + public function __construct( + private ConfigDataProvider $configDataProvider, + private Converter $converter + ) {} + + public function process(EvaluatedArgumentList $arguments): string + { + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $amount = $arguments[0]; + $fromCode = $arguments[1]; + $toCode = $arguments[2] ?? $this->configDataProvider->getDefaultCurrency(); + + if ( + !is_string($amount) && + !is_int($amount) && + !is_float($amount) + ) { + throw BadArgumentType::create(1, 'int|float|string'); + } + + if (!is_string($fromCode)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($toCode)) { + throw BadArgumentType::create(3, 'string'); + } + + assert(is_numeric($amount)); + + $value = Currency::create($amount, $fromCode); + + $convertedValue = $this->converter->convert($value, $toCode); + + return $convertedValue->getAmountAsString(); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/EmailGroup/ApplyTemplateType.php b/application/Espo/Core/Formula/Functions/ExtGroup/EmailGroup/ApplyTemplateType.php new file mode 100644 index 0000000..007d594 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/EmailGroup/ApplyTemplateType.php @@ -0,0 +1,149 @@ +. + * + * 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\Formula\Functions\ExtGroup\EmailGroup; + +use Espo\Core\Formula\Exceptions\FunctionRuntimeError; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\Utils\SystemUser; +use Espo\Entities\Email; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Di; +use Espo\Entities\EmailTemplate; +use Espo\Tools\EmailTemplate\Data; +use Espo\Tools\EmailTemplate\Params; +use Espo\Tools\EmailTemplate\Processor; + +/** + * @noinspection PhpUnused + */ +class ApplyTemplateType extends BaseFunction implements + + Di\EntityManagerAware, + Di\InjectableFactoryAware +{ + use Di\EntityManagerSetter; + use Di\InjectableFactorySetter; + + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(2); + } + + $args = $this->evaluate($args); + + $id = $args[0]; + $templateId = $args[1]; + $parentType = $args[2] ?? null; + $parentId = $args[3] ?? null; + + if (!$id || !is_string($id)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!$templateId || !is_string($templateId)) { + $this->throwBadArgumentType(2, 'string'); + } + + if ($parentType && !is_string($parentType)) { + $this->throwBadArgumentType(3, 'string'); + } + + if ($parentId && !is_string($parentId)) { + $this->throwBadArgumentType(4, 'string'); + } + + $em = $this->entityManager; + + $email = $em->getRDBRepositoryByClass(Email::class)->getById($id); + $emailTemplate = $em->getRDBRepositoryByClass(EmailTemplate::class)->getById($templateId); + + if (!$email) { + throw new FunctionRuntimeError("Email $id does not exist."); + } + + if (!$emailTemplate) { + throw new FunctionRuntimeError("EmailTemplate $templateId does not exist."); + } + + $status = $email->getStatus(); + + if ($status && $status === Email::STATUS_SENT) { + throw new FunctionRuntimeError("Can't apply template to email with 'Sent' status."); + } + + $processor = $this->injectableFactory->create(Processor::class); + + $params = Params::create() + ->withCopyAttachments() + ->withApplyAcl(false); + + $data = Data::create(); + + if (!$parentType || !$parentId) { + $parentType = $email->getParentType(); + $parentId = $email->getParentId(); + } + + if ($parentType && $parentId) { + $data = $data + ->withParentId($parentId) + ->withParentType($parentType); + } + + $data = $data->withEmailAddress( + $email->getToAddressList()[0] ?? null + ); + + $emailData = $processor->process($emailTemplate, $params, $data); + + $attachmentsIdList = $email->getLinkMultipleIdList('attachments'); + + $attachmentsIdList = array_merge( + $attachmentsIdList, + $emailData->getAttachmentIdList() + ); + + $email + ->setSubject($emailData->getSubject()) + ->setBody($emailData->getBody()) + ->setIsHtml($emailData->isHtml()) + ->setAttachmentIdList($attachmentsIdList); + + $systemUserId = $this->injectableFactory->create(SystemUser::class)->getId(); + + $em->saveEntity($email, [ + SaveOption::MODIFIED_BY_ID => $systemUserId, + ]); + + return true; + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/EmailGroup/SendType.php b/application/Espo/Core/Formula/Functions/ExtGroup/EmailGroup/SendType.php new file mode 100644 index 0000000..6137310 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/EmailGroup/SendType.php @@ -0,0 +1,129 @@ +. + * + * 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\Formula\Functions\ExtGroup\EmailGroup; + +use Espo\Core\Formula\Exceptions\FunctionRuntimeError; +use Espo\Core\Mail\ConfigDataProvider; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Utils\SystemUser; +use Espo\Entities\Email; +use Espo\Tools\Email\SendService; +use Espo\Core\Di; +use Exception; + +/** + * @noinspection PhpUnused + */ +class SendType extends BaseFunction implements + Di\EntityManagerAware, + Di\ConfigAware, + Di\InjectableFactoryAware, + Di\RecordServiceContainerAware +{ + use Di\EntityManagerSetter; + use Di\ConfigSetter; + use Di\InjectableFactorySetter; + use Di\RecordServiceContainerSetter; + + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(1); + } + + $args = $this->evaluate($args); + + $id = $args[0]; + + if (!$id || !is_string($id)) { + $this->throwBadArgumentType(1, 'string'); + } + + $em = $this->entityManager; + + $email = $em->getRDBRepositoryByClass(Email::class)->getById($id); + + if (!$email) { + throw new FunctionRuntimeError("Email $id does not exist."); + } + + if ($email->getStatus() === Email::STATUS_SENT) { + throw new FunctionRuntimeError("Can't send email that has 'Sent' status."); + } + + $this->recordServiceContainer + ->getByClass(Email::class) + ->loadAdditionalFields($email); + + $toSave = false; + + if ($email->getStatus() !== Email::STATUS_SENDING) { + $email->setStatus(Email::STATUS_SENDING); + + $toSave = true; + } + + if (!$email->getFromAddress()) { + $from = $this->injectableFactory + ->create(ConfigDataProvider::class) + ->getSystemOutboundAddress(); + + if ($from) { + $email->setFromAddress($from); + + $toSave = true; + } + } + + $systemUserId = $this->injectableFactory->create(SystemUser::class)->getId(); + + if ($toSave) { + $em->saveEntity($email, [ + SaveOption::SILENT => true, + SaveOption::MODIFIED_BY_ID => $systemUserId, + ]); + } + + $sendService = $this->injectableFactory->create(SendService::class); + + try { + $sendService->send($email); + } catch (Exception $e) { + $email->setStatus(Email::STATUS_FAILED); + $em->saveEntity($email); + + throw new FunctionRuntimeError("Error while sending email. {$e->getMessage()}", previous: $e); + } + + return true; + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/MarkdownGroup/TransformType.php b/application/Espo/Core/Formula/Functions/ExtGroup/MarkdownGroup/TransformType.php new file mode 100644 index 0000000..913876c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/MarkdownGroup/TransformType.php @@ -0,0 +1,57 @@ +. + * + * 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\Formula\Functions\ExtGroup\MarkdownGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Utils\Markdown\Markdown; + +/** + * @noinspection PhpUnused + */ +class TransformType implements Func +{ + public function process(EvaluatedArgumentList $arguments): string + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $string = $arguments[0] ?? ''; + + if (!is_string($string)) { + throw BadArgumentType::create(1, 'string'); + } + + return Markdown::transform($string); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/OauthGroup/GetAccessTokenType.php b/application/Espo/Core/Formula/Functions/ExtGroup/OauthGroup/GetAccessTokenType.php new file mode 100644 index 0000000..e12bf89 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/OauthGroup/GetAccessTokenType.php @@ -0,0 +1,80 @@ +. + * + * 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\Formula\Functions\ExtGroup\OauthGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Tools\OAuth\Exceptions\AccountNotFound; +use Espo\Tools\OAuth\Exceptions\NoToken; +use Espo\Tools\OAuth\Exceptions\ProviderNotAvailable; +use Espo\Tools\OAuth\Exceptions\TokenObtainingFailure; +use Espo\Tools\OAuth\TokensProvider; + +/** + * @noinspection PhpUnused + */ +class GetAccessTokenType implements Func +{ + public function __construct( + private TokensProvider $provider, + ) {} + + public function process(EvaluatedArgumentList $arguments): string + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $id = $arguments[0]; + + if (!is_string($id)) { + throw BadArgumentType::create(1, 'string'); + } + + try { + $tokens = $this->provider->get($arguments[0]); + } catch (AccountNotFound|NoToken|ProviderNotAvailable|TokenObtainingFailure $e) { + $message = "Could not obtain access token for OAuth account $id."; + + throw new Error($message, 500, $e); + } + + $accessToken = $tokens->getAccessToken(); + + if (!$accessToken) { + throw new Error("No access token."); + } + + return $accessToken; + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/PdfGroup/GenerateType.php b/application/Espo/Core/Formula/Functions/ExtGroup/PdfGroup/GenerateType.php new file mode 100644 index 0000000..54aa451 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/PdfGroup/GenerateType.php @@ -0,0 +1,154 @@ +. + * + * 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\Formula\Functions\ExtGroup\PdfGroup; + +use Espo\Core\Field\LinkParent; +use Espo\Core\Formula\Exceptions\FunctionRuntimeError; +use Espo\Core\Name\Field; +use Espo\Entities\Attachment; +use Espo\Entities\Template; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Utils\Util; +use Espo\ORM\Entity; +use Espo\Tools\Pdf\Params; +use Espo\Core\Di; +use Espo\Tools\Pdf\Result; +use Espo\Tools\Pdf\Service; +use Exception; + +/** + * @noinspection PhpUnused + */ +class GenerateType extends BaseFunction implements + Di\EntityManagerAware, + Di\InjectableFactoryAware, + Di\FileStorageManagerAware +{ + use Di\EntityManagerSetter; + use Di\InjectableFactorySetter; + use Di\FileStorageManagerSetter; + + public function process(ArgumentList $args) + { + if (count($args) < 3) { + $this->throwTooFewArguments(3); + } + + $args = $this->evaluate($args); + + $entityType = $args[0]; + $id = $args[1]; + $templateId = $args[2]; + $fileName = $args[3] ?? null; + + if (!$entityType || !is_string($entityType)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!$id || !is_string($id)) { + $this->throwBadArgumentType(2, 'string'); + } + + if (!$templateId || !is_string($templateId)) { + $this->throwBadArgumentType(3, 'string'); + } + + if ($fileName && !is_string($fileName)) { + $this->throwBadArgumentType(4, 'string'); + } + + $em = $this->entityManager; + + $entity = $em->getEntityById($entityType, $id); + + if (!$entity) { + throw new FunctionRuntimeError("Record $entityType $id does not exist."); + } + + $template = $em->getRDBRepositoryByClass(Template::class)->getById($templateId); + + if (!$template) { + throw new FunctionRuntimeError("Template $templateId does not exist."); + } + + $params = Params::create()->withAcl(false); + + $service = $this->injectableFactory->create(Service::class); + + try { + $result = $service->generate( + entityType: $entity->getEntityType(), + id: $entity->getId(), + templateId: $template->getId(), + params: $params, + ); + } catch (Exception $e) { + throw new FunctionRuntimeError("Error while generating PDF template. {$e->getMessage()}", previous: $e); + } + + $fileName = $this->prepareFilename($fileName, $result, $entity); + + $attachment = $em->getRDBRepositoryByClass(Attachment::class)->getNew(); + + $attachment + ->setName($fileName) + ->setType('application/pdf') + ->setSize($result->getStream()->getSize()) + ->setRelated(LinkParent::create($entityType, $id)) + ->setRole(Attachment::ROLE_ATTACHMENT); + + $em->saveEntity($attachment); + + $this->fileStorageManager->putStream($attachment, $result->getStream()); + + return $attachment->getId(); + } + + private function composeFilename(Entity $entity): string + { + $defaultName = $entity->get(Field::NAME) ?? $entity->getId(); + + return Util::sanitizeFileName($defaultName) . '.pdf'; + } + + private function prepareFilename(mixed $fileName, Result $result, Entity $entity): string + { + if ($fileName) { + if (!str_ends_with($fileName, '.pdf')) { + $fileName .= '.pdf'; + } + + return $fileName; + } + + return $result->getFilename() ?? $this->composeFilename($entity); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/SecretGroup/GetType.php b/application/Espo/Core/Formula/Functions/ExtGroup/SecretGroup/GetType.php new file mode 100644 index 0000000..27f58fe --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/SecretGroup/GetType.php @@ -0,0 +1,67 @@ +. + * + * 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\Formula\Functions\ExtGroup\SecretGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Tools\AppSecret\SecretProvider; + +/** + * @noinspection PhpUnused + */ +class GetType implements Func +{ + public function __construct(private SecretProvider $secretProvider) + {} + + public function process(EvaluatedArgumentList $arguments): string + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $name = $arguments[0]; + + if (!is_string($name)) { + throw BadArgumentType::create(1, 'string'); + } + + $secret = $this->secretProvider->get($name); + + if ($secret === null) { + throw new Error("No secret '$name'."); + } + + return $secret; + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/SmsGroup/SendType.php b/application/Espo/Core/Formula/Functions/ExtGroup/SmsGroup/SendType.php new file mode 100644 index 0000000..86be183 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/SmsGroup/SendType.php @@ -0,0 +1,94 @@ +. + * + * 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\Formula\Functions\ExtGroup\SmsGroup; + +use Espo\Core\Formula\Exceptions\FunctionRuntimeError; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Sms\SmsSender; +use Espo\Entities\Sms; +use Espo\Core\Di; +use Exception; + +/** + * @noinspection PhpUnused + */ +class SendType extends BaseFunction implements + + Di\EntityManagerAware, + Di\InjectableFactoryAware +{ + use Di\EntityManagerSetter; + use Di\InjectableFactorySetter; + + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(1); + } + + $evaluatedArgs = $this->evaluate($args); + + $id = $evaluatedArgs[0]; + + if (!$id || !is_string($id)) { + $this->throwBadArgumentType(1, 'string'); + } + + + $sms = $this->entityManager->getRDBRepositoryByClass(Sms::class)->getById($id); + + if (!$sms) { + throw new FunctionRuntimeError("SMS $id does not exist."); + } + + if ($sms->getStatus() === Sms::STATUS_SENT) { + throw new FunctionRuntimeError("Can't send SMS that has 'Sent' status."); + } + + try { + $this->createSender()->send($sms); + + $this->entityManager->saveEntity($sms); + } catch (Exception $e) { + $sms->setStatus(Sms::STATUS_FAILED); + $this->entityManager->saveEntity($sms); + + throw new FunctionRuntimeError("Error while sending SMS. {$e->getMessage()}", previous: $e); + } + + return true; + } + + private function createSender(): SmsSender + { + return $this->injectableFactory->create(SmsSender::class); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/UserGroup/SendAccessInfoType.php b/application/Espo/Core/Formula/Functions/ExtGroup/UserGroup/SendAccessInfoType.php new file mode 100644 index 0000000..991fa18 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/UserGroup/SendAccessInfoType.php @@ -0,0 +1,85 @@ +. + * + * 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\Formula\Functions\ExtGroup\UserGroup; + +use Espo\Core\Formula\Exceptions\FunctionRuntimeError; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Job\JobSchedulerFactory; +use Espo\Tools\UserSecurity\Password\Jobs\SendAccessInfo as SendAccessInfoJob; +use Espo\Core\Job\Job\Data as JobData; +use Espo\Entities\User; +use Espo\Core\Di; + +/** + * @noinspection PhpUnused + */ +class SendAccessInfoType extends BaseFunction implements + Di\EntityManagerAware, + Di\InjectableFactoryAware +{ + use Di\EntityManagerSetter; + use Di\InjectableFactorySetter; + + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(1); + } + + $evaluatedArgs = $this->evaluate($args); + + $userId = $evaluatedArgs[0]; + + if (!$userId || !is_string($userId)) { + $this->throwBadArgumentType(1, 'string'); + } + + $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId); + + if (!$user) { + throw new FunctionRuntimeError("User '$userId' does not exist."); + } + + $this->createJobScheduledFactory() + ->create() + ->setClassName(SendAccessInfoJob::class) + ->setData( + JobData::create() + ->withTargetId($user->getId()) + ) + ->schedule(); + } + + private function createJobScheduledFactory(): JobSchedulerFactory + { + return $this->injectableFactory->create(JobSchedulerFactory::class); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/AddWorkingDaysType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/AddWorkingDaysType.php new file mode 100644 index 0000000..a772c35 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/AddWorkingDaysType.php @@ -0,0 +1,91 @@ +. + * + * 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\Formula\Functions\ExtGroup\WorkingTimeGroup; + +use Espo\Core\Field\DateTime; +use Espo\Core\Field\DateTimeOptional; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Utils\DateTime as DateTimeUtil; + +class AddWorkingDaysType extends Base +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(2); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue = $evaluatedArgs[0]; + $days = $evaluatedArgs[1]; + + if (!is_string($stringValue)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_int($days) && !is_float($days)) { + $this->throwBadArgumentType(2, 'int'); + } + + if (is_float($days)) { + $days = (int) $days; + } + + if ($days <= 0) { + $this->throwBadArgumentValue(2, 'Days value should be greater than 0.'); + } + + $calendar = $this->createCalendar($evaluatedArgs, 2); + + $dateTime = DateTimeOptional::fromString($stringValue); + + $isAllDay = $dateTime->isAllDay(); + + if ($isAllDay) { + $dateTime = $dateTime->withTimezone($calendar->getTimezone()); + } + + $dateTime = DateTime::fromDateTime($dateTime->toDateTime()); + + $result = $this->createCalendarUtility($calendar)->addWorkingDays($dateTime, $days); + + if (!$result) { + return null; + } + + if ($isAllDay) { + return $result->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT); + } + + return $result->toString(); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/Base.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/Base.php new file mode 100644 index 0000000..cab92df --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/Base.php @@ -0,0 +1,126 @@ +. + * + * 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\Formula\Functions\ExtGroup\WorkingTimeGroup; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\BadArgumentValue; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Functions\BaseFunction; + +use Espo\Entities\Team; +use Espo\Entities\User; + +use Espo\Core\Di; +use Espo\ORM\Entity; +use Espo\Tools\WorkingTime\Calendar; +use Espo\Tools\WorkingTime\CalendarFactory; +use Espo\Tools\WorkingTime\CalendarUtility; + +abstract class Base extends BaseFunction implements + + Di\EntityManagerAware, + Di\InjectableFactoryAware +{ + use Di\EntityManagerSetter; + use Di\InjectableFactorySetter; + + private function getCalendarFactory(): CalendarFactory + { + return $this->injectableFactory->create(CalendarFactory::class); + } + + protected function createCalendarUtility(Calendar $calendar): CalendarUtility + { + return $this->injectableFactory->createWithBinding( + CalendarUtility::class, + BindingContainerBuilder::create() + ->bindInstance(Calendar::class, $calendar) + ->build() + ); + } + + /** + * @param mixed[] $evaluatedArgs + * @throws BadArgumentType + * @throws BadArgumentValue + * @throws Error + */ + protected function createCalendar(array $evaluatedArgs, int $argumentPosition = 1): Calendar + { + $target = $this->obtainTarget($evaluatedArgs, $argumentPosition); + + if ($target instanceof User) { + return $this->getCalendarFactory()->createForUser($target); + } + + if ($target instanceof Team) { + return $this->getCalendarFactory()->createForTeam($target); + } + + return $this->getCalendarFactory()->createGlobal(); + } + + /** + * @param mixed[] $evaluatedArgs + * @throws BadArgumentType + * @throws BadArgumentValue + * @throws Error + */ + private function obtainTarget(array $evaluatedArgs, int $argumentPosition = 1): ?Entity + { + if (count($evaluatedArgs) < $argumentPosition + 2) { + return null; + } + + $entityType = $evaluatedArgs[$argumentPosition]; + $entityId = $evaluatedArgs[$argumentPosition + 1]; + + if (!is_string($entityType)) { + $this->throwBadArgumentType($argumentPosition + 1, 'string'); + } + + if (!is_string($entityId)) { + $this->throwBadArgumentType($argumentPosition + 2, 'string'); + } + + if (!in_array($entityType, [User::ENTITY_TYPE, Team::ENTITY_TYPE])) { + $this->throwBadArgumentValue($argumentPosition + 1); + } + + $entity = $this->entityManager->getEntityById($entityType, $entityId); + + if (!$entity) { + $this->throwError("Entity {$entityType} {$entityId} not found."); + } + + return $entity; + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/FindClosestWorkingTimeType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/FindClosestWorkingTimeType.php new file mode 100644 index 0000000..a1f3f55 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/FindClosestWorkingTimeType.php @@ -0,0 +1,71 @@ +. + * + * 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\Formula\Functions\ExtGroup\WorkingTimeGroup; + +use Espo\Core\Field\DateTime; +use Espo\Core\Field\DateTimeOptional; +use Espo\Core\Formula\ArgumentList; + +class FindClosestWorkingTimeType extends Base +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(1); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue = $evaluatedArgs[0]; + + if (!is_string($stringValue)) { + $this->throwBadArgumentType(1, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs); + + $dateTime = DateTimeOptional::fromString($stringValue); + + if ($dateTime->isAllDay()) { + $dateTime = $dateTime->withTimezone($calendar->getTimezone()); + } + + $dateTime = DateTime::fromDateTime($dateTime->getDateTime()); + + $result = $this->createCalendarUtility($calendar)->findClosestWorkingTime($dateTime); + + if (!$result) { + return null; + } + + return $result->toString(); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetSummedWorkingHoursType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetSummedWorkingHoursType.php new file mode 100644 index 0000000..8ba4e50 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetSummedWorkingHoursType.php @@ -0,0 +1,76 @@ +. + * + * 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\Formula\Functions\ExtGroup\WorkingTimeGroup; + +use Espo\Core\Field\DateTime; +use Espo\Core\Field\DateTimeOptional; +use Espo\Core\Formula\ArgumentList; + +class GetSummedWorkingHoursType extends Base +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(2); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue1 = $evaluatedArgs[0]; + $stringValue2 = $evaluatedArgs[1]; + + if (!is_string($stringValue1)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_string($stringValue2)) { + $this->throwBadArgumentType(2, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs, 2); + + $dateTime1 = DateTimeOptional::fromString($stringValue1); + $dateTime2 = DateTimeOptional::fromString($stringValue2); + + if ($dateTime1->isAllDay()) { + $dateTime1 = $dateTime1->withTimezone($calendar->getTimezone()); + } + + if ($dateTime2->isAllDay()) { + $dateTime2 = $dateTime2->withTimezone($calendar->getTimezone()); + } + + $dateTime1 = DateTime::fromDateTime($dateTime1->getDateTime()); + $dateTime2 = DateTime::fromDateTime($dateTime2->getDateTime()); + + return $this->createCalendarUtility($calendar)->getSummedWorkingHours($dateTime1, $dateTime2); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetWorkingDaysType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetWorkingDaysType.php new file mode 100644 index 0000000..b05c744 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/GetWorkingDaysType.php @@ -0,0 +1,76 @@ +. + * + * 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\Formula\Functions\ExtGroup\WorkingTimeGroup; + +use Espo\Core\Field\DateTime; +use Espo\Core\Field\DateTimeOptional; +use Espo\Core\Formula\ArgumentList; + +class GetWorkingDaysType extends Base +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(2); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue1 = $evaluatedArgs[0]; + $stringValue2 = $evaluatedArgs[1]; + + if (!is_string($stringValue1)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_string($stringValue2)) { + $this->throwBadArgumentType(2, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs, 2); + + $dateTime1 = DateTimeOptional::fromString($stringValue1); + $dateTime2 = DateTimeOptional::fromString($stringValue2); + + if ($dateTime1->isAllDay()) { + $dateTime1 = $dateTime1->withTimezone($calendar->getTimezone()); + } + + if ($dateTime2->isAllDay()) { + $dateTime2 = $dateTime2->withTimezone($calendar->getTimezone()); + } + + $dateTime1 = DateTime::fromDateTime($dateTime1->getDateTime()); + $dateTime2 = DateTime::fromDateTime($dateTime2->getDateTime()); + + return $this->createCalendarUtility($calendar)->getWorkingDays($dateTime1, $dateTime2); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/HasWorkingTimeType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/HasWorkingTimeType.php new file mode 100644 index 0000000..34fb5c3 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/HasWorkingTimeType.php @@ -0,0 +1,76 @@ +. + * + * 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\Formula\Functions\ExtGroup\WorkingTimeGroup; + +use Espo\Core\Field\DateTime; +use Espo\Core\Field\DateTimeOptional; +use Espo\Core\Formula\ArgumentList; + +class HasWorkingTimeType extends Base +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(2); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue1 = $evaluatedArgs[0]; + $stringValue2 = $evaluatedArgs[1]; + + if (!is_string($stringValue1)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_string($stringValue2)) { + $this->throwBadArgumentType(2, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs, 2); + + $dateTime1 = DateTimeOptional::fromString($stringValue1); + $dateTime2 = DateTimeOptional::fromString($stringValue2); + + if ($dateTime1->isAllDay()) { + $dateTime1 = $dateTime1->withTimezone($calendar->getTimezone()); + } + + if ($dateTime2->isAllDay()) { + $dateTime2 = $dateTime2->withTimezone($calendar->getTimezone()); + } + + $dateTime1 = DateTime::fromDateTime($dateTime1->getDateTime()); + $dateTime2 = DateTime::fromDateTime($dateTime2->getDateTime()); + + return $this->createCalendarUtility($calendar)->hasWorkingTime($dateTime1, $dateTime2); + } +} diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/IsWorkingDayType.php b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/IsWorkingDayType.php new file mode 100644 index 0000000..ddb8cab --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ExtGroup/WorkingTimeGroup/IsWorkingDayType.php @@ -0,0 +1,65 @@ +. + * + * 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\Formula\Functions\ExtGroup\WorkingTimeGroup; + +use Espo\Core\Field\DateTime; +use Espo\Core\Field\DateTimeOptional; +use Espo\Core\Formula\ArgumentList; + +class IsWorkingDayType extends Base +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(1); + } + + /** @var mixed[] $evaluatedArgs */ + $evaluatedArgs = $this->evaluate($args); + + $stringValue = $evaluatedArgs[0]; + + if (!is_string($stringValue)) { + $this->throwBadArgumentType(1, 'string'); + } + + $calendar = $this->createCalendar($evaluatedArgs); + + $dateTime = DateTimeOptional::fromString($stringValue); + + if ($dateTime->isAllDay()) { + $dateTime = $dateTime->withTimezone($calendar->getTimezone()); + } + + $dateTime = DateTime::fromDateTime($dateTime->getDateTime()); + + return $this->createCalendarUtility($calendar)->isWorkingDay($dateTime); + } +} diff --git a/application/Espo/Core/Formula/Functions/IfThenElseType.php b/application/Espo/Core/Formula/Functions/IfThenElseType.php new file mode 100644 index 0000000..6c9d1ef --- /dev/null +++ b/application/Espo/Core/Formula/Functions/IfThenElseType.php @@ -0,0 +1,53 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class IfThenElseType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + if ($this->evaluate($args[0])) { + return $this->evaluate($args[1]); + } + + if (count($args) > 2) { + return $this->evaluate($args[2]); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/IfThenType.php b/application/Espo/Core/Formula/Functions/IfThenType.php new file mode 100644 index 0000000..4a4fda8 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/IfThenType.php @@ -0,0 +1,49 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class IfThenType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + if ($this->evaluate($args[0])) { + return $this->evaluate($args[1]); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/JsonGroup/EncodeType.php b/application/Espo/Core/Formula/Functions/JsonGroup/EncodeType.php new file mode 100644 index 0000000..35ec6f0 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/JsonGroup/EncodeType.php @@ -0,0 +1,49 @@ +. + * + * 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\Formula\Functions\JsonGroup; + +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\ArgumentList; + +use Espo\Core\Utils\Json; + +class EncodeType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $this->evaluate($args[0]); + + return Json::encode($value); + } +} diff --git a/application/Espo/Core/Formula/Functions/JsonGroup/RetrieveType.php b/application/Espo/Core/Formula/Functions/JsonGroup/RetrieveType.php new file mode 100644 index 0000000..60761ff --- /dev/null +++ b/application/Espo/Core/Formula/Functions/JsonGroup/RetrieveType.php @@ -0,0 +1,130 @@ +. + * + * 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\Formula\Functions\JsonGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\ExecutionException; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Functions\BaseFunction; + +class RetrieveType extends BaseFunction +{ + /** + * @return mixed + * @throws TooFewArguments + * @throws Error + * @throws ExecutionException + */ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $jsonString = $this->evaluate($args[0]); + + $path = count($args) > 1 ? + $this->evaluate($args[1]) : + ''; + + if (!is_string($jsonString)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_string($path)) { + $this->throwBadArgumentType(2, 'string'); + } + + $item = json_decode($jsonString); + + $pathArray = $this->splitPath($path); + + return $this->retrieveAttribute($item, $pathArray); + } + + /** + * @param string $path + * @return string[] + */ + private function splitPath(string $path): array + { + if ($path === '') { + return []; + } + + /** @var list $pathArray */ + $pathArray = preg_split('/(? $item) { + $pathArray[$i] = str_replace('\.', '.', $item); + } + + return $pathArray; + } + + /** + * @param mixed $item + * @param string[] $path + * @return mixed + */ + private function retrieveAttribute($item, array $path) + { + if (!count($path)) { + return $item; + } + + $key = array_shift($path); + + if (is_array($item)) { + $key = intval($key); + + $subItem = $item[$key] ?? null; + + if (is_null($subItem)) { + return null; + } + + return $this->retrieveAttribute($subItem, $path); + } + + if (is_object($item)) { + $subItem = $item->$key ?? null; + + if (is_null($subItem)) { + return null; + } + + return $this->retrieveAttribute($subItem, $path); + } + + return null; + } +} diff --git a/application/Espo/Core/Formula/Functions/LanguageGroup/TranslateOptionType.php b/application/Espo/Core/Formula/Functions/LanguageGroup/TranslateOptionType.php new file mode 100644 index 0000000..764affe --- /dev/null +++ b/application/Espo/Core/Formula/Functions/LanguageGroup/TranslateOptionType.php @@ -0,0 +1,57 @@ +. + * + * 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\Formula\Functions\LanguageGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +use Espo\Core\Di; + +class TranslateOptionType extends BaseFunction implements Di\DefaultLanguageAware +{ + use Di\DefaultLanguageSetter; + + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $args = $this->evaluate($args); + + return $this->defaultLanguage->translateOption( + $args[0], + $args[1] ?? null, + $args[2] ?? null + ); + } +} diff --git a/application/Espo/Core/Formula/Functions/LanguageGroup/TranslateType.php b/application/Espo/Core/Formula/Functions/LanguageGroup/TranslateType.php new file mode 100644 index 0000000..cf76eff --- /dev/null +++ b/application/Espo/Core/Formula/Functions/LanguageGroup/TranslateType.php @@ -0,0 +1,57 @@ +. + * + * 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\Formula\Functions\LanguageGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +use Espo\Core\Di; + +class TranslateType extends BaseFunction implements Di\DefaultLanguageAware +{ + use Di\DefaultLanguageSetter; + + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $args = $this->evaluate($args); + + return $this->defaultLanguage->translate( + $args[0], + $args[1] ?? null, + $args[2] ?? null + ); + } +} diff --git a/application/Espo/Core/Formula/Functions/ListType.php b/application/Espo/Core/Formula/Functions/ListType.php new file mode 100644 index 0000000..1b6cf48 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ListType.php @@ -0,0 +1,49 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class ListType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $result = []; + + foreach ($args as $item) { + $result[] = $this->evaluate($item); + } + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/LogGroup/ErrorType.php b/application/Espo/Core/Formula/Functions/LogGroup/ErrorType.php new file mode 100644 index 0000000..3deb7f0 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/LogGroup/ErrorType.php @@ -0,0 +1,37 @@ +. + * + * 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\Formula\Functions\LogGroup; + +use Psr\Log\LogLevel; + +class ErrorType extends InfoType +{ + protected string $level = LogLevel::ERROR; +} diff --git a/application/Espo/Core/Formula/Functions/LogGroup/InfoType.php b/application/Espo/Core/Formula/Functions/LogGroup/InfoType.php new file mode 100644 index 0000000..e67248c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/LogGroup/InfoType.php @@ -0,0 +1,71 @@ +. + * + * 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\Formula\Functions\LogGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Utils\Log; +use Psr\Log\LogLevel; +use stdClass; + +class InfoType implements Func +{ + protected string $level = LogLevel::INFO; + + public function __construct( + private Log $log + ) {} + + public function process(EvaluatedArgumentList $arguments): mixed + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $message = $arguments[0]; + $context = $arguments[1] ?? (object) []; + + if (!is_string($message)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!$context instanceof stdClass) { + throw BadArgumentType::create(2, 'object'); + } + + $context = array_merge(get_object_vars($context), ['context' => 'formula']); + + $this->log->log($this->level, $message, $context); + + return null; + } +} diff --git a/application/Espo/Core/Formula/Functions/LogGroup/NoticeType.php b/application/Espo/Core/Formula/Functions/LogGroup/NoticeType.php new file mode 100644 index 0000000..b3eaab4 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/LogGroup/NoticeType.php @@ -0,0 +1,37 @@ +. + * + * 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\Formula\Functions\LogGroup; + +use Psr\Log\LogLevel; + +class NoticeType extends InfoType +{ + protected string $level = LogLevel::NOTICE; +} diff --git a/application/Espo/Core/Formula/Functions/LogGroup/WarningType.php b/application/Espo/Core/Formula/Functions/LogGroup/WarningType.php new file mode 100644 index 0000000..9d07e63 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/LogGroup/WarningType.php @@ -0,0 +1,37 @@ +. + * + * 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\Formula\Functions\LogGroup; + +use Psr\Log\LogLevel; + +class WarningType extends InfoType +{ + protected string $level = LogLevel::WARNING; +} diff --git a/application/Espo/Core/Formula/Functions/LogicalGroup/AndType.php b/application/Espo/Core/Formula/Functions/LogicalGroup/AndType.php new file mode 100644 index 0000000..48ecff8 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/LogicalGroup/AndType.php @@ -0,0 +1,54 @@ +. + * + * 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\Formula\Functions\LogicalGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class AndType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $result = true; + + foreach ($args as $item) { + /** @phpstan-ignore-next-line */ + $result = $result && $this->evaluate($item); + + if (!$result) { + break; + } + } + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/LogicalGroup/NotType.php b/application/Espo/Core/Formula/Functions/LogicalGroup/NotType.php new file mode 100644 index 0000000..8728da2 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/LogicalGroup/NotType.php @@ -0,0 +1,43 @@ +. + * + * 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\Formula\Functions\LogicalGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class NotType extends BaseFunction +{ + public function process(ArgumentList $args) + { + return !$this->evaluate($args[0]); + } +} diff --git a/application/Espo/Core/Formula/Functions/LogicalGroup/OrType.php b/application/Espo/Core/Formula/Functions/LogicalGroup/OrType.php new file mode 100644 index 0000000..7913b76 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/LogicalGroup/OrType.php @@ -0,0 +1,54 @@ +. + * + * 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\Formula\Functions\LogicalGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class OrType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $result = false; + + foreach ($args as $item) { + /** @phpstan-ignore-next-line */ + $result = $result || $this->evaluate($item); + + if ($result) { + break; + } + } + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/NumberGroup/AbsType.php b/application/Espo/Core/Formula/Functions/NumberGroup/AbsType.php new file mode 100644 index 0000000..cda791e --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumberGroup/AbsType.php @@ -0,0 +1,61 @@ +. + * + * 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\Formula\Functions\NumberGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class AbsType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $this->evaluate($args[0]); + + if (!is_numeric($value)) { + return null; + } + + if (is_string($value)) { + if (str_contains($value, '.')) { + $value = (float) $value; + } else { + $value = (int) $value; + } + } + + return abs($value); + } +} diff --git a/application/Espo/Core/Formula/Functions/NumberGroup/CeilType.php b/application/Espo/Core/Formula/Functions/NumberGroup/CeilType.php new file mode 100644 index 0000000..15f3ae3 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumberGroup/CeilType.php @@ -0,0 +1,53 @@ +. + * + * 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\Formula\Functions\NumberGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class CeilType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $this->evaluate($args[0]); + + if (!is_numeric($value)) { + return null; + } + + return intval(ceil((float) $value)); + } +} diff --git a/application/Espo/Core/Formula/Functions/NumberGroup/FloorType.php b/application/Espo/Core/Formula/Functions/NumberGroup/FloorType.php new file mode 100644 index 0000000..4e1b76f --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumberGroup/FloorType.php @@ -0,0 +1,53 @@ +. + * + * 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\Formula\Functions\NumberGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class FloorType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $this->evaluate($args[0]); + + if (!is_numeric($value)) { + return null; + } + + return intval(floor((float) $value)); + } +} diff --git a/application/Espo/Core/Formula/Functions/NumberGroup/FormatType.php b/application/Espo/Core/Formula/Functions/NumberGroup/FormatType.php new file mode 100644 index 0000000..673b675 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumberGroup/FormatType.php @@ -0,0 +1,69 @@ +. + * + * 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\Formula\Functions\NumberGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +use Espo\Core\Di; + +class FormatType extends BaseFunction implements + Di\NumberAware +{ + use Di\NumberSetter; + + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $decimals = null; + if (count($args) > 1) { + $decimals = $this->evaluate($args[1]); + } + + $decimalMark = null; + if (count($args) > 2) { + $decimalMark = $this->evaluate($args[2]); + } + + $thousandSeparator = null; + if (count($args) > 3) { + $thousandSeparator = $this->evaluate($args[3]); + } + + $value = $this->evaluate($args[0]); + + return $this->number->format($value, $decimals, $decimalMark, $thousandSeparator); + } +} diff --git a/application/Espo/Core/Formula/Functions/NumberGroup/ParseFloatType.php b/application/Espo/Core/Formula/Functions/NumberGroup/ParseFloatType.php new file mode 100644 index 0000000..8638895 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumberGroup/ParseFloatType.php @@ -0,0 +1,51 @@ +. + * + * 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\Formula\Functions\NumberGroup; + +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\ArgumentList; + +class ParseFloatType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $this->evaluate($args[0]) ?? '0.0'; + + if (!is_string($value)) { + return $this->throwBadArgumentType(1, 'string'); + } + + return floatval($value); + } +} diff --git a/application/Espo/Core/Formula/Functions/NumberGroup/ParseIntType.php b/application/Espo/Core/Formula/Functions/NumberGroup/ParseIntType.php new file mode 100644 index 0000000..8351b6b --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumberGroup/ParseIntType.php @@ -0,0 +1,51 @@ +. + * + * 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\Formula\Functions\NumberGroup; + +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\ArgumentList; + +class ParseIntType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $this->evaluate($args[0]) ?? '0'; + + if (!is_string($value)) { + return $this->throwBadArgumentType(1, 'string'); + } + + return intval($value); + } +} diff --git a/application/Espo/Core/Formula/Functions/NumberGroup/PowerType.php b/application/Espo/Core/Formula/Functions/NumberGroup/PowerType.php new file mode 100644 index 0000000..d3f581b --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumberGroup/PowerType.php @@ -0,0 +1,61 @@ +. + * + * 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\Formula\Functions\NumberGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; + +/** + * @noinspection PhpUnused + */ +class PowerType implements Func +{ + public function process(EvaluatedArgumentList $arguments): float|int + { + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $value = $arguments[0]; + $exp = $arguments[1]; + + if (!is_int($value) && !is_float($value)) { + throw BadArgumentType::create(1, 'int|float'); + } + + if (!is_int($exp) && !is_float($exp)) { + throw BadArgumentType::create(2, 'int|float'); + } + + return pow($value, $exp); + } +} diff --git a/application/Espo/Core/Formula/Functions/NumberGroup/RandomIntType.php b/application/Espo/Core/Formula/Functions/NumberGroup/RandomIntType.php new file mode 100644 index 0000000..88a7fb3 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumberGroup/RandomIntType.php @@ -0,0 +1,56 @@ +. + * + * 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\Formula\Functions\NumberGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class RandomIntType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + $min = $args[0] ?? 0; + $max = $args[1] ?? PHP_INT_MAX; + + if (!is_int($min)) { + $this->throwBadArgumentType(1, 'int'); + } + + if (!is_int($max)) { + $this->throwBadArgumentType(2, 'int'); + } + + return random_int($min, $max); + } +} diff --git a/application/Espo/Core/Formula/Functions/NumberGroup/RoundType.php b/application/Espo/Core/Formula/Functions/NumberGroup/RoundType.php new file mode 100644 index 0000000..91481e5 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumberGroup/RoundType.php @@ -0,0 +1,59 @@ +. + * + * 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\Formula\Functions\NumberGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class RoundType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $this->evaluate($args[0]); + + $precision = 0; + + if (count($args) > 1) { + $precision = $this->evaluate($args[1]); + } + + if (!is_numeric($value)) { + return null; + } + + return round((float) $value, $precision); + } +} diff --git a/application/Espo/Core/Formula/Functions/NumericGroup/DivisionType.php b/application/Espo/Core/Formula/Functions/NumericGroup/DivisionType.php new file mode 100644 index 0000000..b2c27c0 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumericGroup/DivisionType.php @@ -0,0 +1,56 @@ +. + * + * 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\Formula\Functions\NumericGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class DivisionType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $result = $this->evaluate($args[0]); + $part = $this->evaluate($args[1]); + + if (!is_float($part) && !is_int($part)) { + $part = floatval($part); + } + + $result /= $part; + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/NumericGroup/ModuloType.php b/application/Espo/Core/Formula/Functions/NumericGroup/ModuloType.php new file mode 100644 index 0000000..a67474d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumericGroup/ModuloType.php @@ -0,0 +1,56 @@ +. + * + * 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\Formula\Functions\NumericGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class ModuloType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $result = $this->evaluate($args[0]); + $part = $this->evaluate($args[1]); + + if (!is_float($part) && !is_int($part)) { + $part = floatval($part); + } + + $result %= $part; + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/NumericGroup/MultiplicationType.php b/application/Espo/Core/Formula/Functions/NumericGroup/MultiplicationType.php new file mode 100644 index 0000000..9cae7ca --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumericGroup/MultiplicationType.php @@ -0,0 +1,54 @@ +. + * + * 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\Formula\Functions\NumericGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class MultiplicationType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $result = 1; + foreach ($args as $subItem) { + $part = $this->evaluate($subItem); + + if (!is_float($part) && !is_int($part)) { + $part = floatval($part); + } + + $result *= $part; + } + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/NumericGroup/SubtractionType.php b/application/Espo/Core/Formula/Functions/NumericGroup/SubtractionType.php new file mode 100644 index 0000000..97ba092 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumericGroup/SubtractionType.php @@ -0,0 +1,56 @@ +. + * + * 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\Formula\Functions\NumericGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class SubtractionType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $result = $this->evaluate($args[0]); + $part = $this->evaluate($args[1]); + + if (!is_float($part) && !is_int($part)) { + $part = floatval($part); + } + + $result -= $part; + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/NumericGroup/SummationType.php b/application/Espo/Core/Formula/Functions/NumericGroup/SummationType.php new file mode 100644 index 0000000..856331f --- /dev/null +++ b/application/Espo/Core/Formula/Functions/NumericGroup/SummationType.php @@ -0,0 +1,55 @@ +. + * + * 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\Formula\Functions\NumericGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class SummationType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $result = 0; + + foreach ($args as $subItem) { + $part = $this->evaluate($subItem); + + if (!is_float($part) && !is_int($part)) { + $part = floatval($part); + } + + $result += $part; + } + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/ObjectGroup/ClearType.php b/application/Espo/Core/Formula/Functions/ObjectGroup/ClearType.php new file mode 100644 index 0000000..717a49b --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ObjectGroup/ClearType.php @@ -0,0 +1,60 @@ +. + * + * 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\Formula\Functions\ObjectGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +use stdClass; + +class ClearType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(2); + } + + $obj = $this->evaluate($args[0]); + $key = $this->evaluate($args[1]); + + if (!$obj instanceof stdClass) { + $this->throwBadArgumentType(1, 'object'); + } + + if ($key === null) { + $this->throwBadArgumentType(2); + } + + unset($obj->$key); + + return $obj; + } +} diff --git a/application/Espo/Core/Formula/Functions/ObjectGroup/CloneDeepType.php b/application/Espo/Core/Formula/Functions/ObjectGroup/CloneDeepType.php new file mode 100644 index 0000000..eb3b2ed --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ObjectGroup/CloneDeepType.php @@ -0,0 +1,55 @@ +. + * + * 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\Formula\Functions\ObjectGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +use Espo\Core\Utils\ObjectUtil; + +use stdClass; + +class CloneDeepType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(1); + } + + $obj = $this->evaluate($args[0]); + + if (!$obj instanceof stdClass) { + $this->throwBadArgumentType(1, 'object'); + } + + return ObjectUtil::clone($obj); + } +} diff --git a/application/Espo/Core/Formula/Functions/ObjectGroup/CreateType.php b/application/Espo/Core/Formula/Functions/ObjectGroup/CreateType.php new file mode 100644 index 0000000..5216626 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ObjectGroup/CreateType.php @@ -0,0 +1,41 @@ +. + * + * 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\Formula\Functions\ObjectGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +class CreateType extends BaseFunction +{ + public function process(ArgumentList $args) + { + return (object) []; + } +} diff --git a/application/Espo/Core/Formula/Functions/ObjectGroup/GetType.php b/application/Espo/Core/Formula/Functions/ObjectGroup/GetType.php new file mode 100644 index 0000000..6e2930c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ObjectGroup/GetType.php @@ -0,0 +1,58 @@ +. + * + * 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\Formula\Functions\ObjectGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +use stdClass; + +class GetType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(2); + } + + $obj = $this->evaluate($args[0]); + $key = $this->evaluate($args[1]); + + if (!$obj instanceof stdClass) { + $this->throwBadArgumentType(1, 'object'); + } + + if ($key === null) { + $this->throwBadArgumentType(2); + } + + return $obj->$key ?? null; + } +} diff --git a/application/Espo/Core/Formula/Functions/ObjectGroup/HasType.php b/application/Espo/Core/Formula/Functions/ObjectGroup/HasType.php new file mode 100644 index 0000000..e0e3c66 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ObjectGroup/HasType.php @@ -0,0 +1,58 @@ +. + * + * 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\Formula\Functions\ObjectGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +use stdClass; + +class HasType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(2); + } + + $obj = $this->evaluate($args[0]); + $key = $this->evaluate($args[1]); + + if (!$obj instanceof stdClass) { + $this->throwBadArgumentType(1, 'object'); + } + + if ($key === null) { + $this->throwBadArgumentType(2); + } + + return property_exists($obj, $key); + } +} diff --git a/application/Espo/Core/Formula/Functions/ObjectGroup/SetType.php b/application/Espo/Core/Formula/Functions/ObjectGroup/SetType.php new file mode 100644 index 0000000..55b9040 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ObjectGroup/SetType.php @@ -0,0 +1,62 @@ +. + * + * 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\Formula\Functions\ObjectGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + + +use stdClass; + +class SetType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 3) { + $this->throwTooFewArguments(3); + } + + $obj = $this->evaluate($args[0]); + $key = $this->evaluate($args[1]); + $value = $this->evaluate($args[2]); + + if (!$obj instanceof stdClass) { + $this->throwBadArgumentType(1, 'object'); + } + + if ($key === null) { + $this->throwBadArgumentType(2); + } + + $obj->$key = $value; + + return $obj; + } +} diff --git a/application/Espo/Core/Formula/Functions/OutputGroup/PrintLineType.php b/application/Espo/Core/Formula/Functions/OutputGroup/PrintLineType.php new file mode 100644 index 0000000..b20f4a3 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/OutputGroup/PrintLineType.php @@ -0,0 +1,72 @@ +. + * + * 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\Formula\Functions\OutputGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +use stdClass; +use const JSON_UNESCAPED_UNICODE; + +class PrintLineType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) === 0) { + $this->throwTooFewArguments(1); + } + + $value = $this->evaluate($args[0]); + + if (is_int($value) || is_float($value)) { + $value = strval($value); + } else if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } else if (is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE); + } else if ($value instanceof stdClass) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE); + } else if ($value === null) { + $value = 'null'; + } + + $variables = $this->getVariables(); + + if (!isset($variables->__output)) { + $variables->__output = ''; + } + + if ($variables->__output !== '') { + $variables->__output = $variables->__output .= "\n"; + } + + $variables->__output = $variables->__output .= $value; + } +} diff --git a/application/Espo/Core/Formula/Functions/OutputGroup/PrintType.php b/application/Espo/Core/Formula/Functions/OutputGroup/PrintType.php new file mode 100644 index 0000000..9a08fe1 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/OutputGroup/PrintType.php @@ -0,0 +1,68 @@ +. + * + * 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\Formula\Functions\OutputGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +use stdClass; +use const JSON_UNESCAPED_UNICODE; + +class PrintType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) === 0) { + $this->throwTooFewArguments(1); + } + + $value = $this->evaluate($args[0]); + + if (is_int($value) || is_float($value)) { + $value = strval($value); + } else if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } else if (is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE); + } else if ($value instanceof stdClass) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE); + } else if ($value === null) { + $value = 'null'; + } + + $variables = $this->getVariables(); + + if (!isset($variables->__output)) { + $variables->__output = ''; + } + + $variables->__output = $variables->__output .= $value; + } +} diff --git a/application/Espo/Core/Formula/Functions/PasswordGroup/GenerateType.php b/application/Espo/Core/Formula/Functions/PasswordGroup/GenerateType.php new file mode 100644 index 0000000..e998bac --- /dev/null +++ b/application/Espo/Core/Formula/Functions/PasswordGroup/GenerateType.php @@ -0,0 +1,46 @@ +. + * + * 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\Formula\Functions\PasswordGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Func; +use Espo\Tools\UserSecurity\Password\Generator; + +class GenerateType implements Func +{ + public function __construct( + private Generator $generator, + ) {} + + public function process(EvaluatedArgumentList $arguments): string + { + return $this->generator->generate(); + } +} diff --git a/application/Espo/Core/Formula/Functions/PasswordGroup/HashType.php b/application/Espo/Core/Formula/Functions/PasswordGroup/HashType.php new file mode 100644 index 0000000..7e37bfd --- /dev/null +++ b/application/Espo/Core/Formula/Functions/PasswordGroup/HashType.php @@ -0,0 +1,63 @@ +. + * + * 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\Formula\Functions\PasswordGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\Processor; + +use Espo\Core\Utils\PasswordHash; +use SensitiveParameter; + +class HashType extends BaseFunction +{ + protected PasswordHash $passwordHash; + + public function __construct(Processor $processor, PasswordHash $passwordHash) + { + $this->processor = $processor; + $this->passwordHash = $passwordHash; + } + + public function process(#[SensitiveParameter] ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $password = $this->evaluate($args[0]); + + if (!is_string($password)) { + $this->throwBadArgumentType(1, 'string'); + } + + return $this->passwordHash->hash($password); + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/AttributeType.php b/application/Espo/Core/Formula/Functions/RecordGroup/AttributeType.php new file mode 100644 index 0000000..991c0d0 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/AttributeType.php @@ -0,0 +1,76 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; + +use Espo\Core\Di; +use stdClass; + +class AttributeType extends \Espo\Core\Formula\Functions\AttributeType implements + Di\EntityManagerAware +{ + use Di\EntityManagerSetter; + + /** + * @throws Error + */ + public function process(stdClass $item) + { + if (count($item->value) < 3) { + throw new Error("record\\attribute: too few arguments."); + } + + $entityType = $this->evaluate($item->value[0]); + $id = $this->evaluate($item->value[1]); + $attribute = $this->evaluate($item->value[2]); + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($attribute)) { + throw BadArgumentType::create(3, 'string'); + } + + $entity = $this->entityManager->getEntityById($entityType, $id); + + if (!$entity) { + return null; + } + + return $this->attributeFetcher->fetch($entity, $attribute); + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/CountType.php b/application/Espo/Core/Formula/Functions/RecordGroup/CountType.php new file mode 100644 index 0000000..2c14fd4 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/CountType.php @@ -0,0 +1,111 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Functions\RecordGroup\Util\FindQueryUtil; +use Espo\Core\Select\Primary\Filters\All; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class CountType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private FindQueryUtil $findQueryUtil, + ) {} + + public function process(EvaluatedArgumentList $arguments): int + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $entityType = $arguments[0]; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (count($arguments) < 3) { + $filter = null; + + if (count($arguments) === 2) { + $filter = $arguments[1]; + } + + $builder = $this->selectBuilderFactory + ->create() + ->withPrimaryFilter(All::NAME) + ->from($entityType); + + $this->findQueryUtil->applyFilter($builder, $filter, 2); + + try { + return $this->entityManager + ->getRDBRepository($entityType) + ->clone($builder->build()) + ->count(); + } catch (BadRequest|Forbidden $e) { + throw new NotAllowedUsage($e->getMessage(), 0, $e); + } + } + + $whereClause = []; + + $i = 1; + + while ($i < count($arguments) - 1) { + $key = $arguments[$i]; + $value = $arguments[$i + 1]; + + $this->findQueryUtil->assertWhereClauseKeyValid($entityType, $key); + + $whereClause[] = [$key => $value]; + + $i = $i + 2; + } + + return $this->entityManager + ->getRDBRepository($entityType) + ->where($whereClause) + ->count(); + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/CreateType.php b/application/Espo/Core/Formula/Functions/RecordGroup/CreateType.php new file mode 100644 index 0000000..95bd6d7 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/CreateType.php @@ -0,0 +1,140 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Utils\EntityUtil; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class CreateType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private EntityUtil $entityUtil, + ) {} + + public function process(EvaluatedArgumentList $arguments): ?string + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $entityType = $arguments[0]; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + $data = $this->getData($arguments, $entityType); + + $notAllowedAttributes = array_intersect( + array_keys($data), + $this->entityUtil->getWriteRestrictedAttributeList($entityType), + ); + + $notAllowedAttributes = array_values($notAllowedAttributes); + + if ($notAllowedAttributes) { + throw new NotAllowedUsage("Cannot write $entityType.$notAllowedAttributes[0]."); + } + + $id = $data[Attribute::ID] ?? null; + + if ($id !== null) { + if (!is_string($id) && !is_int($id)) { + throw new NotAllowedUsage("Entity ID can be only string or int."); + } + + if (is_string($id) && !preg_match('/^[a-zA-Z0-9\-]+$/', $id)) { + throw new NotAllowedUsage("Not allowed characters in ID '$id'."); + } + } + + $entity = $this->entityManager->getNewEntity($entityType); + $entity->setMultiple($data); + + $this->entityUtil->assertUpdateAccess($entity); + + $this->entityManager->saveEntity($entity); + + return $entity->getId(); + } + + /** + * @return array + * @throws BadArgumentType + */ + private function getData(EvaluatedArgumentList $args, mixed $entityType): array + { + if (count($args) >= 2 && $args[1] instanceof stdClass) { + return $this->filterData(get_object_vars($args[1])); + } + + $data = []; + + $i = 1; + + while ($i < count($args) - 1) { + $attribute = $args[$i]; + + if (!is_string($entityType)) { + throw BadArgumentType::create($i + 1, 'string'); + } + + /** @var string $attribute */ + + $value = $args[$i + 1]; + + $data[$attribute] = $value; + + $i = $i + 2; + } + + return $this->filterData($data); + } + + /** + * @param array $data + * @return array + */ + private function filterData(array $data): array + { + return $data; + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/DeleteType.php b/application/Espo/Core/Formula/Functions/RecordGroup/DeleteType.php new file mode 100644 index 0000000..b31ef77 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/DeleteType.php @@ -0,0 +1,78 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Utils\EntityUtil; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class DeleteType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private EntityUtil $entityUtil, + ) {} + + public function process(EvaluatedArgumentList $arguments): null + { + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $entityType = $arguments[0]; + $id = $arguments[1]; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + $entity = $this->entityManager->getEntityById($entityType, $id); + + if (!$entity) { + return null; + } + + $this->entityUtil->assertRemoveAccess($entity); + + $this->entityManager->removeEntity($entity); + + return null; + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/ExistsType.php b/application/Espo/Core/Formula/Functions/RecordGroup/ExistsType.php new file mode 100644 index 0000000..44263f0 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/ExistsType.php @@ -0,0 +1,113 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Functions\RecordGroup\Util\FindQueryUtil; +use Espo\Core\Select\Primary\Filters\All; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +/** + * @noinspection PhpUnused + */ +class ExistsType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private FindQueryUtil $findQueryUtil, + ) {} + + public function process(EvaluatedArgumentList $arguments): bool + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $entityType = $arguments[0]; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (count($arguments) <= 2) { + $filter = null; + + if (count($arguments) === 2) { + $filter = $arguments[1]; + } + + $builder = $this->selectBuilderFactory + ->create() + ->withPrimaryFilter(All::NAME) + ->from($entityType); + + $this->findQueryUtil->applyFilter($builder, $filter, 2); + + try { + return (bool) $this->entityManager + ->getRDBRepository($entityType) + ->clone($builder->build()) + ->findOne(); + } catch (BadRequest|Forbidden $e) { + throw new NotAllowedUsage($e->getMessage(), 0, $e); + } + } + + $whereClause = []; + + $i = 1; + + while ($i < count($arguments) - 1) { + $key = $arguments[$i]; + $value = $arguments[$i + 1]; + + $this->findQueryUtil->assertWhereClauseKeyValid($entityType, $key); + + $whereClause[] = [$key => $value]; + + $i = $i + 2; + } + + return (bool) $this->entityManager + ->getRDBRepository($entityType) + ->select([Attribute::ID]) + ->where($whereClause) + ->findOne(); + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/FetchType.php b/application/Espo/Core/Formula/Functions/RecordGroup/FetchType.php new file mode 100644 index 0000000..89db695 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/FetchType.php @@ -0,0 +1,101 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\ORM\Type\FieldType; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use stdClass; + +class FetchType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private SystemRestriction $systemRestriction, + ) {} + + public function process(EvaluatedArgumentList $arguments): ?stdClass + { + if (count($arguments) < 2) { + throw TooFewArguments::create(1); + } + + $entityType = $arguments[0] ?? null; + $id = $arguments[1] ?? null; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + $entity = $this->entityManager->getEntityById($entityType, $id); + + if (!$entity) { + return null; + } + + $this->load($entity); + + foreach ($this->systemRestriction->getReadRestrictedAttributeList($entityType) as $attribute) { + $entity->clear($attribute); + } + + return $entity->getValueMap(); + } + + private function load(Entity $entity): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + $fieldDefsList = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()) + ->getFieldList(); + + foreach ($fieldDefsList as $fieldDefs) { + $field = $fieldDefs->getName(); + + if ($fieldDefs->getType() === FieldType::LINK_MULTIPLE && $entity->hasLinkMultipleField($field)) { + $entity->loadLinkMultipleField($field); + } + } + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/FindManyType.php b/application/Espo/Core/Formula/Functions/RecordGroup/FindManyType.php new file mode 100644 index 0000000..60984cc --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/FindManyType.php @@ -0,0 +1,144 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Functions\RecordGroup\Util\FindQueryUtil; +use Espo\Core\Select\Primary\Filters\All; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +/** + * @noinspection PhpUnused + */ +class FindManyType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private FindQueryUtil $findQueryUtil, + ) {} + + /** + * @return string[] + * @inheritDoc + */ + public function process(EvaluatedArgumentList $arguments): array + { + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $entityType = $arguments[0]; + $limit = $arguments[1]; + $orderBy = $arguments[2] ?? null; + $order = $arguments[3] ?? null; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_int($limit)) { + throw BadArgumentType::create(2, 'int'); + } + + if ($orderBy !== null && !is_string($orderBy)) { + throw BadArgumentType::create(3, 'string|null'); + } + + if ($order !== null && !is_bool($order) && !is_string($order)) { + throw BadArgumentType::create(4, 'string|bool|null'); + } + + $builder = $this->selectBuilderFactory + ->create() + ->withPrimaryFilter(All::NAME) + ->from($entityType); + + $this->findQueryUtil->applyOrder($builder, $orderBy, $order, 4); + + $whereClause = []; + + if (count($arguments) <= 5) { + $filter = null; + + if (count($arguments) === 5) { + $filter = $arguments[4]; + } + + $this->findQueryUtil->applyFilter($builder, $filter, 5); + } else { + $i = 4; + + while ($i < count($arguments) - 1) { + $key = $arguments[$i]; + $value = $arguments[$i + 1]; + + $this->findQueryUtil->assertWhereClauseKeyValid($entityType, $key); + + $whereClause[] = [$key => $value]; + + $i = $i + 2; + } + } + + try { + $queryBuilder = $builder->buildQueryBuilder(); + } catch (BadRequest|Forbidden $e) { + throw new NotAllowedUsage($e->getMessage(), $e->getCode(), $e); + } + + if (!empty($whereClause)) { + $queryBuilder->where($whereClause); + } + + $queryBuilder + ->select([Attribute::ID]) + ->limit(0, $limit); + + $collection = $this->entityManager + ->getRDBRepository($entityType) + ->clone($queryBuilder->build()) + ->find(); + + return array_map( + fn (Entity $entity) => $entity->getId(), + iterator_to_array($collection) + ); + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/FindOneType.php b/application/Espo/Core/Formula/Functions/RecordGroup/FindOneType.php new file mode 100644 index 0000000..46c6e5a --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/FindOneType.php @@ -0,0 +1,129 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Functions\RecordGroup\Util\FindQueryUtil; +use Espo\Core\Select\Primary\Filters\All; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +/** + * @noinspection PhpUnused + */ +class FindOneType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private FindQueryUtil $findQueryUtil, + ) {} + + public function process(EvaluatedArgumentList $arguments): ?string + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $entityType = $arguments[0]; + $orderBy = $arguments[1] ?? null; + $order = $arguments[2] ?? null; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if ($orderBy !== null && !is_string($orderBy)) { + throw BadArgumentType::create(2, 'string|null'); + } + + if ($order !== null && !is_bool($order) && !is_string($order)) { + throw BadArgumentType::create(3, 'string|bool|null'); + } + + $builder = $this->selectBuilderFactory + ->create() + ->withPrimaryFilter(All::NAME) + ->from($entityType); + + $this->findQueryUtil->applyOrder($builder, $orderBy, $order, 3); + + $whereClause = []; + + if (count($arguments) <= 4) { + $filter = null; + + if (count($arguments) === 4) { + $filter = $arguments[3]; + } + + $this->findQueryUtil->applyFilter($builder, $filter, 4); + } else { + $i = 3; + + while ($i < count($arguments) - 1) { + $key = $arguments[$i]; + $value = $arguments[$i + 1]; + + $this->findQueryUtil->assertWhereClauseKeyValid($entityType, $key); + + $whereClause[] = [$key => $value]; + + $i = $i + 2; + } + } + + try { + $queryBuilder = $builder->buildQueryBuilder(); + } catch (BadRequest|Forbidden $e) { + throw new NotAllowedUsage($e->getMessage(), $e->getCode(), $e); + } + + if (!empty($whereClause)) { + $queryBuilder->where($whereClause); + } + + $queryBuilder->select([Attribute::ID]); + + $entity = $this->entityManager + ->getRDBRepository($entityType) + ->clone($queryBuilder->build()) + ->findOne(); + + return $entity?->getId(); + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/FindRelatedManyType.php b/application/Espo/Core/Formula/Functions/RecordGroup/FindRelatedManyType.php new file mode 100644 index 0000000..2c6279d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/FindRelatedManyType.php @@ -0,0 +1,241 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Functions\RecordGroup\Util\FindQueryUtil; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Select\Helpers\RandomStringGenerator; +use Espo\Core\Select\Primary\Filters\All; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Type\RelationType; + +/** + * @noinspection PhpUnused + */ +class FindRelatedManyType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private FindQueryUtil $findQueryUtil, + private RandomStringGenerator $randomStringGenerator, + private SystemRestriction $systemRestriction, + ) {} + + /** + * @return string[] + * @throws Error + * @throws TooFewArguments + * @throws BadArgumentType + */ + public function process(EvaluatedArgumentList $arguments): array + { + if (count($arguments) < 4) { + throw TooFewArguments::create(4); + } + + $entityManager = $this->entityManager; + + $entityType = $arguments[0]; + $id = $arguments[1]; + $link = $arguments[2]; + $limit = $arguments[3]; + + $orderBy = null; + $order = null; + + if (count($arguments) > 4) { + $orderBy = $arguments[4]; + } + + if (count($arguments) > 5) { + $order = $arguments[5]; + } + + if (!$entityType || !is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!$link || !is_string($link)) { + throw BadArgumentType::create(3, 'string'); + } + + if (!is_int($limit)) { + throw BadArgumentType::create(4, 'int'); + } + + if ($orderBy !== null && !is_string($orderBy)) { + throw BadArgumentType::create(5, 'string|null'); + } + + if ($order !== null && !is_string($order) && !is_bool($order)) { + throw BadArgumentType::create(6, 'string|bool|null'); + } + + $this->assertLinkRead($entityType, $link); + + $entity = $entityManager->getEntityById($entityType, $id); + + if (!$entity) { + return []; + } + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + $relationType = $entity->getRelationType($link); + + if ( + in_array($relationType, [ + RelationType::BELONGS_TO, + RelationType::HAS_ONE, + RelationType::BELONGS_TO_PARENT, + ]) + ) { + throw new NotAllowedUsage("Not supported link type '$relationType'."); + } + + $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY); + + if (!$foreignEntityType) { + throw new NotAllowedUsage("Bad or not supported link '$link'."); + } + + $foreignLink = $entity->getRelationParam($link, RelationParam::FOREIGN); + + if (!$foreignLink) { + throw new NotAllowedUsage("Not supported link '$link'."); + } + + $builder = $this->selectBuilderFactory + ->create() + ->withPrimaryFilter(All::NAME) + ->from($foreignEntityType); + + $this->findQueryUtil->applyOrder($builder, $orderBy, $order, 6); + + $whereClause = []; + + if (count($arguments) <= 7) { + $filter = null; + if (count($arguments) == 7) { + $filter = $arguments[6]; + } + + $this->findQueryUtil->applyFilter($builder, $filter, 7); + } else { + $i = 6; + + while ($i < count($arguments) - 1) { + $key = $arguments[$i]; + $value = $arguments[$i + 1]; + + $this->findQueryUtil->assertWhereClauseKeyValid($entityType, $key); + + $whereClause[] = [$key => $value]; + + $i = $i + 2; + } + } + + try { + $queryBuilder = $builder->buildQueryBuilder(); + } catch (BadRequest|Forbidden $e) { + throw new NotAllowedUsage($e->getMessage(), 0, $e); + } + + if (!empty($whereClause)) { + $queryBuilder->where($whereClause); + } + + if ($relationType === RelationType::HAS_CHILDREN) { + $queryBuilder->where([ + $foreignLink . 'Id' => $entity->getId(), + $foreignLink . 'Type' => $entity->getEntityType(), + ]); + } else { + $alias = $foreignLink . $this->generateRandomString(); + + $queryBuilder + ->join($foreignLink, $alias) + ->where([ + $alias . '.id' => $entity->getId(), + ]); + } + + $queryBuilder->limit(0, $limit); + + $collection = $entityManager + ->getRDBRepository($foreignEntityType) + ->clone($queryBuilder->build()) + ->select([Attribute::ID]) + ->find(); + + $idList = []; + + foreach ($collection as $e) { + $idList[] = $e->getId(); + } + + return $idList; + } + + private function generateRandomString(): string + { + return $this->randomStringGenerator->generate(); + } + + /** + * @throws NotAllowedUsage + */ + private function assertLinkRead(string $entityType, string $link): void + { + if (!$this->systemRestriction->checkLinkRead($entityType, $link) ) { + throw new NotAllowedUsage("Cannot read restricted link $entityType.$link."); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/FindRelatedOneType.php b/application/Espo/Core/Formula/Functions/RecordGroup/FindRelatedOneType.php new file mode 100644 index 0000000..1b480bc --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/FindRelatedOneType.php @@ -0,0 +1,220 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Select\Primary\Filters\All; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\Core\Formula\Functions\RecordGroup\Util\FindQueryUtil; +use Espo\ORM\Type\RelationType; + +/** + * @noinspection PhpUnused + */ +class FindRelatedOneType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private SelectBuilderFactory $selectBuilderFactory, + private FindQueryUtil $findQueryUtil, + private SystemRestriction $systemRestriction, + ) {} + + public function process(EvaluatedArgumentList $arguments): ?string + { + if (count($arguments) < 3) { + throw TooFewArguments::create(3); + } + + $entityManager = $this->entityManager; + + $entityType = $arguments[0]; + $id = $arguments[1]; + $link = $arguments[2]; + + $orderBy = null; + $order = null; + + if (count($arguments) > 3) { + $orderBy = $arguments[3]; + } + + if (count($arguments) > 4) { + $order = $arguments[4]; + } + + if (!$entityType) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($link)) { + throw BadArgumentType::create(3, 'string'); + } + + if ($orderBy !== null && !is_string($orderBy)) { + throw BadArgumentType::create(4, 'string|null'); + } + + if ($order !== null && !is_string($order) && !is_bool($order)) { + throw BadArgumentType::create(5, 'string|bool|null'); + } + + $this->assertLinkRead($entityType, $link); + + $entity = $entityManager->getEntityById($entityType, $id); + + if (!$entity) { + return null; + } + + if (!$entity instanceof CoreEntity) { + throw new Error("Non-core entity."); + } + + $relationType = $entity->getRelationType($link); + + if ( + in_array($relationType, [ + RelationType::BELONGS_TO, + RelationType::HAS_ONE, + RelationType::BELONGS_TO_PARENT, + ]) + ) { + $relatedEntity = $entityManager + ->getRDBRepository($entityType) + ->getRelation($entity, $link) + ->select([Attribute::ID]) + ->findOne(); + + return $relatedEntity?->getId(); + } + + $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY); + + if (!$foreignEntityType) { + throw new NotAllowedUsage("Bad or not supported link '$link'."); + } + + $foreignLink = $entity->getRelationParam($link, RelationParam::FOREIGN); + + if (!$foreignLink) { + throw new NotAllowedUsage("Not supported link '$link'."); + } + + $builder = $this->selectBuilderFactory + ->create() + ->withPrimaryFilter(All::NAME) + ->from($foreignEntityType); + + $this->findQueryUtil->applyOrder($builder, $orderBy, $order, 5); + + $whereClause = []; + + if (count($arguments) <= 6) { + $filter = null; + + if (count($arguments) === 6) { + $filter = $arguments[5]; + } + + $this->findQueryUtil->applyFilter($builder, $filter, 6); + } else { + $i = 5; + + while ($i < count($arguments) - 1) { + $key = $arguments[$i]; + $value = $arguments[$i + 1]; + + $this->findQueryUtil->assertWhereClauseKeyValid($entityType, $key); + + $whereClause[] = [$key => $value]; + + $i = $i + 2; + } + } + + try { + $queryBuilder = $builder->buildQueryBuilder(); + } catch (BadRequest|Forbidden $e) { + throw new NotAllowedUsage($e->getMessage(), 0, $e); + } + + if (!empty($whereClause)) { + $queryBuilder->where($whereClause); + } + + if ($relationType === RelationType::HAS_CHILDREN) { + $queryBuilder->where([ + $foreignLink . 'Id' => $entity->getId(), + $foreignLink . 'Type' => $entity->getEntityType(), + ]); + } else { + $queryBuilder + ->join($foreignLink) + ->where([ + $foreignLink . '.' . Attribute::ID => $entity->getId(), + ]); + } + + $relatedEntity = $entityManager + ->getRDBRepository($foreignEntityType) + ->clone($queryBuilder->build()) + ->select([Attribute::ID]) + ->findOne(); + + return $relatedEntity?->getId(); + } + + /** + * @throws NotAllowedUsage + */ + private function assertLinkRead(string $entityType, string $link): void + { + if (!$this->systemRestriction->checkLinkRead($entityType, $link) ) { + throw new NotAllowedUsage("Cannot read restricted link $entityType.$link."); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/RelateType.php b/application/Espo/Core/Formula/Functions/RecordGroup/RelateType.php new file mode 100644 index 0000000..41e305d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/RelateType.php @@ -0,0 +1,134 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Di; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class RelateType extends BaseFunction implements + Di\EntityManagerAware, + Di\InjectableFactoryAware +{ + use Di\EntityManagerSetter; + use Di\InjectableFactorySetter; + + public function process(ArgumentList $args) + { + if (count($args) < 4) { + $this->throwTooFewArguments(4); + } + + $entityType = $this->evaluate($args[0]); + $id = $this->evaluate($args[1]); + $link = $this->evaluate($args[2]); + $foreignId = $this->evaluate($args[3]); + $columnData = count($args) > 4 ? $this->evaluate($args[4]) : null; + + if (!$entityType || !is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!$link || !is_string($link)) { + throw BadArgumentType::create(3, 'string'); + } + + if ($columnData !== null && !$columnData instanceof stdClass) { + throw BadArgumentType::create(4, 'object'); + } + + $this->assertLinkWrite($entityType, $link); + + if ($columnData instanceof stdClass) { + $columnData = get_object_vars($columnData); + } + + if (!$foreignId) { + return null; + } + + $em = $this->entityManager; + + if (!$em->hasRepository($entityType)) { + $this->throwError("Repository does not exist."); + } + + $entity = $em->getEntityById($entityType, $id); + + if (!$entity) { + return null; + } + + $relation = $em->getRDBRepository($entityType)->getRelation($entity, $link); + + if (is_array($foreignId)) { + foreach ($foreignId as $itemId) { + $relation->relateById($itemId, $columnData); + } + + return true; + } + + if (!is_string($foreignId)) { + $this->throwError("foreignId type is wrong."); + } + + $relation->relateById($foreignId, $columnData); + + return true; + } + + /** + * @throws NotAllowedUsage + */ + private function assertLinkWrite(string $entityType, string $link): void + { + $restriction = $this->injectableFactory->create(SystemRestriction::class); + + if (!$restriction->checkEntityTypeWrite($entityType)) { + throw new NotAllowedUsage("Cannot write '$entityType'."); + } + + if (!$restriction->checkLinkWrite($entityType, $link) ) { + throw new NotAllowedUsage("Cannot write restricted link $entityType.$link."); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/RelationColumnType.php b/application/Espo/Core/Formula/Functions/RecordGroup/RelationColumnType.php new file mode 100644 index 0000000..84aa6d1 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/RelationColumnType.php @@ -0,0 +1,113 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Functions\BaseFunction; + +use Espo\Core\Di; + +/** + * @noinspection PhpUnused + */ +class RelationColumnType extends BaseFunction implements + Di\EntityManagerAware, + Di\InjectableFactoryAware +{ + use Di\EntityManagerSetter; + use Di\InjectableFactorySetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 5) { + $this->throwTooFewArguments(5); + } + + $entityType = $args[0]; + $id = $args[1]; + $link = $args[2]; + $foreignId = $args[3]; + $column = $args[4]; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($link)) { + throw BadArgumentType::create(3, 'string'); + } + + if (!is_string($foreignId)) { + throw BadArgumentType::create(4, 'string'); + } + + if (!is_string($column)) { + throw BadArgumentType::create(5, 'string'); + } + + $this->assertLinkRead($entityType, $link); + + $em = $this->entityManager; + + if (!$em->hasRepository($entityType)) { + $this->throwError("Repository '$entityType' does not exist."); + } + + $entity = $em->getEntityById($entityType, $id); + + if (!$entity) { + return null; + } + + return $em->getRelation($entity, $link) + ->getColumnById($foreignId, $column); + } + + /** + * @throws NotAllowedUsage + */ + private function assertLinkRead(string $entityType, string $link): void + { + $restriction = $this->injectableFactory->create(SystemRestriction::class); + + if (!$restriction->checkLinkRead($entityType, $link) ) { + throw new NotAllowedUsage("Cannot read restricted link $entityType.$link."); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/UnrelateType.php b/application/Espo/Core/Formula/Functions/RecordGroup/UnrelateType.php new file mode 100644 index 0000000..3bd019d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/UnrelateType.php @@ -0,0 +1,110 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Functions\BaseFunction; + +use Espo\Core\Di; + +class UnrelateType extends BaseFunction implements + Di\EntityManagerAware, + Di\InjectableFactoryAware +{ + use Di\EntityManagerSetter; + use Di\InjectableFactorySetter; + + public function process(ArgumentList $args) + { + if (count($args) < 4) { + $this->throwTooFewArguments(4); + } + + $entityType = $this->evaluate($args[0]); + $id = $this->evaluate($args[1]); + $link = $this->evaluate($args[2]); + $foreignId = $this->evaluate($args[3]); + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($link)) { + throw BadArgumentType::create(3, 'string'); + } + + if (!is_string($foreignId)) { + throw BadArgumentType::create(4, 'string'); + } + + $this->assertLinkWrite($entityType, $link); + + $em = $this->entityManager; + + if (!$em->hasRepository($entityType)) { + $this->throwError("Repository does not exist."); + } + + $entity = $em->getEntityById($entityType, $id); + + if (!$entity) { + return null; + } + + $em->getRDBRepository($entityType) + ->getRelation($entity, $link) + ->unrelateById($foreignId); + + return true; + } + + /** + * @throws NotAllowedUsage + */ + private function assertLinkWrite(string $entityType, string $link): void + { + $restriction = $this->injectableFactory->create(SystemRestriction::class); + + if (!$restriction->checkEntityTypeWrite($entityType)) { + throw new NotAllowedUsage("Cannot write '$entityType'."); + } + + if (!$restriction->checkLinkWrite($entityType, $link) ) { + throw new NotAllowedUsage("Cannot write restricted link $entityType.$link."); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/UpdateRelationColumnType.php b/application/Espo/Core/Formula/Functions/RecordGroup/UpdateRelationColumnType.php new file mode 100644 index 0000000..986eece --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/UpdateRelationColumnType.php @@ -0,0 +1,122 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Functions\BaseFunction; + +use Espo\Core\Di; + +class UpdateRelationColumnType extends BaseFunction implements + Di\EntityManagerAware, + Di\InjectableFactoryAware +{ + use Di\EntityManagerSetter; + use Di\InjectableFactorySetter; + + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 6) { + $this->throwTooFewArguments(6); + } + + $entityType = $args[0]; + $id = $args[1]; + $link = $args[2]; + $foreignId = $args[3]; + $column = $args[4]; + $value = $args[5]; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + if (!is_string($link)) { + throw BadArgumentType::create(3, 'string'); + } + + if (!is_string($foreignId)) { + throw BadArgumentType::create(4, 'string'); + } + + if (!is_string($column)) { + throw BadArgumentType::create(5, 'string'); + } + + $this->assertLinkWrite($entityType, $link); + + if (!$column) { + $this->throwError("Empty column."); + } + + $em = $this->entityManager; + + if (!$em->hasRepository($entityType)) { + $this->throwError("Repository does not exist."); + } + + $entity = $em->getEntityById($entityType, $id); + + if (!$entity) { + return null; + } + + $em->getRDBRepository($entityType) + ->getRelation($entity, $link) + ->updateColumnsById($foreignId, [$column => $value]); + + return true; + } + + /** + * @throws NotAllowedUsage + */ + private function assertLinkWrite(string $entityType, string $link): void + { + $restriction = $this->injectableFactory->create(SystemRestriction::class); + + if (!$restriction->checkEntityTypeWrite($entityType)) { + throw new NotAllowedUsage("Cannot write '$entityType'."); + } + + if (!$restriction->checkLinkWrite($entityType, $link) ) { + throw new NotAllowedUsage("Cannot write restricted link $entityType.$link."); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/UpdateType.php b/application/Espo/Core/Formula/Functions/RecordGroup/UpdateType.php new file mode 100644 index 0000000..7150ae6 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/UpdateType.php @@ -0,0 +1,140 @@ +. + * + * 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\Formula\Functions\RecordGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; +use Espo\Core\Formula\Utils\EntityUtil; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class UpdateType implements Func +{ + public function __construct( + private EntityManager $entityManager, + private EntityUtil $entityUtil, + ) {} + + public function process(EvaluatedArgumentList $arguments): bool + { + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $entityType = $arguments[0]; + $id = $arguments[1]; + + if (!is_string($entityType)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($id)) { + throw BadArgumentType::create(2, 'string'); + } + + $data = $this->getData($arguments, $entityType); + + $notAllowedAttributes = array_intersect( + array_keys($data), + $this->entityUtil->getWriteRestrictedAttributeList($entityType), + ); + + $notAllowedAttributes = array_values($notAllowedAttributes); + + if ($notAllowedAttributes) { + throw new NotAllowedUsage("Cannot write $entityType.$notAllowedAttributes[0]."); + } + + $entity = $this->entityManager->getEntityById($entityType, $id); + + if (!$entity) { + return false; + } + + $entity->setMultiple($data); + + $this->entityUtil->assertUpdateAccess($entity); + + $this->entityManager->saveEntity($entity); + + return true; + } + + /** + * @return array + * @throws BadArgumentType + */ + private function getData(EvaluatedArgumentList $args, mixed $entityType): array + { + if (count($args) >= 3 && $args[2] instanceof stdClass) { + return $this->filterData(get_object_vars($args[2])); + } + + $data = []; + + $i = 2; + + while ($i < count($args) - 1) { + $attribute = $args[$i]; + + if (!is_string($entityType)) { + throw BadArgumentType::create($i + 1, 'string'); + } + + /** @var string $attribute */ + + $value = $args[$i + 1]; + + $data[$attribute] = $value; + + $i = $i + 2; + } + + return $this->filterData($data); + } + + /** + * @param array $data + * @return array + */ + private function filterData(array $data): array + { + unset($data[Attribute::ID]); + + return $data; + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordGroup/Util/FindQueryUtil.php b/application/Espo/Core/Formula/Functions/RecordGroup/Util/FindQueryUtil.php new file mode 100644 index 0000000..0913c3d --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordGroup/Util/FindQueryUtil.php @@ -0,0 +1,146 @@ +. + * + * 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\Formula\Functions\RecordGroup\Util; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\BadArgumentValue; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Select\SearchParams; +use Espo\Core\Select\SelectBuilder; +use Espo\Core\Select\Where\Item; +use Espo\Core\Utils\Json; +use Espo\ORM\Query\Part\Order; +use Espo\ORM\QueryComposer\Util; +use InvalidArgumentException; +use stdClass; + +class FindQueryUtil +{ + public function __construct( + private SystemRestriction $systemRestriction, + ) {} + + /** + * @throws Error + * @throws BadArgumentType + */ + public function applyFilter(SelectBuilder $builder, mixed $filter, int $position): void + { + if (is_string($filter)) { + $builder->withPrimaryFilter($filter); + + return; + } + + if ($filter instanceof stdClass) { + $rawWhere = Json::decode(Json::encode($filter), true); + + try { + $where = Item::fromRaw($rawWhere); + } catch (InvalidArgumentException $e) { + throw new Error($e->getMessage(), 0, $e); + } + + $builder->withWhere($where); + + return; + } + + if ($filter) { + throw BadArgumentType::create($position, 'string|object'); + } + } + + /** + * @throws BadArgumentValue + */ + public function applyOrder( + SelectBuilder $builder, + ?string $orderBy, + string|bool|null $order, + int $orderPosition, + ): void { + + if (is_bool($order)) { + $order = $order ? Order::DESC : Order::ASC; + } + + if (is_string($order)) { + $order = strtoupper($order); + + if ($order !== Order::ASC && $order !== Order::DESC) { + throw BadArgumentValue::create($orderPosition, "Order must be 'ASC'|'DESC'|bool|null."); + } + } + + if ($orderBy) { + $builder->withSearchParams( + SearchParams::create() + ->withOrderBy($orderBy) + ->withOrder($order) + ); + + return; + } + + if ($order !== null) { + $builder->withSearchParams( + SearchParams::create() + ->withOrder($order) + ); + } + + $builder->withDefaultOrder(); + } + + /** + * @throws NotAllowedUsage + */ + public function assertWhereClauseKeyValid(string $entityType, string $key): void + { + try { + [$expression] = Util::splitWhereKeyThrowing($key); + } catch (InvalidArgumentException) { + throw new NotAllowedUsage("Not allowed where key expression '$key'"); + } + + if (Util::isComplexExpression($expression)) { + throw new NotAllowedUsage("Not allowed expression is where key '$key'"); + } + + $attribute = $expression; + + if (!$this->systemRestriction->checkAttributeRead($entityType, $attribute)) { + throw new NotAllowedUsage("Cannot use restricted attribute in where key '$key'."); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordServiceGroup/SkipDuplicateCheckType.php b/application/Espo/Core/Formula/Functions/RecordServiceGroup/SkipDuplicateCheckType.php new file mode 100644 index 0000000..0fff854 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordServiceGroup/SkipDuplicateCheckType.php @@ -0,0 +1,47 @@ +. + * + * 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\Formula\Functions\RecordServiceGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +class SkipDuplicateCheckType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (empty($this->getVariables()->__isRecordService)) { + $this->throwError("Can be called only from API script."); + } + + $skipDuplicateCheck = $this->getVariables()->__skipDuplicateCheck ?? false; + + return (bool) $skipDuplicateCheck; + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowBadRequestType.php b/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowBadRequestType.php new file mode 100644 index 0000000..ed5cd20 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowBadRequestType.php @@ -0,0 +1,63 @@ +. + * + * 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\Formula\Functions\RecordServiceGroup; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\WrapperException; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Utils\Json; + +/** + * @noinspection PhpUnused + */ +class ThrowBadRequestType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (empty($this->getVariables()->__isRecordService)) { + $this->throwError("Can be called only from API script."); + } + + $message = isset($args[0]) ? $this->evaluate($args[0]) : ''; + $body = isset($args[1]) ? $this->evaluate($args[1]) : null; + + if ($body !== null) { + throw WrapperException::create( + BadRequest::createWithBody($message, Json::encode($body)) + ); + } + + throw WrapperException::create( + BadRequest::createWithBody($message, Body::create()->withMessage($message)) + ); + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowConflictType.php b/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowConflictType.php new file mode 100644 index 0000000..52d3d31 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowConflictType.php @@ -0,0 +1,63 @@ +. + * + * 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\Formula\Functions\RecordServiceGroup; + +use Espo\Core\Exceptions\ConflictSilent; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\WrapperException; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Utils\Json; + +/** + * @noinspection PhpUnused + */ +class ThrowConflictType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (empty($this->getVariables()->__isRecordService)) { + $this->throwError("Can be called only from API script."); + } + + $message = isset($args[0]) ? $this->evaluate($args[0]) : ''; + $body = isset($args[1]) ? $this->evaluate($args[1]) : null; + + if ($body !== null) { + throw WrapperException::create( + ConflictSilent::createWithBody($message, Json::encode($body)) + ); + } + + throw WrapperException::create( + ConflictSilent::createWithBody($message, Body::create()->withMessage($message)) + ); + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowDuplicateConflictType.php b/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowDuplicateConflictType.php new file mode 100644 index 0000000..cf6ba47 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowDuplicateConflictType.php @@ -0,0 +1,96 @@ +. + * + * 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\Formula\Functions\RecordServiceGroup; + +use Espo\Core\Di\EntityManagerAware; +use Espo\Core\Di\EntityManagerSetter; +use Espo\Core\Di\RecordServiceContainerAware; +use Espo\Core\Di\RecordServiceContainerSetter; +use Espo\Core\Exceptions\ConflictSilent; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\WrapperException; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Name\Field; +use Espo\Core\Utils\Json; + +/** + * @noinspection PhpUnused + */ +class ThrowDuplicateConflictType extends BaseFunction implements + EntityManagerAware, + RecordServiceContainerAware +{ + use EntityManagerSetter; + use RecordServiceContainerSetter; + + public function process(ArgumentList $args) + { + if (empty($this->getVariables()->__isRecordService)) { + $this->throwError("Can be called only from API script."); + } + + if (count($args) < 1) { + $this->throwTooFewArguments(1); + } + + $ids = $this->evaluate($args[0]); + + if (is_string($ids)) { + $ids = [$ids]; + } + + if (!is_array($ids)) { + $this->throwBadArgumentType(1); + } + + $entityType = $this->getEntity()->getEntityType(); + + $list = []; + + foreach ($ids as $id) { + $entity = $this->entityManager->getEntityById($entityType, $id); + + if ($entity) { + $this->recordServiceContainer->get($entityType)->prepareEntityForOutput($entity); + } + + if (!$entity) { + $entity = $this->entityManager->getNewEntity($entityType); + $entity->set(Field::NAME, $id); + } + + $list[] = $entity->getValueMap(); + } + + throw WrapperException::create( + ConflictSilent::createWithBody('duplicate', Json::encode($list)) + ); + } +} diff --git a/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowForbiddenType.php b/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowForbiddenType.php new file mode 100644 index 0000000..ed2aa92 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/RecordServiceGroup/ThrowForbiddenType.php @@ -0,0 +1,64 @@ +. + * + * 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\Formula\Functions\RecordServiceGroup; + +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Exceptions\ForbiddenSilent; +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\WrapperException; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Utils\Json; + +/** + * @noinspection PhpUnused + */ +class ThrowForbiddenType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (empty($this->getVariables()->__isRecordService)) { + $this->throwError("Can be called only from API script."); + } + + $message = isset($args[0]) ? $this->evaluate($args[0]) : ''; + $body = isset($args[1]) ? $this->evaluate($args[1]) : null; + + if ($body !== null) { + throw WrapperException::create( + ForbiddenSilent::createWithBody($message, Json::encode($body)) + ); + + } + + throw WrapperException::create( + ForbiddenSilent::createWithBody($message, Body::create()->withMessage($message)) + ); + } +} diff --git a/application/Espo/Core/Formula/Functions/SetAttributeType.php b/application/Espo/Core/Formula/Functions/SetAttributeType.php new file mode 100644 index 0000000..6d1ad88 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/SetAttributeType.php @@ -0,0 +1,95 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Processor; +use Espo\Core\Formula\Utils\EntityUtil; +use Espo\ORM\Entity; +use Espo\ORM\Name\Attribute; +use stdClass; + +class SetAttributeType extends Base +{ + public function __construct( + private EntityUtil $entityUtil, + string $name, + Processor $processor, + ?Entity $entity = null, + ?stdClass $variables = null, + ) { + parent::__construct( + name: $name, + processor: $processor, + entity: $entity, + variables: $variables, + ); + } + + /** + * @return mixed + * @throws Error + */ + public function process(stdClass $item) + { + if (count($item->value) < 2) { + throw TooFewArguments::create(2); + } + + $attribute = $this->evaluate($item->value[0]); + + if (!is_string($attribute)) { + throw BadArgumentType::create(1, 'string'); + } + + if ($attribute === Attribute::ID) { + throw new NotAllowedUsage("Not allowed to set `id` attribute."); + } + + $value = $this->evaluate($item->value[1]); + + $entity = $this->getEntity(); + + $entityType = $entity->getEntityType(); + + if (in_array($attribute, $this->entityUtil->getWriteRestrictedAttributeList($entityType))) { + throw new NotAllowedUsage("Cannot write $entityType.$attribute."); + } + + $entity->set($attribute, $value); + + $this->entityUtil->assertUpdateAccess($entity); + + return $value; + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/ConcatenateType.php b/application/Espo/Core/Formula/Functions/StringGroup/ConcatenateType.php new file mode 100644 index 0000000..38402e8 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/ConcatenateType.php @@ -0,0 +1,34 @@ +. + * + * 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\Formula\Functions\StringGroup; + +class ConcatenateType extends ConcatenationType +{ +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/ConcatenationType.php b/application/Espo/Core/Formula/Functions/StringGroup/ConcatenationType.php new file mode 100644 index 0000000..dbe7de6 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/ConcatenationType.php @@ -0,0 +1,55 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class ConcatenationType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $result = ''; + + foreach ($args as $subItem) { + $part = $this->evaluate($subItem); + + if (!is_string($part)) { + $part = strval($part); + } + + $result .= $part; + } + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/ContainsType.php b/application/Espo/Core/Formula/Functions/StringGroup/ContainsType.php new file mode 100644 index 0000000..1c2a34c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/ContainsType.php @@ -0,0 +1,53 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; + +class ContainsType implements Func +{ + public function process(EvaluatedArgumentList $arguments): bool + { + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $string = $arguments[0]; + $needle = $arguments[1]; + + if (!is_string($string)) { + return false; + } + + return mb_strpos($string, $needle) !== false; + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/LengthType.php b/application/Espo/Core/Formula/Functions/StringGroup/LengthType.php new file mode 100644 index 0000000..499b718 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/LengthType.php @@ -0,0 +1,53 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class LengthType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $this->evaluate($args[0]); + + if (!is_string($value)) { + $value = strval($value); + } + + return mb_strlen($value); + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/LowerCaseType.php b/application/Espo/Core/Formula/Functions/StringGroup/LowerCaseType.php new file mode 100644 index 0000000..1fa1cf3 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/LowerCaseType.php @@ -0,0 +1,53 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class LowerCaseType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $this->evaluate($args[0]); + + if (!is_string($value)) { + $value = strval($value); + } + + return mb_strtolower($value); + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/MatchAllType.php b/application/Espo/Core/Formula/Functions/StringGroup/MatchAllType.php new file mode 100644 index 0000000..72b31e0 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/MatchAllType.php @@ -0,0 +1,70 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +class MatchAllType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $string = $args[0]; + $regexp = $args[1]; + + if (!is_string($string)) { + return null; + } + + if (!is_string($regexp)) { + return null; + } + + $offset = $args[2] ?? 0; + + $result = preg_match_all($regexp, $string, $matches, \PREG_PATTERN_ORDER, $offset); + + if (!$result) { + return null; + } + + if (!count($matches)) { + return null; + } + + return $matches[0]; + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/MatchExtractType.php b/application/Espo/Core/Formula/Functions/StringGroup/MatchExtractType.php new file mode 100644 index 0000000..c7953ca --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/MatchExtractType.php @@ -0,0 +1,68 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; + +class MatchExtractType implements Func +{ + /** + * {@inheritDoc} + * @return ?string[] + */ + public function process(EvaluatedArgumentList $arguments): ?array + { + if (count($arguments) < 2) { + throw TooFewArguments::create(2); + } + + $string = $arguments[0]; + $pattern = $arguments[1]; + + if (!is_string($string)) { + throw BadArgumentType::create(1, 'string'); + } + + if (!is_string($pattern)) { + throw BadArgumentType::create(2, 'string'); + } + + $result = preg_match($pattern, $string, $matches); + + if (!$result) { + return null; + } + + return array_slice($matches, 1); + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/MatchType.php b/application/Espo/Core/Formula/Functions/StringGroup/MatchType.php new file mode 100644 index 0000000..a11be00 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/MatchType.php @@ -0,0 +1,72 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class MatchType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $string = $args[0]; + $regexp = $args[1]; + + if (!is_string($string)) { + return null; + } + + if (!is_string($regexp)) { + return null; + } + + $offset = $args[2] ?? 0; + + $result = preg_match($regexp, $string, $matches, 0, $offset); + + if (!$result) { + return null; + } + + if (!count($matches)) { + return null; + } + + return $matches[0]; + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/PadType.php b/application/Espo/Core/Formula/Functions/StringGroup/PadType.php new file mode 100644 index 0000000..d606e89 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/PadType.php @@ -0,0 +1,70 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class PadType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $input = $args[0]; + $length = $args[1]; + $string = $args[2] ?? ' '; + $type = $args[3] ?? 'right'; + + if (!is_string($input)) { + $input = strval($input); + } + + if (!is_int($length)) { + $this->throwBadArgumentType(2); + } + + $map = [ + 'right' => \STR_PAD_RIGHT, + 'left' => \STR_PAD_LEFT, + 'both' => \STR_PAD_BOTH, + ]; + + $padType = $map[$type] ?? \STR_PAD_RIGHT; + + return mb_str_pad($input, $length, $string, $padType); + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/PosType.php b/application/Espo/Core/Formula/Functions/StringGroup/PosType.php new file mode 100644 index 0000000..3d4407c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/PosType.php @@ -0,0 +1,56 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class PosType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $string = $args[0]; + $needle = $args[1]; + + if (!is_string($string)) { + return false; + } + + return mb_strpos($string, $needle); + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/ReplaceType.php b/application/Espo/Core/Formula/Functions/StringGroup/ReplaceType.php new file mode 100644 index 0000000..816c1cb --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/ReplaceType.php @@ -0,0 +1,68 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class ReplaceType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 3) { + $this->throwTooFewArguments(); + }; + + $string = $args[0]; + $search = $args[1]; + $replace = $args[2]; + + if (!is_string($string)) { + $this->logBadArgumentType(1, 'string'); + return ''; + } + + if (!is_string($search)) { + $this->logBadArgumentType(2, 'string'); + return $string; + } + + if (!is_string($replace)) { + $this->logBadArgumentType(3, 'string'); + return $string; + } + + return str_replace($search, $replace, $string); + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/SplitType.php b/application/Espo/Core/Formula/Functions/StringGroup/SplitType.php new file mode 100644 index 0000000..c32f504 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/SplitType.php @@ -0,0 +1,68 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\ArgumentList; + +class SplitType extends BaseFunction +{ + /** + * @return string[] + * @throws \Espo\Core\Formula\Exceptions\TooFewArguments + * @throws \Espo\Core\Formula\Exceptions\BadArgumentType + * @throws \Espo\Core\Formula\Exceptions\Error + */ + public function process(ArgumentList $args) + { + $evaluatedArgs = $this->evaluate($args); + + if (count($evaluatedArgs) < 2) { + $this->throwTooFewArguments(2); + } + + $string = $evaluatedArgs[0] ?? ''; + $separator = $evaluatedArgs[1]; + + if (!is_string($string)) { + $this->throwBadArgumentType(1, 'string'); + } + + if (!is_string($separator)) { + $this->throwBadArgumentType(2, 'string'); + } + + if ($separator === '') { + return mb_str_split($string); + } + + return explode($separator, $string); + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/SubstringType.php b/application/Espo/Core/Formula/Functions/StringGroup/SubstringType.php new file mode 100644 index 0000000..03636b9 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/SubstringType.php @@ -0,0 +1,57 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class SubstringType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $string = $args[0]; + $start = $args[1]; + + if (count($args) > 2) { + $length = $args[2]; + return mb_substr($string, $start, $length); + } else { + return mb_substr($string, $start); + } + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/TestType.php b/application/Espo/Core/Formula/Functions/StringGroup/TestType.php new file mode 100644 index 0000000..90e2a72 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/TestType.php @@ -0,0 +1,59 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class TestType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $string = $args[0]; + $regexp = $args[1]; + + if (!is_string($string)) { + return false; + } + if (!is_string($regexp)) { + return false; + } + + return !!preg_match($regexp, $string); + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/TrimType.php b/application/Espo/Core/Formula/Functions/StringGroup/TrimType.php new file mode 100644 index 0000000..635ffe1 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/TrimType.php @@ -0,0 +1,66 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Functions\BaseFunction; + +/** + * @noinspection PhpUnused + */ +class TrimType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $args[0]; + + if (!is_string($value)) { + $value = strval($value); + } + + return self::trim($value); + } + + private static function trim(string $value): string + { + // @todo Remove check when PHP 8.4 is min supported. + if (function_exists('mb_trim')) { + return mb_trim($value); + } + + return trim($value); + } +} diff --git a/application/Espo/Core/Formula/Functions/StringGroup/UpperCaseType.php b/application/Espo/Core/Formula/Functions/StringGroup/UpperCaseType.php new file mode 100644 index 0000000..dcebcc9 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/StringGroup/UpperCaseType.php @@ -0,0 +1,55 @@ +. + * + * 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\Formula\Functions\StringGroup; + +use Espo\Core\Formula\{ + Functions\BaseFunction, + ArgumentList, +}; + +class UpperCaseType extends BaseFunction +{ + public function process(ArgumentList $args) + { + $args = $this->evaluate($args); + + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $value = $args[0]; + + if (!is_string($value)) { + $value = strval($value); + } + + return mb_strtoupper($value); + } +} diff --git a/application/Espo/Core/Formula/Functions/UtilGroup/Base64DecodeType.php b/application/Espo/Core/Formula/Functions/UtilGroup/Base64DecodeType.php new file mode 100644 index 0000000..1c3e358 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/UtilGroup/Base64DecodeType.php @@ -0,0 +1,60 @@ +. + * + * 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\Formula\Functions\UtilGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\BadArgumentValue; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; + +class Base64DecodeType implements Func +{ + public function process(EvaluatedArgumentList $arguments): string + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $string = $arguments[0]; + + if (!is_string($string)) { + throw BadArgumentType::create(1, 'string'); + } + + $result = base64_decode($string); + + if ($result === false) { + throw BadArgumentValue::create(1); + } + + return $result; + } +} diff --git a/application/Espo/Core/Formula/Functions/UtilGroup/Base64EncodeType.php b/application/Espo/Core/Formula/Functions/UtilGroup/Base64EncodeType.php new file mode 100644 index 0000000..38c179a --- /dev/null +++ b/application/Espo/Core/Formula/Functions/UtilGroup/Base64EncodeType.php @@ -0,0 +1,53 @@ +. + * + * 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\Formula\Functions\UtilGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Func; + +class Base64EncodeType implements Func +{ + public function process(EvaluatedArgumentList $arguments): string + { + if (count($arguments) < 1) { + throw TooFewArguments::create(1); + } + + $string = $arguments[0]; + + if (!is_string($string)) { + throw BadArgumentType::create(1, 'string'); + } + + return base64_encode($string); + } +} diff --git a/application/Espo/Core/Formula/Functions/UtilGroup/GenerateIdType.php b/application/Espo/Core/Formula/Functions/UtilGroup/GenerateIdType.php new file mode 100644 index 0000000..dbfbae5 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/UtilGroup/GenerateIdType.php @@ -0,0 +1,42 @@ +. + * + * 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\Formula\Functions\UtilGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Func; +use Espo\Core\Utils\Util; + +class GenerateIdType implements Func +{ + public function process(EvaluatedArgumentList $arguments): string + { + return Util::generateId(); + } +} diff --git a/application/Espo/Core/Formula/Functions/UtilGroup/GenerateRecordIdType.php b/application/Espo/Core/Formula/Functions/UtilGroup/GenerateRecordIdType.php new file mode 100644 index 0000000..714f244 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/UtilGroup/GenerateRecordIdType.php @@ -0,0 +1,44 @@ +. + * + * 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\Formula\Functions\UtilGroup; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Func; +use Espo\Core\Utils\Id\RecordIdGenerator; + +class GenerateRecordIdType implements Func +{ + public function __construct(private RecordIdGenerator $recordIdGenerator) {} + + public function process(EvaluatedArgumentList $arguments): string + { + return $this->recordIdGenerator->generate(); + } +} diff --git a/application/Espo/Core/Formula/Functions/ValueType.php b/application/Espo/Core/Formula/Functions/ValueType.php new file mode 100644 index 0000000..b00dab2 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/ValueType.php @@ -0,0 +1,45 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\ArgumentList; + +class ValueType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (!count($args)) { + throw new Error("Bad value."); + } + + return $args[0]->getData(); + } +} diff --git a/application/Espo/Core/Formula/Functions/VariableDecrementType.php b/application/Espo/Core/Formula/Functions/VariableDecrementType.php new file mode 100644 index 0000000..2901e9c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/VariableDecrementType.php @@ -0,0 +1,64 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\Error; + +/** + * @noinspection PhpUnused + */ +class VariableDecrementType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $name = $this->evaluate($args[0]); + + if (!is_string($name)) { + $this->throwBadArgumentValue(1, 'string'); + } + + if (!property_exists($this->getVariables(), $name)) { + throw new Error("Cannot decrement non-existing variable."); + } + + $reference =& $this->getVariables()->$name; + + if (!is_float($reference) && !is_int($reference)) { + throw new Error("Cannot decrement non-number variable."); + } + + $reference--; + } +} diff --git a/application/Espo/Core/Formula/Functions/VariableGetValueByKeyType.php b/application/Espo/Core/Formula/Functions/VariableGetValueByKeyType.php new file mode 100644 index 0000000..1b0e97c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/VariableGetValueByKeyType.php @@ -0,0 +1,121 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\UndefinedKey; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class VariableGetValueByKeyType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(); + } + + $name = $this->evaluate($args[0]); + $keys = $this->evaluate($args[1]); + + if (!is_string($name)) { + $this->throwBadArgumentValue(1, 'string'); + } + + if (!property_exists($this->getVariables(), $name)) { + throw new Error("Cannot access by key of non-existing variable."); + } + + $reference =& $this->getVariables()->$name; + + $value = null; + + foreach ($keys as $key) { + $value =& $this->getByKey($reference, $key); + + $reference =& $value; + } + + return $value; + } + + /** + * @throws Error + */ + private function &getByKey(mixed &$reference, mixed $key): mixed + { + if (!is_array($reference) && !$reference instanceof stdClass) { + throw new Error("Cannot access by key of variable that is non-array and non-object."); + } + + if (is_array($reference)) { + if (!is_int($key)) { + throw new Error("Cannot get array item value by non-integer key."); + } + + if ($key < 0) { + throw new UndefinedKey("Cannot get array item value by key that is less than zero."); + } + + if ($key > count($reference) - 1) { + throw new UndefinedKey("Cannot get array item value by key that is out of array end."); + } + + if (!array_key_exists($key, $reference)) { + throw new UndefinedKey("Cannot get array item value by non-existent key."); + } + + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $value =& $reference[$key]; + + return $value; + } + + if (!is_string($key)) { + throw new Error("Cannot get object item value by non-string key."); + } + + if ($key === '') { + throw new Error("Cannot get object item value by empty string key."); + } + + if (!property_exists($reference, $key)) { + throw new UndefinedKey("Cannot get object item value by non-existent key."); + } + + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $value =& $reference->$key; + + return $value; + } +} diff --git a/application/Espo/Core/Formula/Functions/VariableIncrementType.php b/application/Espo/Core/Formula/Functions/VariableIncrementType.php new file mode 100644 index 0000000..5a96c14 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/VariableIncrementType.php @@ -0,0 +1,64 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\Error; + +/** + * @noinspection PhpUnused + */ +class VariableIncrementType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 1) { + $this->throwTooFewArguments(); + } + + $name = $this->evaluate($args[0]); + + if (!is_string($name)) { + $this->throwBadArgumentValue(1, 'string'); + } + + if (!property_exists($this->getVariables(), $name)) { + throw new Error("Cannot increment non-existing variable."); + } + + $reference =& $this->getVariables()->$name; + + if (!is_float($reference) && !is_int($reference)) { + throw new Error("Cannot increment non-number variable."); + } + + $reference++; + } +} diff --git a/application/Espo/Core/Formula/Functions/VariableSetKeyValueType.php b/application/Espo/Core/Formula/Functions/VariableSetKeyValueType.php new file mode 100644 index 0000000..eb555e4 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/VariableSetKeyValueType.php @@ -0,0 +1,161 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\Error; +use stdClass; + +/** + * @noinspection PhpUnused + */ +class VariableSetKeyValueType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 3) { + $this->throwTooFewArguments(); + } + + $name = $this->evaluate($args[0]); + $keys = $this->evaluate($args[1]); + $value = $this->evaluate($args[2]); + + if (!is_string($name)) { + $this->throwBadArgumentValue(1, 'string'); + } + + if (!property_exists($this->getVariables(), $name)) { + throw new Error("Cannot access by key of non-existing variable."); + } + + $reference =& $this->getVariables()->$name; + + foreach ($keys as $i => $key) { + if ($i === count($keys) - 1) { + $this->setByKey($reference, $key, $value); + + return; + } + + $referenceValue =& $this->getByKey($reference, $key); + + $reference =& $referenceValue; + } + } + + /** + * @throws Error + */ + private function setByKey(mixed &$reference, mixed $key, mixed $value): void + { + if (!is_array($reference) && !$reference instanceof stdClass) { + throw new Error("Cannot access by key of variable that is non-array and non-object."); + } + + if (is_array($reference)) { + if (!is_int($key)) { + throw new Error("Cannot set array item value by non-integer key."); + } + + if ($key < 0) { + throw new Error("Cannot set array item value by key that is less than zero."); + } + + if ($key > count($reference)) { + throw new Error("Cannot set array item value by key that is greater than length of array."); + } + + $reference[$key] = $value; + + return; + } + + if (!is_string($key)) { + throw new Error("Cannot set object item value by non-string key."); + } + + if ($key === '') { + throw new Error("Cannot set object item value by empty string key."); + } + + $reference->$key = $value; + } + + /** + * @throws Error + */ + private function &getByKey(mixed &$reference, mixed $key): mixed + { + if (!is_array($reference) && !$reference instanceof stdClass) { + throw new Error("Cannot access by key of variable that is non-array and non-object."); + } + + if (is_array($reference)) { + if (!is_int($key)) { + throw new Error("Cannot get array item value by non-integer key."); + } + + if ($key < 0) { + throw new Error("Cannot get array item value by key that is less than zero."); + } + + if ($key > count($reference) - 1) { + throw new Error("Cannot get array item value by key that is out of array end."); + } + + if (!array_key_exists($key, $reference)) { + throw new Error("Cannot get array item value by non-existent key."); + } + + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $value =& $reference[$key]; + + return $value; + } + + if (!is_string($key)) { + throw new Error("Cannot get object item value by non-string key."); + } + + if ($key === '') { + throw new Error("Cannot get object item value by empty string key."); + } + + if (!property_exists($reference, $key)) { + throw new Error("Cannot get object item value by non-existent key."); + } + + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $value =& $reference->$key; + + return $value; + } +} diff --git a/application/Espo/Core/Formula/Functions/VariableType.php b/application/Espo/Core/Formula/Functions/VariableType.php new file mode 100644 index 0000000..24dcb2c --- /dev/null +++ b/application/Espo/Core/Formula/Functions/VariableType.php @@ -0,0 +1,57 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\EvaluatedArgumentList; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\FuncVariablesAware; +use Espo\Core\Formula\Variables; + +class VariableType implements FuncVariablesAware +{ + public function process(EvaluatedArgumentList $arguments, Variables $variables): mixed + { + if (!count($arguments)) { + throw new Error("No variable name."); + } + + $name = $arguments[0]; + + if (!is_string($name)) { + throw new Error("Bad variable name."); + } + + if ($name === '') { + throw new Error("Empty variable name."); + } + + return $variables->tryGet($name); + } +} diff --git a/application/Espo/Core/Formula/Functions/WhileType.php b/application/Espo/Core/Formula/Functions/WhileType.php new file mode 100644 index 0000000..2582ae1 --- /dev/null +++ b/application/Espo/Core/Formula/Functions/WhileType.php @@ -0,0 +1,54 @@ +. + * + * 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\Formula\Functions; + +use Espo\Core\Formula\ArgumentList; +use Espo\Core\Formula\Exceptions\BreakLoop; +use Espo\Core\Formula\Exceptions\ContinueLoop; + +class WhileType extends BaseFunction +{ + public function process(ArgumentList $args) + { + if (count($args) < 2) { + $this->throwTooFewArguments(2); + } + + while ($this->evaluate($args[0])) { + try { + $this->evaluate($args[1]); + } catch (BreakLoop) { + break; + } catch (ContinueLoop) { + continue; + } + } + } +} diff --git a/application/Espo/Core/Formula/Manager.php b/application/Espo/Core/Formula/Manager.php new file mode 100644 index 0000000..3fb9f8b --- /dev/null +++ b/application/Espo/Core/Formula/Manager.php @@ -0,0 +1,90 @@ +. + * + * 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\Formula; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; + +use stdClass; + +/** + * An access point for the formula functionality. + */ +class Manager +{ + private Evaluator $evaluator; + + public function __construct(InjectableFactory $injectableFactory, Metadata $metadata) + { + $functionClassNameMap = $metadata->get(['app', 'formula', 'functionClassNameMap'], []); + + $unsafeFunctionList = $this->getUnsafeFunctionList($metadata); + + $this->evaluator = new Evaluator($injectableFactory, $functionClassNameMap, $unsafeFunctionList); + } + + /** + * Executes a script and returns its result. + * + * @throws Exceptions\Error + */ + public function run(string $script, ?Entity $entity = null, ?stdClass $variables = null) : mixed + { + return $this->evaluator->process($script, $entity, $variables); + } + + /** + * Executes a script in safe mode and returns its result. + * + * @throws Exceptions\Error + * @since 8.3.0 + * @internal + */ + public function runSafe(string $script, ?Entity $entity = null, ?stdClass $variables = null): mixed + { + return $this->evaluator->processSafe($script, $entity, $variables); + } + + /** + * @return string[] + */ + private function getUnsafeFunctionList(Metadata $metadata): array + { + $unsafeFunctionList = []; + + foreach ($metadata->get("app.formula.functionList") ?? [] as $item) { + if ($item['unsafe'] ?? false) { + $unsafeFunctionList[] = $item['name']; + } + } + return $unsafeFunctionList; + } +} diff --git a/application/Espo/Core/Formula/Parser.php b/application/Espo/Core/Formula/Parser.php new file mode 100644 index 0000000..78bec23 --- /dev/null +++ b/application/Espo/Core/Formula/Parser.php @@ -0,0 +1,1539 @@ +. + * + * 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\Formula; + +use Espo\Core\Formula\Exceptions\SyntaxError; +use Espo\Core\Formula\Parser\Ast\Attribute; +use Espo\Core\Formula\Parser\Ast\Node; +use Espo\Core\Formula\Parser\Ast\Value; +use Espo\Core\Formula\Parser\Ast\Variable; +use Espo\Core\Formula\Parser\Statement\IfRef; +use Espo\Core\Formula\Parser\Statement\StatementRef; + +use Espo\Core\Formula\Parser\Statement\WhileRef; +use LogicException; + +/** + * Parses a formula-script into AST. + */ +class Parser +{ + /** @var array */ + private array $priorityList = [ + ['='], + ['??'], + ['||'], + ['&&'], + ['==', '!=', '>', '<', '>=', '<='], + ['+', '-'], + ['*', '/', '%'], + ]; + + /** @var array */ + private array $operatorMap = [ + '=' => 'assign', + '??' => 'comparison\\nullCoalescing', + '||' => 'logical\\or', + '&&' => 'logical\\and', + '+' => 'numeric\\summation', + '-' => 'numeric\\subtraction', + '*' => 'numeric\\multiplication', + '/' => 'numeric\\division', + '%' => 'numeric\\modulo', + '==' => 'comparison\\equals', + '!=' => 'comparison\\notEquals', + '>' => 'comparison\\greaterThan', + '<' => 'comparison\\lessThan', + '>=' => 'comparison\\greaterThanOrEquals', + '<=' => 'comparison\\lessThanOrEquals', + ]; + + /** @var string[] */ + private array $whiteSpaceCharList = [ + "\r", + "\n", + "\t", + ' ', + ]; + + private string $variableNameRegExp = "/^[a-zA-Z0-9_\$]+$/"; + private string $functionNameRegExp = "/^[a-zA-Z0-9_\\\\]+$/"; + private string $attributeNameRegExp = "/^[a-zA-Z0-9.]+$/"; + + /** + * @throws SyntaxError + */ + public function parse(string $expression): Node|Attribute|Variable|Value + { + return $this->split($expression, true); + } + + /** + * @throws SyntaxError + */ + private function applyOperator(string $operator, string $firstPart, string $secondPart): Node + { + if ($operator === '=') { + if (!strlen($firstPart)) { + throw new SyntaxError("Bad operator usage."); + } + + if ($firstPart[0] == '$') { + return $this->applyOperatorVariableAssign($firstPart, $secondPart); + } + + if ($secondPart === '') { + throw SyntaxError::create("Bad assignment usage."); + } + + return new Node('setAttribute', [ + new Value($firstPart), + $this->split($secondPart) + ]); + } + + $functionName = $this->operatorMap[$operator]; + + if ($functionName === '' || !preg_match($this->functionNameRegExp, $functionName)) { + throw new SyntaxError("Bad function name `$functionName`."); + } + + return new Node($functionName, [ + $this->split($firstPart), + $this->split($secondPart), + ]); + } + + private static function isNotAfterBackslash(string $string, int $i): bool + { + return + ($string[$i - 1] ?? null) !== "\\" || + (($string[$i - 2] ?? null) === "\\" && ($string[$i - 3] ?? null) !== "\\"); + } + + /** + * @param string $string An expression. Comments will be stripped by the method. + * @param string $modifiedString A modified expression with removed parentheses and braces inside strings. + * @param ?((StatementRef|IfRef|WhileRef)[]) $statementList Statements will be added if there are multiple. + * @throws SyntaxError + */ + private function processString( + string &$string, + string &$modifiedString, + ?array &$statementList = null, + bool $intoOneLine = false + ): bool { + + $isString = false; + $isSingleQuote = false; + $isComment = false; + $isLineComment = false; + $parenthesisCounter = 0; + $braceCounter = 0; + $bracketCounter = 0; + + $modifiedString = $string; + + for ($i = 0; $i < strlen($string); $i++) { + $isStringStart = false; + $char = $string[$i]; + $isLast = $i === strlen($string) - 1; + + if (!$isLineComment && !$isComment) { + if ($string[$i] === "'" && self::isNotAfterBackslash($string, $i)) { + if (!$isString) { + $isString = true; + $isStringStart = true; + $isSingleQuote = true; + } else if ($isSingleQuote) { + $isString = false; + } + } else if ($string[$i] === "\"" && self::isNotAfterBackslash($string, $i)) { + if (!$isString) { + $isString = true; + $isStringStart = true; + $isSingleQuote = false; + } else if (!$isSingleQuote) { + $isString = false; + } + } + } + + if ($isString) { + if (in_array($char, ['(', ')', '{', '}', '[', ']'])) { + $modifiedString[$i] = '_'; + } else if (!$isStringStart) { + $modifiedString[$i] = ' '; + } + + continue; + } + + $isLineCommentEnding = $isLineComment && ($string[$i] === "\n" || $isLast); + $isCommentEnding = $isComment && $string[$i] === "*" && $string[$i + 1] === "/"; + + if ($isCommentEnding) { + $string[$i + 1] = ' '; + $modifiedString[$i + 1] = ' '; + } + + if ($isLineComment || $isComment) { + $string[$i] = ' '; + $modifiedString[$i] = ' '; + } + + if (!$isLineComment && !$isComment) { + if (!$isLast && $string[$i] === '/' && $string[$i + 1] === '/') { + $isLineComment = true; + + $string[$i] = ' '; + $string[$i + 1] = ' '; + $modifiedString[$i] = ' '; + $modifiedString[$i + 1] = ' '; + } + + if (!$isLineComment) { + if (!$isLast && $string[$i] === '/' && $string[$i + 1] === '*') { + $isComment = true; + + $string[$i] = ' '; + $string[$i + 1] = ' '; + $modifiedString[$i] = ' '; + $modifiedString[$i + 1] = ' '; + } + } + + if ($char === '(') { + $parenthesisCounter++; + } else if ($char === ')') { + $parenthesisCounter--; + } else if ($char === '{') { + $braceCounter++; + } else if ($char === '}') { + $braceCounter--; + } else if ($char === '[') { + $bracketCounter++; + } else if ($char === ']') { + $bracketCounter--; + } + } + + if ($statementList !== null) { + $this->processStringIteration( + string: $string, + i: $i, + statementList: $statementList, + parenthesisCounter: $parenthesisCounter, + braceCounter: $braceCounter, + bracketCounter: $bracketCounter, + isLineComment: $isLineComment, + isComment: $isComment, + ); + } + + if ($intoOneLine) { + if ( + $parenthesisCounter === 0 && + $this->isWhiteSpace($char) && + $char !== ' ' + ) { + $string[$i] = ' '; + } + } + + if ($isLineCommentEnding) { + $isLineComment = false; + } + + if ($isCommentEnding) { + $isComment = false; + } + + /*if ($isLineComment) { + if ($string[$i] === "\n") { + $isLineComment = false; + } + } + + if ($isComment) { + if ($string[$i - 1] === "*" && $string[$i] === "/") { + $isComment = false; + } + }*/ + } + + if ($statementList !== null) { + $lastStatement = end($statementList); + + if ( + $lastStatement instanceof StatementRef && + count($statementList) === 1 && + !$lastStatement->isEndedWithSemicolon() + ) { + array_pop($statementList); + } else if ( + $lastStatement instanceof StatementRef && + !$lastStatement->isEndedWithSemicolon() + ) { + $lastStatement->setEnd(strlen($string)); + } + } + + return $isString; + } + + /** + * @param (StatementRef|IfRef|WhileRef)[] $statementList + * @throws SyntaxError + */ + private function processStringIteration( + string $string, + int &$i, + array &$statementList, + int $parenthesisCounter, + int $braceCounter, + int $bracketCounter, + bool $isLineComment, + bool $isComment, + ): void { + + $char = $string[$i]; + $isLast = $i === strlen($string) - 1; + + $lastStatement = count($statementList) ? + end($statementList) : null; + + if ( + $lastStatement instanceof StatementRef && + !$lastStatement->isReady() + ) { + if ( + $parenthesisCounter === 0 && + $braceCounter === 0 && + $bracketCounter === 0 + ) { + if ($char === ';') { + $lastStatement->setEnd($i, true); + + return; + } + + if ($isLast) { + $lastStatement->setEnd($i + 1); + + return; + } + } + } + + if ( + $lastStatement instanceof IfRef && + !$lastStatement->isReady() + ) { + $toContinue = $this->processStringIfStatement( + string: $string, + i: $i, + parenthesisCounter: $parenthesisCounter, + braceCounter: $braceCounter, + statement: $lastStatement, + ); + + if ($toContinue) { + return; + } + } + + if ( + $lastStatement instanceof WhileRef && + !$lastStatement->isReady() + ) { + $toContinue = $this->processStringWhileStatement( + $string, + $i, + $parenthesisCounter, + $braceCounter, + $lastStatement + ); + + if ($toContinue === null) { + // Not a `while` statement, but likely a `while` function. + array_pop($statementList); + + $lastStatement = new StatementRef($lastStatement->getStart()); + $statementList[] = $lastStatement; + + if ($char === ';') { + $lastStatement->setEnd($i, true); + + return; + } + } + + if ($toContinue) { + return; + } + } + + if ( + ( + $parenthesisCounter === 0 || + $parenthesisCounter === 1 && $char === '(' + ) && + $braceCounter === 0 && + $bracketCounter === 0 + ) { + if ($isLineComment || $isComment) { + return; + } + + $previousStatementEnd = $lastStatement ? + $lastStatement->getEnd() : + -1; + + if ( + $lastStatement && + !$lastStatement->isReady() + ) { + return; + } + + if ($previousStatementEnd === null) { + throw SyntaxError::create("Incorrect statement usage."); + } + + if ($this->isOnIf($string, $i)) { + $statementList[] = new IfRef(); + + $i += 1; + + return; + } + + if ($this->isOnWhile($string, $i)) { + $statementList[] = new WhileRef($i); + + $i += 4; + + return; + } + + if ( + !$this->isWhiteSpace($char) && + $char !== ';' && + $char !== '/' + ) { + $statementList[] = new StatementRef($i); + } + } + } + + private function processStringIfStatement( + string $string, + int &$i, + int $parenthesisCounter, + int $braceCounter, + IfRef $statement + ): bool { + + $char = $string[$i]; + $isLast = $i === strlen($string) - 1; + + if ( + $char === '(' && + !$isLast && + $parenthesisCounter === 1 && + $braceCounter === 0 && + $statement->getState() === IfRef::STATE_EMPTY + ) { + $statement->setConditionStart($i + 1); + + return true; + } + + if ( + $char === ')' && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $statement->getState() === IfRef::STATE_CONDITION_STARTED + ) { + $statement->setConditionEnd($i); + + return true; + } + + if ( + $statement->getState() === IfRef::STATE_CONDITION_ENDED && + !$isLast && + $parenthesisCounter === 0 && + $braceCounter === 1 && + $char === '{' + ) { + $statement->setThenStart($i + 1); + + return true; + } + + if ( + $statement->getState() === IfRef::STATE_THEN_STARTED && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $char === '}' + ) { + $statement->setThenEnd($i); + + if ($isLast) { + $statement->setReady(); + } + + return true; + } + + if ( + $statement->getState() === IfRef::STATE_THEN_ENDED && + $this->isWhiteSpace($char) && + $isLast + ) { + $statement->setReady(); + + // No need to call continue. + return false; + } + + if ( + $statement->getState() === IfRef::STATE_THEN_ENDED && + !$this->isWhiteSpace($char) && + !$this->isOnElse($string, $i) + ) { + $statement->setReady(); + + // No need to call continue. + return false; + } + + if ( + $statement->getState() === IfRef::STATE_THEN_ENDED && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $this->isOnElse($string, $i) + ) { + $statement->setElseMet($i + 4); + + $i += 3; + + return true; + } + + if ( + $statement->getState() === IfRef::STATE_ELSE_MET && + !$isLast && + $parenthesisCounter === 0 && + $braceCounter === 1 && + $char === '{' + ) { + $statement->setElseStart($i + 1); + + return true; + } + + if ( + $statement->getState() === IfRef::STATE_ELSE_MET && + !$isLast && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $this->isWhiteSpace($string[$i - 1]) && + $this->isOnIf($string, $i) + ) { + $statement->setElseStart($i, true); + + $i += 1; + + return true; + } + + if ( + $statement->getState() === IfRef::STATE_ELSE_STARTED && + $statement->hasInlineElse() && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $char === '}' + ) { + $elseFound = false; + $j = $i + 1; + + while ($j < strlen($string)) { + if ($this->isWhiteSpace($string[$j])) { + $j++; + + continue; + } + + $elseFound = $this->isOnElse($string, $j); + + break; + } + + if (!$elseFound) { + $statement->setElseEnd($i + 1); + $statement->setReady(); + } + + return true; + } + + if ( + $statement->getState() === IfRef::STATE_ELSE_STARTED && + !$statement->hasInlineElse() && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $char === '}' + ) { + $statement->setElseEnd($i); + $statement->setReady(); + + return true; + } + + if ( + $statement->getState() === IfRef::STATE_ELSE_MET && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $char === '}' + ) { + $statement->setElseStart($statement->getElseKeywordEnd() + 1); + $statement->setElseEnd($i + 1); + $statement->setReady(); + + return true; + } + + return false; + } + + private function processStringWhileStatement( + string $string, + int $i, + int $parenthesisCounter, + int $braceCounter, + WhileRef $statement + ): ?bool { + + $char = $string[$i]; + $isLast = $i === strlen($string) - 1; + + if ( + $char === '(' && + !$isLast && + $parenthesisCounter === 1 && + $braceCounter === 0 && + $statement->getState() === WhileRef::STATE_EMPTY + ) { + $statement->setConditionStart($i + 1); + + return true; + } + + if ( + $char === ')' && + !$isLast && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $statement->getState() === WhileRef::STATE_CONDITION_STARTED + ) { + $statement->setConditionEnd($i); + + return true; + } + + if ( + $statement->getState() === WhileRef::STATE_CONDITION_ENDED && + !$isLast && + $parenthesisCounter === 0 && + $braceCounter === 1 && + $char === '{' + ) { + $statement->setBodyStart($i + 1); + + return true; + } + + if ( + $statement->getState() === WhileRef::STATE_CONDITION_STARTED && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $char === ')' && + $isLast + ) { + return null; + } + + if ( + $statement->getState() === WhileRef::STATE_CONDITION_ENDED && + $parenthesisCounter === 0 && + $braceCounter === 0 && + ( + $isLast || + !$this->isWhiteSpace($char) + ) + ) { + return null; + } + + if ( + $statement->getState() === WhileRef::STATE_BODY_STARTED && + $parenthesisCounter === 0 && + $braceCounter === 0 && + $char === '}' + ) { + $statement->setBodyEnd($i); + + return true; + } + + return false; + } + + private function isOnIf(string $string, int $i): bool + { + $before = substr($string, $i - 1, 1); + $after = substr($string, $i + 2, 1); + + return + substr($string, $i, 2) === 'if' && + ( + $i === 0 || + $this->isWhiteSpace($before) || + $before === ';' + ) && + ( + $this->isWhiteSpace($after) || + $after === '(' + ); + } + + private function isOnElse(string $string, int $i): bool + { + return substr($string, $i, 4) === 'else' && + $this->isWhiteSpaceCharOrBraceOpen(substr($string, $i + 4, 1)) && + $this->isWhiteSpaceCharOrBraceClose(substr($string, $i - 1, 1)); + } + + private function isOnWhile(string $string, int $i): bool + { + $before = substr($string, $i - 1, 1); + $after = substr($string, $i + 5, 1); + + return + substr($string, $i, 5) === 'while' && + ( + $i === 0 || + $this->isWhiteSpace($before) || + $before === ';' + ) && + ( + $this->isWhiteSpace($after) || + $after === '(' + ); + } + + private function isWhiteSpaceCharOrBraceOpen(string $char): bool + { + return $char === '{' || in_array($char, $this->whiteSpaceCharList); + } + + private function isWhiteSpaceCharOrBraceClose(string $char): bool + { + return $char === '}' || in_array($char, $this->whiteSpaceCharList); + } + + private function isWhiteSpace(string $char): bool + { + return in_array($char, $this->whiteSpaceCharList); + } + + /** + * @throws SyntaxError + */ + private function split(string $expression, bool $isRoot = false): Node|Attribute|Variable|Value + { + $expression = trim($expression); + + $parenthesisCounter = 0; + $braceCounter = 0; + $bracketCounter = 0; + $hasExcessParenthesis = true; + $modifiedExpression = ''; + $topLevelExpressionList = []; + + $statementList = []; + + $isStringNotClosed = $this->processString($expression, $modifiedExpression, $statementList, true); + + if ($isStringNotClosed) { + throw SyntaxError::create('String is not closed.'); + } + + $expressionLength = strlen($modifiedExpression); + + for ($i = 0; $i < $expressionLength; $i++) { + $value = $modifiedExpression[$i]; + + if ($value === '(') { + $parenthesisCounter++; + } else if ($value === ')') { + $parenthesisCounter--; + } else if ($value === '{') { + $braceCounter++; + } else if ($value === '}') { + $braceCounter--; + } else if ($value === '[') { + $bracketCounter++; + } else if ($value === ']') { + $bracketCounter--; + } + + if ($parenthesisCounter === 0 && $i < $expressionLength - 1) { + $hasExcessParenthesis = false; + } + + $topLevelExpressionList[] = $parenthesisCounter === 0 && $bracketCounter === 0; + } + + if ($parenthesisCounter !== 0) { + throw SyntaxError::create( + 'Incorrect parentheses usage in expression ' . $expression . '.', + 'Incorrect parentheses.' + ); + } + + if ($braceCounter !== 0) { + throw SyntaxError::create( + 'Incorrect braces usage in expression ' . $expression . '.', + 'Incorrect braces.' + ); + } + + if ($bracketCounter !== 0) { + throw SyntaxError::create( + 'Incorrect bracket usage in expression ' . $expression . '.', + 'Incorrect brackets.' + ); + } + + if ( + strlen($expression) > 1 && + $expression[0] === '(' && + $expression[strlen($expression) - 1] === ')' && + $hasExcessParenthesis + ) { + $expression = substr($expression, 1, strlen($expression) - 2); + + return $this->split($expression, true); + } + + if ($statementList !== null && count($statementList)) { + return $this->processStatementList($expression, $statementList, $isRoot); + } + + $firstOperator = null; + $minIndex = null; + + if (trim($expression) === '') { + return new Value(null); + } + + foreach ($this->priorityList as $operationList) { + foreach ($operationList as $operator) { + $offset = -1; + + while (true) { + $index = strrpos($modifiedExpression, $operator, $offset); + + if ($index === false) { + break; + } + + if ( + $topLevelExpressionList[$index] && + !$this->isAtAnotherOperator($index, $operator, $modifiedExpression) + ) { + break; + } + + $offset = -(strlen($expression) - $index) - 1; + } + + if ($index === false) { + continue; + } + + if ($operator === '+' || $operator === '-') { + $j = $index - 1; + + while ($j >= 0) { + $char = $expression[$j]; + + if ($this->isWhiteSpace($char)) { + $j--; + + continue; + } + + if (array_key_exists($char, $this->operatorMap)) { + continue 2; + } + + break; + } + } + + $firstPart = substr($expression, 0, $index); + $secondPart = substr($expression, $index + strlen($operator)); + + $modifiedFirstPart = $modifiedSecondPart = ''; + + $isString = $this->processString($firstPart, $modifiedFirstPart); + + $this->processString($secondPart, $modifiedSecondPart); + + if ( + substr_count($modifiedFirstPart, '(') === substr_count($modifiedFirstPart, ')') && + substr_count($modifiedSecondPart, '(') === substr_count($modifiedSecondPart, ')') && + !$isString + ) { + if ($minIndex === null || $index > $minIndex) { + $minIndex = $index; + + $firstOperator = $operator; + } + } + } + + if ($firstOperator) { + break; + } + } + + if ($firstOperator) { + /** @var int $minIndex */ + + $firstPart = substr($expression, 0, $minIndex); + $secondPart = substr($expression, $minIndex + strlen($firstOperator)); + + $firstPart = trim($firstPart); + $secondPart = trim($secondPart); + + return $this->applyOperator($firstOperator, $firstPart, $secondPart); + } + + $expression = trim($expression); + + if ($expression[0] === '!') { + return new Node('logical\\not', [ + $this->split(substr($expression, 1)) + ]); + } + + if ($expression[0] === '-') { + return new Node('numeric\\subtraction', [ + new Value(0), + $this->split(substr($expression, 1)) + ]); + } + + if ($expression[0] === '+') { + return new Node('numeric\\summation', [ + new Value(0), + $this->split(substr($expression, 1)) + ]); + } + + if ( + $expression[0] === "'" && $expression[strlen($expression) - 1] === "'" || + $expression[0] === "\"" && $expression[strlen($expression) - 1] === "\"" + ) { + return new Value(self::prepareStringValue($expression)); + } + + if ($expression[0] === "$") { + return $this->splitVariable($expression); + } + + if (is_numeric($expression)) { + $value = filter_var($expression, FILTER_VALIDATE_INT) !== false ? + (int) $expression : + (float) $expression; + + return new Value($value); + } + + if ($expression === 'true') { + return new Value(true); + } + + if ($expression === 'false') { + return new Value(false); + } + + if ($expression === 'null') { + return new Value(null); + } + + if ($expression === 'break') { + return new Node('break', []); + } + + if ($expression === 'continue') { + return new Node('continue', []); + } + + if ($expression[strlen($expression) - 1] === ')') { + $firstOpeningBraceIndex = strpos($expression, '('); + + if ($firstOpeningBraceIndex > 0) { + $functionName = trim(substr($expression, 0, $firstOpeningBraceIndex)); + $functionContent = substr($expression, $firstOpeningBraceIndex + 1, -1); + + $argumentList = $this->parseArgumentListFromFunctionContent($functionContent); + + $argumentSplitList = []; + + foreach ($argumentList as $argument) { + $argumentSplitList[] = $this->split($argument); + } + + if ($functionName === '' || !preg_match($this->functionNameRegExp, $functionName)) { + throw new SyntaxError("Bad function name `$functionName`."); + } + + return new Node($functionName, $argumentSplitList); + } + } + + if (str_contains($expression, ' ')) { + throw SyntaxError::create("Could not parse."); + } + + if (!preg_match($this->attributeNameRegExp, $expression)) { + throw SyntaxError::create("Attribute name `$expression` contains not allowed characters."); + } + + if (str_ends_with($expression, '.')) { + throw SyntaxError::create("Attribute ends with dot."); + } + + return new Attribute($expression); + } + + private function isAtAnotherOperator(int $index, string $operator, string $expression): bool + { + $possibleRightOperator = null; + + if (strlen($operator) === 1) { + if ($index < strlen($expression) - 1) { + $possibleRightOperator = trim($operator . $expression[$index + 1]); + } + } + + if ( + $possibleRightOperator && + $possibleRightOperator != $operator && + !empty($this->operatorMap[$possibleRightOperator]) + ) { + return true; + } + + $possibleLeftOperator = null; + + if (strlen($operator) === 1) { + if ($index > 0) { + $possibleLeftOperator = trim($expression[$index - 1] . $operator); + } + } + + if ( + $possibleLeftOperator && + $possibleLeftOperator != $operator && + !empty($this->operatorMap[$possibleLeftOperator]) + ) { + return true; + } + + return false; + } + + /** + * @param (StatementRef|IfRef|WhileRef)[] $statementList + * @throws SyntaxError + */ + private function processStatementList( + string $expression, + array $statementList, + bool $isRoot + ): Node|Value|Attribute|Variable { + + $parsedPartList = []; + + foreach ($statementList as $statement) { + $parsedPart = null; + + if ($statement instanceof StatementRef) { + $start = $statement->getStart(); + $end = $statement->getEnd(); + + if ($end === null) { + throw new LogicException(); + } + + $part = self::sliceByStartEnd($expression, $start, $end); + + $parsedPart = $this->split($part); + } else if ($statement instanceof IfRef) { + if (!$isRoot || !$statement->isReady()) { + throw SyntaxError::create( + 'Incorrect if statement usage in expression ' . $expression . '.', + 'Incorrect if statement.' + ); + } + + $conditionStart = $statement->getConditionStart(); + $conditionEnd = $statement->getConditionEnd(); + $thenStart = $statement->getThenStart(); + $thenEnd = $statement->getThenEnd(); + $elseStart = $statement->getElseStart(); + $elseEnd = $statement->getElseEnd(); + + if ( + $conditionStart === null || + $conditionEnd === null || + $thenStart === null || + $thenEnd === null + ) { + throw new LogicException(); + } + + $conditionPart = self::sliceByStartEnd($expression, $conditionStart, $conditionEnd); + $thenPart = self::sliceByStartEnd($expression, $thenStart, $thenEnd); + $elsePart = $elseStart !== null && $elseEnd !== null ? + self::sliceByStartEnd($expression, $elseStart, $elseEnd) : null; + + $parsedPart = $statement->getElseKeywordEnd() ? + new Node('ifThenElse', [ + $this->split($conditionPart), + $this->split($thenPart, true), + $this->split($elsePart ?? '', true) + ]) : + new Node('ifThen', [ + $this->split($conditionPart), + $this->split($thenPart, true) + ]); + } else if ($statement instanceof WhileRef) { + if (!$isRoot || !$statement->isReady()) { + throw SyntaxError::create( + 'Incorrect while statement usage in expression ' . $expression . '.', + 'Incorrect while statement.' + ); + } + + $conditionStart = $statement->getConditionStart(); + $conditionEnd = $statement->getConditionEnd(); + $bodyStart = $statement->getBodyStart(); + $bodyEnd = $statement->getBodyEnd(); + + if ( + $conditionStart === null || + $conditionEnd === null || + $bodyStart === null || + $bodyEnd === null + ) { + throw new LogicException(); + } + + $conditionPart = self::sliceByStartEnd($expression, $conditionStart, $conditionEnd); + $bodyPart = self::sliceByStartEnd($expression, $bodyStart, $bodyEnd); + + $parsedPart = new Node('while', [ + $this->split($conditionPart), + $this->split($bodyPart, true) + ]); + } + + if (!$parsedPart) { + throw SyntaxError::create( + 'Unknown syntax error in expression ' . $expression . '.', + 'Unknown syntax error.' + ); + } + + $parsedPartList[] = $parsedPart; + } + + if (count($parsedPartList) === 1) { + return $parsedPartList[0]; + } + + return new Node('bundle', $parsedPartList); + } + + private static function sliceByStartEnd(string $expression, int $start, int $end): string + { + return trim( + substr( + $expression, + $start, + $end - $start + ) + ); + } + + /** + * @return string[] + */ + private function parseArgumentListFromFunctionContent(string $functionContent): array + { + $functionContent = trim($functionContent); + + $isString = false; + $isSingleQuote = false; + + if ($functionContent === '') { + return []; + } + + $commaIndexList = []; + $braceCounter = 0; + + for ($i = 0; $i < strlen($functionContent); $i++) { + if ($functionContent[$i] === "'" && self::isNotAfterBackslash($functionContent, $i)) { + if (!$isString) { + $isString = true; + $isSingleQuote = true; + } else { + if ($isSingleQuote) { + $isString = false; + } + } + } else if ($functionContent[$i] === "\"" && self::isNotAfterBackslash($functionContent, $i)) { + if (!$isString) { + $isString = true; + $isSingleQuote = false; + } else { + if (!$isSingleQuote) { + $isString = false; + } + } + } + + if (!$isString) { + if ($functionContent[$i] === '(') { + $braceCounter++; + } else if ($functionContent[$i] === ')') { + $braceCounter--; + } + } + + if ($braceCounter === 0 && !$isString && $functionContent[$i] === ',') { + $commaIndexList[] = $i; + } + } + + $commaIndexList[] = strlen($functionContent); + + $argumentList = []; + + for ($i = 0; $i < count($commaIndexList); $i++) { + if ($i > 0) { + $previousCommaIndex = $commaIndexList[$i - 1] + 1; + } else { + $previousCommaIndex = 0; + } + + $argument = trim( + substr( + $functionContent, + $previousCommaIndex, + $commaIndexList[$i] - $previousCommaIndex + ) + ); + + if ($argument === '' && $i === count($commaIndexList) - 1) { + continue; + } + + $argumentList[] = $argument; + } + + return $argumentList; + } + + static private function prepareStringValue(string $expression): string + { + $string = substr($expression, 1, strlen($expression) - 2); + + $isDoubleQuote = $expression[0] === '"'; + + /** @var array{bool, string}[] $tokens */ + $tokens = []; + + $stripList = ["\\\\", "\\\"", "\\n", "\\t", "\\r"]; + $replaceList = ["\\", "\"", "\n", "\t", "\r"]; + + if ($isDoubleQuote) { + $stripList[] = "\\\""; + $replaceList[] = "\""; + } else { + $stripList[] = "\\'"; + $replaceList[] = "'"; + } + + $k = 0; + + for ($i = 0; $i < strlen($string); $i++) { + $part = substr($string, $i, 2); + + if (in_array($part, $stripList)) { + $len = strlen($part); + + $before = substr($string, $k, $i - $k); + + if (strlen($before)) { + $tokens[] = [false, $before]; + } + + $tokens[] = [true, $part]; + + $i += $len - 1; + $k = $i + 1; + } + + if ($i >= strlen($string) - 1) { + $after = substr($string, $k); + + if (strlen($after)) { + $tokens[] = [false, $after]; + } + } + } + + $result = ''; + + foreach ($tokens as $token) { + if (!$token[0]) { + $result .= $token[1]; + + continue; + } + + $result .= str_replace($stripList, $replaceList, $token[1]); + } + + return $result; + } + + /** + * @throws SyntaxError + */ + private function applyOperatorVariableAssign(string $firstPart, string $secondPart): Node + { + $variable = substr($firstPart, 1); + + $isArrayAppend = false; + $isKeyValue = false; + $keyPath = []; + + if (str_ends_with($firstPart, '[]')) { + $variable = substr($firstPart, 1, -2); + + $isArrayAppend = true; + } else if (str_ends_with($firstPart, ']') && str_contains($firstPart, '[')) { + $bracketPosition = strpos($firstPart, '[') ?: 0; + + $variable = substr($firstPart, 1, $bracketPosition - 1); + + $keyPart = trim(substr($firstPart, $bracketPosition)); + $keyPath = array_map(fn ($it) => $this->split($it), $this->splitKeys($keyPart)); + + $isKeyValue = true; + } + + if ($variable === '' || !preg_match($this->variableNameRegExp, $variable)) { + throw new SyntaxError("Bad variable name `$variable`."); + } + + if ($isArrayAppend) { + return new Node('arrayAppend', [ + new Value($variable), + $this->split($secondPart) + ]); + } + + if ($isKeyValue) { + return new Node('variableSetKeyValue', [ + new Value($variable), + new Node('list', $keyPath), + $this->split($secondPart) + ]); + } + + return new Node('assign', [ + new Value($variable), + $this->split($secondPart) + ]); + } + + /** + * @throws SyntaxError + */ + private function splitVariable(string $expression): Node|Variable + { + $value = substr($expression, 1); + + $isIncrement = false; + $isDecrement = false; + $isKeyValue = false; + $keyPath = []; + + if (str_ends_with($expression, '++')) { + $isIncrement = true; + + $value = rtrim(substr($value, 0, -2)); + } + + if (str_ends_with($expression, '--')) { + $isDecrement = true; + + $value = rtrim(substr($value, 0, -2)); + } else if (str_ends_with($expression, ']') && str_contains($expression, '[')) { + $bracketPosition = strpos($expression, '[') ?: 0; + $value = substr($expression, 1, $bracketPosition - 1); + $keyPart = trim(substr($expression, $bracketPosition)); + $keyPath = array_map(fn ($it) => $this->split($it), $this->splitKeys($keyPart)); + + $isKeyValue = true; + } + + if ($value === '' || !preg_match($this->variableNameRegExp, $value)) { + throw new SyntaxError("Bad variable name `$value`."); + } + + if ($isIncrement) { + return new Node('variableIncrement', [ + new Value($value), + ]); + } + + if ($isDecrement) { + return new Node('variableDecrement', [ + new Value($value), + ]); + } + + if ($isKeyValue) { + return new Node('variableGetValueByKey', [ + new Value($value), + new Node('list', $keyPath), + ]); + } + + return new Variable($value); + } + + /** + * @return string[] + * @throws SyntaxError + */ + private function splitKeys(string $expression): array + { + $modifiedExpression = ''; + + $this->processString($expression, $modifiedExpression, $statementList, true); + + $expressionLength = strlen($modifiedExpression); + + $parenthesisCounter = 0; + $bracketCounter = 0; + + $output = []; + + /** @var array{int, int}[] $indexPairs */ + $indexPairs = []; + + $startIndex = -1; + + for ($i = 0; $i < $expressionLength; $i++) { + $value = $modifiedExpression[$i]; + + if ($value === '(') { + $parenthesisCounter++; + } else if ($value === ')') { + $parenthesisCounter--; + } else if ($value === '[') { + $bracketCounter++; + } else if ($value === ']') { + $bracketCounter--; + } + + if ( + $value === '[' && + $parenthesisCounter === 0 && + $bracketCounter === 1 + ) { + $startIndex = $i; + } + + if ( + $value === ']' && + $parenthesisCounter === 0 && + $bracketCounter === 0 + ) { + $indexPairs[] = [$startIndex + 1, $i]; + + $startIndex = -1; + } + } + + foreach ($indexPairs as $i => $pair) { + if ($i > 0) { + if ($indexPairs[$i - 1][1] !== $pair[0] - 2) { + throw new SyntaxError("Nested brackets must have no gaps in between."); + } + } + + $itemExpression = trim(substr($expression, $pair[0], $pair[1] - $pair[0])); + + if ($itemExpression === '') { + throw new SyntaxError("No expression inside brackets."); + } + + $output[] = $itemExpression; + } + + return $output; + } +} diff --git a/application/Espo/Core/Formula/Parser/Ast/Attribute.php b/application/Espo/Core/Formula/Parser/Ast/Attribute.php new file mode 100644 index 0000000..e835f7b --- /dev/null +++ b/application/Espo/Core/Formula/Parser/Ast/Attribute.php @@ -0,0 +1,44 @@ +. + * + * 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\Formula\Parser\Ast; + +/** + * An attribute AST node. + */ +class Attribute +{ + public function __construct(private string $name) + {} + + public function getName(): string + { + return $this->name; + } +} diff --git a/application/Espo/Core/Formula/Parser/Ast/Node.php b/application/Espo/Core/Formula/Parser/Ast/Node.php new file mode 100644 index 0000000..2d63053 --- /dev/null +++ b/application/Espo/Core/Formula/Parser/Ast/Node.php @@ -0,0 +1,55 @@ +. + * + * 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\Formula\Parser\Ast; + +/** + * An AST node. + */ +class Node +{ + /** + * @param (Node|Value|Attribute|Variable)[] $childNodes + */ + public function __construct(private string $type, private array $childNodes) + {} + + public function getType(): string + { + return $this->type; + } + + /** + * @return (Node|Value|Attribute|Variable)[] + */ + public function getChildNodes(): array + { + return $this->childNodes; + } +} diff --git a/application/Espo/Core/Formula/Parser/Ast/Value.php b/application/Espo/Core/Formula/Parser/Ast/Value.php new file mode 100644 index 0000000..2b6b922 --- /dev/null +++ b/application/Espo/Core/Formula/Parser/Ast/Value.php @@ -0,0 +1,44 @@ +. + * + * 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\Formula\Parser\Ast; + +/** + * A value AST node. + */ +class Value +{ + public function __construct(private mixed $value) + {} + + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/application/Espo/Core/Formula/Parser/Ast/Variable.php b/application/Espo/Core/Formula/Parser/Ast/Variable.php new file mode 100644 index 0000000..0330a2d --- /dev/null +++ b/application/Espo/Core/Formula/Parser/Ast/Variable.php @@ -0,0 +1,44 @@ +. + * + * 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\Formula\Parser\Ast; + +/** + * A variable AST node. + */ +class Variable +{ + public function __construct(private string $name) + {} + + public function getName(): string + { + return $this->name; + } +} diff --git a/application/Espo/Core/Formula/Parser/Statement/IfRef.php b/application/Espo/Core/Formula/Parser/Statement/IfRef.php new file mode 100644 index 0000000..d39071b --- /dev/null +++ b/application/Espo/Core/Formula/Parser/Statement/IfRef.php @@ -0,0 +1,164 @@ +. + * + * 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\Formula\Parser\Statement; + +class IfRef +{ + public const STATE_EMPTY = 0; + public const STATE_CONDITION_STARTED = 1; + public const STATE_CONDITION_ENDED = 2; + public const STATE_THEN_STARTED = 3; + public const STATE_THEN_ENDED = 4; + public const STATE_ELSE_MET = 5; + public const STATE_ELSE_STARTED = 6; + public const STATE_ELSE_ENDED = 7; + public const STATE_READY = 8; + + private ?int $conditionStart = null; + private ?int $conditionEnd = null; + private ?int $thenStart = null; + private ?int $thenEnd = null; + private ?int $elseStart = null; + private ?int $elseEnd = null; + private ?int $elseKeywordEnd = null; + private bool $hasInlineElse = false; + private int $state = self::STATE_EMPTY; + + public function __construct() + {} + + public function setConditionStart(int $conditionStart): void + { + $this->conditionStart = $conditionStart; + $this->state = self::STATE_CONDITION_STARTED; + } + + public function setReady(): void + { + $this->state = self::STATE_READY; + } + + public function setConditionEnd(int $conditionEnd): void + { + $this->conditionEnd = $conditionEnd; + $this->state = self::STATE_CONDITION_ENDED; + } + + public function setThenStart(int $thenStart): void + { + $this->thenStart = $thenStart; + $this->state = self::STATE_THEN_STARTED; + } + + public function setThenEnd(int $thenEnd): void + { + $this->thenEnd = $thenEnd; + $this->state = self::STATE_THEN_ENDED; + } + + public function setElseMet(int $elseKeywordEnd): void + { + $this->state = self::STATE_ELSE_MET; + $this->elseKeywordEnd = $elseKeywordEnd; + } + + public function setElseStart(int $elseStart, bool $isInlineElse = false): void + { + $this->elseStart = $elseStart; + $this->state = self::STATE_ELSE_STARTED; + $this->hasInlineElse = $isInlineElse; + } + + public function setElseEnd(int $elseEnd): void + { + $this->elseEnd = $elseEnd; + $this->state = self::STATE_ELSE_ENDED; + } + + public function setState(int $state): void + { + $this->state = $state; + } + + public function getState(): int + { + return $this->state; + } + + public function isReady(): bool + { + return $this->state === self::STATE_READY; + } + + public function getConditionStart(): ?int + { + return $this->conditionStart; + } + + public function getConditionEnd(): ?int + { + return $this->conditionEnd; + } + + public function getThenStart(): ?int + { + return $this->thenStart; + } + + public function getThenEnd(): ?int + { + return $this->thenEnd; + } + + public function getElseStart(): ?int + { + return $this->elseStart; + } + + public function getElseEnd(): ?int + { + return $this->elseEnd; + } + + public function getElseKeywordEnd(): ?int + { + return $this->elseKeywordEnd; + } + + public function getEnd(): ?int + { + return $this->elseEnd ?? $this->thenEnd; + } + + public function hasInlineElse(): bool + { + return $this->hasInlineElse; + } +} diff --git a/application/Espo/Core/Formula/Parser/Statement/StatementRef.php b/application/Espo/Core/Formula/Parser/Statement/StatementRef.php new file mode 100644 index 0000000..7fe1b77 --- /dev/null +++ b/application/Espo/Core/Formula/Parser/Statement/StatementRef.php @@ -0,0 +1,64 @@ +. + * + * 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\Formula\Parser\Statement; + +class StatementRef +{ + private bool $endedWithSemicolon = false; + + public function __construct(private int $start, private ?int $end = null) + {} + + public function setEnd(int $end, bool $endedWithSemicolon = false): void + { + $this->end = $end; + $this->endedWithSemicolon = $endedWithSemicolon; + } + + public function getStart(): int + { + return $this->start; + } + + public function getEnd(): ?int + { + return $this->end; + } + + public function isReady(): bool + { + return $this->end !== null; + } + + public function isEndedWithSemicolon(): bool + { + return $this->endedWithSemicolon; + } +} diff --git a/application/Espo/Core/Formula/Parser/Statement/WhileRef.php b/application/Espo/Core/Formula/Parser/Statement/WhileRef.php new file mode 100644 index 0000000..e8c6266 --- /dev/null +++ b/application/Espo/Core/Formula/Parser/Statement/WhileRef.php @@ -0,0 +1,112 @@ +. + * + * 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\Formula\Parser\Statement; + +class WhileRef +{ + public const STATE_EMPTY = 0; + public const STATE_CONDITION_STARTED = 1; + public const STATE_CONDITION_ENDED = 2; + public const STATE_BODY_STARTED = 3; + public const STATE_READY = 4; + + private ?int $conditionStart = null; + private ?int $conditionEnd = null; + private ?int $bodyStart = null; + private ?int $bodyEnd = null; + private int $state = self::STATE_EMPTY; + + public function __construct(private int $start) + {} + + public function setConditionStart(int $conditionStart): void + { + $this->conditionStart = $conditionStart; + $this->state = self::STATE_CONDITION_STARTED; + } + + public function setConditionEnd(int $conditionEnd): void + { + $this->conditionEnd = $conditionEnd; + $this->state = self::STATE_CONDITION_ENDED; + } + + public function setBodyStart(int $bodyStart): void + { + $this->bodyStart = $bodyStart; + $this->state = self::STATE_BODY_STARTED; + } + + public function setBodyEnd(int $bodyEnd): void + { + $this->bodyEnd = $bodyEnd; + $this->state = self::STATE_READY; + } + + public function isReady(): bool + { + return $this->state === self::STATE_READY; + } + + public function getState(): int + { + return $this->state; + } + + public function getConditionStart(): ?int + { + return $this->conditionStart; + } + + public function getConditionEnd(): ?int + { + return $this->conditionEnd; + } + + public function getBodyStart(): ?int + { + return $this->bodyStart; + } + + public function getBodyEnd(): ?int + { + return $this->bodyEnd; + } + + public function getEnd(): ?int + { + return $this->bodyEnd; + } + + public function getStart(): int + { + return $this->start; + } +} diff --git a/application/Espo/Core/Formula/Processor.php b/application/Espo/Core/Formula/Processor.php new file mode 100644 index 0000000..eec059e --- /dev/null +++ b/application/Espo/Core/Formula/Processor.php @@ -0,0 +1,205 @@ +. + * + * 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\Formula; + +use Espo\Core\Formula\Exceptions\BadArgumentType; +use Espo\Core\Formula\Exceptions\BadArgumentValue; +use Espo\Core\Formula\Exceptions\ExecutionException; +use Espo\Core\Formula\Exceptions\FunctionRuntimeError; +use Espo\Core\Formula\Exceptions\TooFewArguments; +use Espo\Core\Formula\Exceptions\UndefinedKey; +use Espo\Core\Formula\Functions\Base as DeprecatedBaseFunction; +use Espo\Core\Formula\Functions\BaseFunction; +use Espo\Core\Formula\Parser\Ast\Attribute; +use Espo\Core\Formula\Parser\Ast\Node; +use Espo\Core\Formula\Parser\Ast\Value; +use Espo\Core\Formula\Parser\Ast\Variable; +use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\InjectableFactory; + +use Espo\ORM\Entity; + +use InvalidArgumentException; +use stdClass; + +/** + * An instance of Processor is created for every formula script. + */ +class Processor +{ + private FunctionFactory $functionFactory; + private stdClass $variables; + + /** + * @param ?array> $functionClassNameMap + */ + public function __construct( + InjectableFactory $injectableFactory, + AttributeFetcher $attributeFetcher, + ?array $functionClassNameMap = null, + private ?Entity $entity = null, + ?stdClass $variables = null + ) { + $this->functionFactory = new FunctionFactory( + $this, + $injectableFactory, + $attributeFetcher, + $functionClassNameMap + ); + + $this->variables = $variables ?? (object) []; + } + + /** + * Evaluates an argument or argument list. + * + * @return mixed A result of evaluation. An array if an argument list was passed. + * @throws Error + * @throws ExecutionException + */ + public function process(Evaluatable $item): mixed + { + if ($item instanceof ArgumentList) { + return $this->processList($item); + } + + if (!$item instanceof Argument) { + throw new InvalidArgumentException(); + } + + $function = $this->functionFactory->create($item->getType(), $this->entity, $this->variables); + + if ($function instanceof Func || $function instanceof FuncVariablesAware) { + return $this->processFunc($item, $function); + } + + if ($function instanceof DeprecatedBaseFunction) { + return $function->process(self::dataToStdClass($item->getData())); + } + + try { + return $function->process($item->getArgumentList()); + } catch (UndefinedKey $e) { + throw UndefinedKey::cloneWithLevelRisen($e); + } catch (TooFewArguments|BadArgumentType|BadArgumentValue $e) { + $message = sprintf('Function %s; %s', $item->getType(), $e->getLogMessage()); + + throw new Error($message); + } catch (FunctionRuntimeError $e) { + $message = sprintf('Function %s; %s', $item->getType(), $e->getMessage()); + + throw new Error($message); + } + } + + /** + * @throws Error + */ + private function dataToStdClass(Node|Value|Attribute|Variable|string|float|int|bool|null $data): stdClass + { + if ($data instanceof Node) { + return (object) [ + 'type' => $data->getType(), + 'value' => $data->getChildNodes(), + ]; + } + + if ($data instanceof Value) { + return (object) [ + 'type' => 'value', + 'value' => $data->getValue(), + ]; + } + + if ($data instanceof Attribute) { + return (object) [ + 'type' => 'attribute', + 'value' => $data->getName(), + ]; + } + + if ($data instanceof Variable) { + return (object) [ + 'type' => 'variable', + 'value' => $data->getName(), + ]; + } + + throw new Error("Can't convert argument to a raw object."); + } + + /** + * @return array + * @throws Error + * @throws ExecutionException + */ + private function processList(ArgumentList $args): array + { + $list = []; + + foreach ($args as $item) { + $list[] = $this->process($item); + } + + return $list; + } + + /** + * @throws Error + * @throws ExecutionException + */ + private function processFunc(Argument $item, Func|FuncVariablesAware $function): mixed + { + $rawEvaluatedArguments = array_map( + fn ($item) => $this->process($item), + iterator_to_array($item->getArgumentList()) + ); + + try { + $evaluatedArguments = new EvaluatedArgumentList($rawEvaluatedArguments); + + if ($function instanceof FuncVariablesAware) { + $variables = new Variables($this->variables); + + return $function->process($evaluatedArguments, $variables); + } + + return $function->process($evaluatedArguments); + } catch (TooFewArguments|BadArgumentType|BadArgumentValue $e) { + $message = sprintf('Function %s; %s', $item->getType(), $e->getLogMessage()); + + throw new Error($message); + } catch (FunctionRuntimeError $e) { + $message = sprintf('Function %s; %s', $item->getType(), $e->getMessage()); + + throw new Error($message); + } + } +} diff --git a/application/Espo/Core/Formula/Utils/EntityUtil.php b/application/Espo/Core/Formula/Utils/EntityUtil.php new file mode 100644 index 0000000..db07dd0 --- /dev/null +++ b/application/Espo/Core/Formula/Utils/EntityUtil.php @@ -0,0 +1,78 @@ +. + * + * 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\Formula\Utils; + +use Espo\Core\Acl\SystemRestriction; +use Espo\Core\Acl\Exceptions\Restricted; +use Espo\Core\Formula\Exceptions\NotAllowedUsage; +use Espo\ORM\Entity; + +/** + * @internal + * @since 9.3.0 + */ +class EntityUtil +{ + public function __construct( + private SystemRestriction $systemRestriction, + ) {} + + /** + * @throws NotAllowedUsage + */ + public function assertUpdateAccess(Entity $entity): void + { + try { + $this->systemRestriction->assertUpdate($entity); + } catch (Restricted $e) { + throw new NotAllowedUsage($e->getMessage(), previous: $e); + } + } + + /** + * @throws NotAllowedUsage + */ + public function assertRemoveAccess(Entity $entity): void + { + try { + $this->systemRestriction->assertRemoval($entity); + } catch (Restricted $e) { + throw new NotAllowedUsage($e->getMessage(), previous: $e); + } + } + + /** + * @return string[] + */ + public function getWriteRestrictedAttributeList(string $entityType): array + { + return $this->systemRestriction->getWriteRestrictedAttributeList($entityType); + } +} diff --git a/application/Espo/Core/Formula/Variables.php b/application/Espo/Core/Formula/Variables.php new file mode 100644 index 0000000..9a6ebc7 --- /dev/null +++ b/application/Espo/Core/Formula/Variables.php @@ -0,0 +1,67 @@ +. + * + * 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\Formula; + +use Espo\Core\Formula\Exceptions\Error; +use stdClass; + +class Variables +{ + public function __construct( + private stdClass $variables + ) {} + + public function has(string $name): bool + { + return property_exists($this->variables, $name); + } + + /** + * @throws Error + */ + public function get(string $name): mixed + { + if (!property_exists($this->variables, $name)) { + throw new Error("Variable $name is not defined."); + } + + return $this->variables->$name; + } + + public function tryGet(string $name): mixed + { + return $this->variables->$name ?? null; + } + + public function set(string $name, mixed $value): void + { + $this->variables->$name = $value; + } +} diff --git a/application/Espo/Core/Hook/Control.php b/application/Espo/Core/Hook/Control.php new file mode 100644 index 0000000..aec35d7 --- /dev/null +++ b/application/Espo/Core/Hook/Control.php @@ -0,0 +1,60 @@ +. + * + * 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; + +use Espo\Core\Container; +use Espo\Core\HookManager; + +/** + * @internal + * + * Serves as a proxy for `HookManager`. + */ +class Control +{ + public function __construct( + private Container $container, + ) {} + + public function enable(): void + { + $this->getHookManager()->enable(); + } + + public function disable(): void + { + $this->getHookManager()->disable(); + } + + private function getHookManager(): HookManager + { + return $this->container->getByClass(HookManager::class); + } +} diff --git a/application/Espo/Core/Hook/DataProvider.php b/application/Espo/Core/Hook/DataProvider.php new file mode 100644 index 0000000..db9f0e8 --- /dev/null +++ b/application/Espo/Core/Hook/DataProvider.php @@ -0,0 +1,229 @@ +. + * + * 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; + +use Espo\Core\Binding\Attributes\Qualify; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Module\PathProvider; +use Espo\Core\Utils\Util; +use LogicException; + +/** + * @internal + * @since 10.1.0 + * + * To be re-used between requests. + */ +class DataProvider +{ + private const int DEFAULT_ORDER = 9; + + /** @var ?array> */ + private $data = null; + + private string $cacheKey = 'hooks'; + + /** @var string[] */ + private $ignoredMethodList = [ + '__construct', + 'getDependencyList', + 'inject', + ]; + + public function __construct( + private SystemConfig $systemConfig, + private FileManager $fileManager, + private PathProvider $pathProvider, + #[Qualify(DataCache::QUALIFIER_SYSTEM)] + private DataCache $dataCache, + private Metadata $metadata, + ) {} + + /** + * @return array> + */ + public function get(): array + { + if (!$this->data) { + $this->load(); + } + + return $this->data ?? throw new LogicException(); + } + + private function load(): void + { + if ($this->systemConfig->useCache() && $this->dataCache->has($this->cacheKey)) { + /** @var ?array> $cachedData */ + $cachedData = $this->dataCache->get($this->cacheKey); + + if ($cachedData !== null) { + $this->data = $cachedData; + + return; + } + } + + $data = $this->readHookData($this->pathProvider->getCustom() . 'Hooks'); + + foreach ($this->metadata->getModuleList() as $moduleName) { + $modulePath = $this->pathProvider->getModule($moduleName) . 'Hooks'; + + $data = $this->readHookData($modulePath, $data); + } + + $data = $this->readHookData($this->pathProvider->getCore() . 'Hooks', $data); + + $this->data = $this->sortHooks($data); + + if ($this->systemConfig->useCache()) { + $this->dataCache->store($this->cacheKey, $this->data); + } + } + + /** + * @param string $hookDir + * @param array> $hookData + * @return array> + */ + private function readHookData(string $hookDir, array $hookData = []): array + { + if (!$this->fileManager->exists($hookDir)) { + return $hookData; + } + + /** @var array $fileList */ + $fileList = $this->fileManager->getFileList($hookDir, 1, '\.php$', true); + + foreach ($fileList as $scopeName => $hookFiles) { + $hookScopeDirPath = Util::concatPath($hookDir, $scopeName); + $normalizedScopeName = Util::normalizeScopeName($scopeName); + + foreach ($hookFiles as $hookFile) { + $hookFilePath = Util::concatPath($hookScopeDirPath, $hookFile); + $className = Util::getClassName($hookFilePath); + + $classMethods = get_class_methods($className); + + $hookMethods = array_diff($classMethods, $this->ignoredMethodList); + + /** @var string[] $hookMethods */ + $hookMethods = array_filter($hookMethods, function ($item) { + if (str_starts_with($item, 'set')) { + return false; + } + + return true; + }); + + foreach ($hookMethods as $hookType) { + $entityHookData = $hookData[$normalizedScopeName][$hookType] ?? []; + + if ($this->hookExists($className, $entityHookData)) { + continue; + } + + if ($this->hookClassIsSuppressed($className)) { + continue; + } + + $hookData[$normalizedScopeName][$hookType][] = [ + 'className' => $className, + 'order' => $className::$order ?? self::DEFAULT_ORDER, + ]; + } + } + } + + return $hookData; + } + + /** + * Check if hook exists in the list. + * + * @param class-string $className + * @param array $hookData + */ + private function hookExists(string $className, array $hookData): bool + { + $class = preg_replace('/^.*\\\(.*)$/', '$1', $className); + + foreach ($hookData as $item) { + if (preg_match('/\\\\'.$class.'$/', $item['className'])) { + return true; + } + } + + return false; + } + + /** + * Sort hooks by the order parameter. + * + * @param array> $hooks + * @return array> + */ + private function sortHooks(array $hooks): array + { + foreach ($hooks as &$scopeHooks) { + foreach ($scopeHooks as &$hookList) { + usort($hookList, [$this, 'cmpHooks']); + } + } + + return $hooks; + } + + /** + * @param class-string $className + */ + private function hookClassIsSuppressed(string $className): bool + { + $suppressList = $this->metadata->get(['app', 'hook', 'suppressClassNameList']) ?? []; + + return in_array($className, $suppressList); + } + + /** + * @param array $a + * @param array $b + */ + private function cmpHooks($a, $b): int + { + if ($a['order'] == $b['order']) { + return 0; + } + + return ($a['order'] < $b['order']) ? -1 : 1; + } +} diff --git a/application/Espo/Core/Hook/GeneralInvoker.php b/application/Espo/Core/Hook/GeneralInvoker.php new file mode 100644 index 0000000..0adf6a9 --- /dev/null +++ b/application/Espo/Core/Hook/GeneralInvoker.php @@ -0,0 +1,212 @@ +. + * + * 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; + +use Espo\Core\Hook\Hook\AfterMassRelate; +use Espo\Core\Hook\Hook\AfterRelate; +use Espo\Core\Hook\Hook\AfterRemove; +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; +use Espo\ORM\Repository\Option\RelateOptions; +use Espo\ORM\Repository\Option\RemoveOptions; +use Espo\ORM\Repository\Option\SaveOptions; +use Espo\ORM\Repository\Option\UnrelateOptions; +use LogicException; + +/** + * Invokes hook methods, prepares needed arguments for specific hooks. + */ +class GeneralInvoker +{ + private const string HOOK_BEFORE_SAVE = 'beforeSave'; + private const string HOOK_AFTER_SAVE = 'afterSave'; + private const string HOOK_LATE_AFTER_SAVE = 'lateAfterSave'; + private const string HOOK_BEFORE_REMOVE = 'beforeRemove'; + private const string HOOK_AFTER_REMOVE = 'afterRemove'; + private const string HOOK_LATE_AFTER_REMOVE = 'lateAfterRemove'; + private const string HOOK_AFTER_RELATE = 'afterRelate'; + private const string HOOK_AFTER_UNRELATE = 'afterUnrelate'; + private const string HOOK_AFTER_MASS_RELATE = 'afterMassRelate'; + + /** + * @param object $hook A hook object. + * @param string $name A hook name. + * @param mixed $subject A subject. + * @param array $options Options. + * @param array $hookData Additional data. + */ + public function invoke( + object $hook, + string $name, + mixed $subject, + array $options, + array $hookData, + ): void { + + if ($name === self::HOOK_BEFORE_SAVE && $hook instanceof BeforeSave) { + if (!$subject instanceof Entity) { + throw new LogicException(); + } + + $hook->beforeSave($subject, SaveOptions::fromAssoc($options)); + + return; + } + + if ($name === self::HOOK_AFTER_SAVE && $hook instanceof AfterSave) { + if (!$subject instanceof Entity) { + throw new LogicException(); + } + + $hook->afterSave($subject, SaveOptions::fromAssoc($options)); + + 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(); + } + + $hook->beforeRemove($subject, RemoveOptions::fromAssoc($options)); + + return; + } + + if ($name === self::HOOK_AFTER_REMOVE && $hook instanceof AfterRemove) { + if (!$subject instanceof Entity) { + throw new LogicException(); + } + + $hook->afterRemove($subject, RemoveOptions::fromAssoc($options)); + + 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; + $columnData = $hookData['relationData'] ?? []; + + if ( + !$subject instanceof Entity || + !is_string($relationName) || + !$relatedEntity instanceof Entity + ) { + throw new LogicException(); + } + + $hook->afterRelate( + entity: $subject, + relationName: $relationName, + relatedEntity: $relatedEntity, + columnData: $columnData, + options: RelateOptions::fromAssoc($options), + ); + + return; + } + + if ($name === self::HOOK_AFTER_UNRELATE && $hook instanceof AfterUnrelate) { + $relationName = $hookData['relationName'] ?? null; + $relatedEntity = $hookData['foreignEntity'] ?? null; + + if ( + !$subject instanceof Entity || + !is_string($relationName) || + !$relatedEntity instanceof Entity + ) { + throw new LogicException(); + } + + $hook->afterUnrelate( + entity: $subject, + relationName: $relationName, + relatedEntity: $relatedEntity, + options: UnrelateOptions::fromAssoc($options), + ); + + return; + } + + if ($name === self::HOOK_AFTER_MASS_RELATE && $hook instanceof AfterMassRelate) { + $relationName = $hookData['relationName'] ?? null; + $query = $hookData['query'] ?? null; + $columnData = $hookData['relationData'] ?? []; // Not implemented currently. + + if ( + !$subject instanceof Entity || + !is_string($relationName) || + !$query instanceof Select + ) { + throw new LogicException(); + } + + $hook->afterMassRelate( + entity: $subject, + relationName: $relationName, + query: $query, + columnData: $columnData, + options: MassRelateOptions::fromAssoc($options), + ); + + return; + } + + $hook->$name($subject, $options, $hookData); + } +} diff --git a/application/Espo/Core/Hook/Hook/AfterMassRelate.php b/application/Espo/Core/Hook/Hook/AfterMassRelate.php new file mode 100644 index 0000000..794d24f --- /dev/null +++ b/application/Espo/Core/Hook/Hook/AfterMassRelate.php @@ -0,0 +1,59 @@ +. + * + * 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\Query\Select; +use Espo\ORM\Repository\Option\MassRelateOptions; + +/** + * An afterMassRelate hook. + * + * @template TEntity of Entity = Entity + */ +interface AfterMassRelate +{ + /** + * Processed after an entity is mass-related. Called from within a repository. + * + * @param TEntity $entity An entity. + * @param string $relationName A relation name. + * @param Select $query A select query for records to be related. + * @param array $columnData Middle table role values. + * @param MassRelateOptions $options Options. + */ + public function afterMassRelate( + Entity $entity, + string $relationName, + Select $query, + array $columnData, + MassRelateOptions $options + ): void; +} diff --git a/application/Espo/Core/Hook/Hook/AfterRelate.php b/application/Espo/Core/Hook/Hook/AfterRelate.php new file mode 100644 index 0000000..ea8cba5 --- /dev/null +++ b/application/Espo/Core/Hook/Hook/AfterRelate.php @@ -0,0 +1,57 @@ +. + * + * 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\RelateOptions; + +/** + * An afterRelate hook. + * + * @template TEntity of Entity = Entity + */ +interface AfterRelate +{ + /** + * Processed after an entity is related with another entity. Called from within a repository. + * + * @param TEntity $entity An entity. + * @param string $relationName A relation name. + * @param array $columnData Middle table role values. + * @param RelateOptions $options Options. + */ + public function afterRelate( + Entity $entity, + string $relationName, + Entity $relatedEntity, + array $columnData, + RelateOptions $options + ): void; +} diff --git a/application/Espo/Core/Hook/Hook/AfterRemove.php b/application/Espo/Core/Hook/Hook/AfterRemove.php new file mode 100644 index 0000000..b155c25 --- /dev/null +++ b/application/Espo/Core/Hook/Hook/AfterRemove.php @@ -0,0 +1,49 @@ +. + * + * 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; + +/** + * An afterRemove hook. + * + * @template TEntity of Entity = Entity + */ +interface AfterRemove +{ + /** + * Processed after an entity is removed from within a repository. + * + * @param TEntity $entity An entity. + * @param RemoveOptions $options Options. + */ + public function afterRemove(Entity $entity, RemoveOptions $options): void; +} diff --git a/application/Espo/Core/Hook/Hook/AfterSave.php b/application/Espo/Core/Hook/Hook/AfterSave.php new file mode 100644 index 0000000..1768d5d --- /dev/null +++ b/application/Espo/Core/Hook/Hook/AfterSave.php @@ -0,0 +1,49 @@ +. + * + * 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; + +/** + * An afterSave hook. + * + * @template TEntity of Entity = Entity + */ +interface AfterSave +{ + /** + * Processed after an entity is saved from within a repository. + * + * @param TEntity $entity An entity. + * @param SaveOptions $options Save options. + */ + public function afterSave(Entity $entity, SaveOptions $options): void; +} diff --git a/application/Espo/Core/Hook/Hook/AfterUnrelate.php b/application/Espo/Core/Hook/Hook/AfterUnrelate.php new file mode 100644 index 0000000..3046620 --- /dev/null +++ b/application/Espo/Core/Hook/Hook/AfterUnrelate.php @@ -0,0 +1,55 @@ +. + * + * 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\UnrelateOptions; + +/** + * An afterUnrelate hook. + * + * @template TEntity of Entity = Entity + */ +interface AfterUnrelate +{ + /** + * Processed after an entity is unrelated from another entity. Called from within a repository. + * + * @param TEntity $entity An entity. + * @param string $relationName A relation name. + * @param UnrelateOptions $options Options. + */ + public function afterUnrelate( + Entity $entity, + string $relationName, + Entity $relatedEntity, + UnrelateOptions $options + ): void; +} diff --git a/application/Espo/Core/Hook/Hook/BeforeRemove.php b/application/Espo/Core/Hook/Hook/BeforeRemove.php new file mode 100644 index 0000000..d15b636 --- /dev/null +++ b/application/Espo/Core/Hook/Hook/BeforeRemove.php @@ -0,0 +1,49 @@ +. + * + * 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; + +/** + * An beforeRemove hook. + * + * @template TEntity of Entity = Entity + */ +interface BeforeRemove +{ + /** + * Processed before an entity is removed from within a repository. + * + * @param TEntity $entity An entity. + * @param RemoveOptions $options Options. + */ + public function beforeRemove(Entity $entity, RemoveOptions $options): void; +} diff --git a/application/Espo/Core/Hook/Hook/BeforeSave.php b/application/Espo/Core/Hook/Hook/BeforeSave.php new file mode 100644 index 0000000..fb56fdb --- /dev/null +++ b/application/Espo/Core/Hook/Hook/BeforeSave.php @@ -0,0 +1,51 @@ +. + * + * 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\Exceptions\PersistenceException; +use Espo\ORM\Repository\Option\SaveOptions; + +/** + * A beforeSave hook. + * + * @template TEntity of Entity = Entity + */ +interface BeforeSave +{ + /** + * Processed before an entity is saved from within a repository. + * + * @param TEntity $entity An entity. + * @param SaveOptions $options Save options. + * @throws PersistenceException + */ + public function beforeSave(Entity $entity, SaveOptions $options): void; +} diff --git a/application/Espo/Core/Hook/Hook/LateAfterRemove.php b/application/Espo/Core/Hook/Hook/LateAfterRemove.php new file mode 100644 index 0000000..8130a09 --- /dev/null +++ b/application/Espo/Core/Hook/Hook/LateAfterRemove.php @@ -0,0 +1,51 @@ +. + * + * 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 10.0.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; +} diff --git a/application/Espo/Core/Hook/Hook/LateAfterSave.php b/application/Espo/Core/Hook/Hook/LateAfterSave.php new file mode 100644 index 0000000..1f03c43 --- /dev/null +++ b/application/Espo/Core/Hook/Hook/LateAfterSave.php @@ -0,0 +1,53 @@ +. + * + * 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 10.0.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; +} diff --git a/application/Espo/Core/HookManager.php b/application/Espo/Core/HookManager.php new file mode 100644 index 0000000..a8a73ad --- /dev/null +++ b/application/Espo/Core/HookManager.php @@ -0,0 +1,193 @@ +. + * + * 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; + +use Espo\Core\Hook\DataProvider; +use Espo\Core\Hook\GeneralInvoker; +use Espo\Core\Utils\Log; + +/** + * Runs hooks. E.g. beforeSave, afterSave. Hooks can be located in a folder + * that matches a certain entity type or in the `Common` folder. + * Common hooks are applied to all entity types. + * + * - `Espo\Hooks\Common\MyHook` – a common hook; + * - `Espo\Hooks\{EntityType}\MyHook` – an entity type specific hook; + * - `Espo\Modules\{ModuleName}\Hooks\{EntityType}\MyHook` – in a module. + * + * @link https://docs.espocrm.com/development/hooks/ + */ +class HookManager +{ + /** @var ?array> */ + private $data = null; + + private bool $isDisabled = false; + + /** @var array */ + private $hookListHash = []; + + /** @var array */ + private $hooks; + + public function __construct( + private InjectableFactory $injectableFactory, + private Log $log, + private GeneralInvoker $generalInvoker, + private DataProvider $dataProvider, + ) {} + + /** + * @param string $scope A scope (entity type). + * @param string $hookName A hook name. + * @param mixed $injection A subject (usually an entity). + * @param array $options Options. + * @param array $hookData Additional hook data. + */ + public function process( + string $scope, + string $hookName, + mixed $injection = null, + array $options = [], + array $hookData = [], + ): void { + + if ($this->isDisabled) { + return; + } + + if ($this->data === null) { + $this->loadHooks(); + } + + $hookList = $this->getHookList($scope, $hookName); + + if ($hookList === []) { + return; + } + + foreach ($hookList as $className) { + if (empty($this->hooks[$className])) { + $this->hooks[$className] = $this->createHookByClassName($className); + } + + $hook = $this->hooks[$className]; + + $this->generalInvoker->invoke( + hook: $hook, + name: $hookName, + subject: $injection, + options: $options, + hookData: $hookData, + ); + } + } + + /** + * Disable hook processing. + */ + public function disable(): void + { + $this->isDisabled = true; + } + + /** + * Enable hook processing. + */ + public function enable(): void + { + $this->isDisabled = false; + } + + private function loadHooks(): void + { + $this->data = $this->dataProvider->get(); + } + + /** + * @param class-string $className + */ + private function createHookByClassName(string $className): object + { + if (!class_exists($className)) { + $this->log->error("Hook class '$className' does not exist."); + } + + return $this->injectableFactory->create($className); + } + + + /** + * Get sorted hook list. + * + * @return class-string[] + */ + private function getHookList(string $scope, string $hookName): array + { + $key = $scope . '_' . $hookName; + + if (!isset($this->hookListHash[$key])) { + $hookList = []; + + if (isset($this->data['Common'][$hookName])) { + $hookList = $this->data['Common'][$hookName]; + } + + if (isset($this->data[$scope][$hookName])) { + $hookList = array_merge($hookList, $this->data[$scope][$hookName]); + + usort($hookList, [$this, 'cmpHooks']); + } + + $normalizedList = []; + + foreach ($hookList as $hookData) { + $normalizedList[] = $hookData['className']; + } + + $this->hookListHash[$key] = $normalizedList; + } + + return $this->hookListHash[$key]; + } + + /** + * @param array $a + * @param array $b + */ + private function cmpHooks($a, $b): int + { + if ($a['order'] == $b['order']) { + return 0; + } + + return ($a['order'] < $b['order']) ? -1 : 1; + } +} diff --git a/application/Espo/Core/Htmlizer/CreateForUserParams.php b/application/Espo/Core/Htmlizer/CreateForUserParams.php new file mode 100644 index 0000000..f3f9dad --- /dev/null +++ b/application/Espo/Core/Htmlizer/CreateForUserParams.php @@ -0,0 +1,46 @@ +. + * + * 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\Htmlizer; + +/** + * @internal + */ +class CreateForUserParams +{ + /** + * @var bool + */ + public $useUserTimezone = false; + + /** + * @var bool + */ + public $applyAcl = false; +} diff --git a/application/Espo/Core/Htmlizer/Helper.php b/application/Espo/Core/Htmlizer/Helper.php new file mode 100644 index 0000000..eaf25d1 --- /dev/null +++ b/application/Espo/Core/Htmlizer/Helper.php @@ -0,0 +1,38 @@ +. + * + * 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\Htmlizer; + +use Espo\Core\Htmlizer\Helper\Data; +use Espo\Core\Htmlizer\Helper\Result; + +interface Helper +{ + public function render(Data $data): Result; +} diff --git a/application/Espo/Core/Htmlizer/Helper/Data.php b/application/Espo/Core/Htmlizer/Helper/Data.php new file mode 100644 index 0000000..746be40 --- /dev/null +++ b/application/Espo/Core/Htmlizer/Helper/Data.php @@ -0,0 +1,121 @@ +. + * + * 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\Htmlizer\Helper; + +use stdClass; +use Closure; + +class Data +{ + /** + * @internal + * @param array $argumentList + * @param array $rootContext + */ + public function __construct( + private string $name, + private array $argumentList, + private stdClass $options, + private mixed $context, + private array $rootContext, + private ?Closure $func, + private ?Closure $inverseFunc, + ) {} + + public function getName(): string + { + return $this->name; + } + + /** + * A scope context. + * + * @return mixed + */ + public function getContext(): mixed + { + return $this->context; + } + + /** + * A root context. + * + * @return array + */ + public function getRootContext(): array + { + return $this->rootContext; + } + + /** + * Specified helper options (parameters). + * + * @return stdClass + */ + public function getOptions(): stdClass + { + return $this->options; + } + + /** + * @return array + */ + public function getArgumentList(): array + { + return $this->argumentList; + } + + /** + * Has a specified helper option (parameter). + */ + public function hasOption(string $name): bool + { + return property_exists($this->options, $name); + } + + /** + * Get the value of a specified helper option (parameter). + */ + public function getOption(string $name): mixed + { + return $this->options->$name ?? null; + } + + public function getFunction(): ?Closure + { + return $this->func; + } + + /** @noinspection PhpUnused */ + public function getInverseFunction(): ?Closure + { + return $this->inverseFunc; + } +} diff --git a/application/Espo/Core/Htmlizer/Helper/Result.php b/application/Espo/Core/Htmlizer/Helper/Result.php new file mode 100644 index 0000000..47f3752 --- /dev/null +++ b/application/Espo/Core/Htmlizer/Helper/Result.php @@ -0,0 +1,76 @@ +. + * + * 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\Htmlizer\Helper; + +class Result +{ + /** + * @var string|SafeString|null + */ + private $value = null; + + private function __construct() {} + + public static function createSafeString(string $value): self + { + $obj = new self(); + $obj->value = new SafeString($value); + + return $obj; + } + + public static function createEmpty(): self + { + $obj = new self(); + $obj->value = ''; + + return $obj; + } + + public static function create(string $value): self + { + $obj = new self(); + $obj->value = $value; + + return $obj; + } + + /** + * @return SafeString|string + */ + public function getValue() + { + if ($this->value instanceof SafeString) { + return $this->value; + } + + return (string) $this->value; + } +} diff --git a/application/Espo/Core/Htmlizer/Helper/SafeString.php b/application/Espo/Core/Htmlizer/Helper/SafeString.php new file mode 100644 index 0000000..ec8ee55 --- /dev/null +++ b/application/Espo/Core/Htmlizer/Helper/SafeString.php @@ -0,0 +1,60 @@ +. + * + * 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\Htmlizer\Helper; + +use DevTheorem\Handlebars\SafeString as SafeStringHandlebars; + +class SafeString +{ + private SafeStringHandlebars $internalSafeString; + + public function __construct(string $value) + { + $this->internalSafeString = new SafeStringHandlebars($value); + } + + public static function create(string $value): self + { + return new self($value); + } + + /** + * @internal + */ + public function getInternal(): SafeStringHandlebars + { + return $this->internalSafeString; + } + + public function __toString() + { + return (string) $this->internalSafeString; + } +} diff --git a/application/Espo/Core/Htmlizer/Htmlizer.php b/application/Espo/Core/Htmlizer/Htmlizer.php new file mode 100644 index 0000000..d9181e2 --- /dev/null +++ b/application/Espo/Core/Htmlizer/Htmlizer.php @@ -0,0 +1,1179 @@ +. + * + * 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\Htmlizer; + +use Closure; +use DevTheorem\Handlebars\Handlebars; +use DevTheorem\Handlebars\HelperOptions; +use DOMDocument; +use DOMElement; +use DOMException; +use DOMXPath; +use Espo\Core\AclManager; +use Espo\Core\Currency\PrecisionProvider; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\Entities\Attachment; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Order; +use Espo\Repositories\Attachment as AttachmentRepository; +use Espo\Core\Utils\Json; +use Espo\Core\Acl; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime; +use Espo\Core\Utils\Language; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\NumberUtil; +use Espo\ORM\Collection; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +use LogicException; +use RuntimeException; +use stdClass; + +use const JSON_PRESERVE_ZERO_FRACTION; + +/** + * Generates an HTML for an entity. Used by Print-to-PDF, system email notifications. + * Not for direct use. Use `TemplateRenderer`. + * @internal + */ +class Htmlizer +{ + private const int LINK_LIMIT = 100; + + public function __construct( + private DateTime $dateTime, + private NumberUtil $number, + private SelectBuilderFactory $selectBuilderFactory, + private User $user, + private EntityManager $entityManager, + private Metadata $metadata, + private Language $language, + private Config $config, + private Log $log, + private InjectableFactory $injectableFactory, + private PrecisionProvider $precisionProvider, + private AclManager $aclManager, + private ?Acl $acl = null, + ) {} + + /** + * Generate an HTML for entity by a given template. + * + * @param ?array $additionalData Data will be passed to the template. + * @param bool $skipLinks Do not process related records. + * @internal + */ + public function render( + ?Entity $entity, + string $template, + ?array $additionalData = null, + bool $skipLinks = false, + bool $skipInlineAttachmentHandling = false, + ): string { + + $helpers = $this->getHelpers(); + + $template = $this->prepare($template, array_keys($helpers)); + + $renderer = Handlebars::compile($template); + + $data = $this->prepareRendererData( + additionalData: $additionalData, + entity: $entity, + skipLinks: $skipLinks, + template: $template, + ); + + $html = $renderer($data, ['helpers' => $helpers]); + + return $this->postProcessHtml($skipInlineAttachmentHandling, $html); + } + + private function format(mixed $value): mixed + { + if (is_float($value)) { + return $this->number->format($value, 2); + } + + if (is_int($value)) { + return $this->number->format($value); + } + + if (is_string($value)) { + return nl2br($value); + } + + return $value; + } + + /** + * @param ?array $additionalData + * @return array + */ + private function getDataFromEntity( + Entity $entity, + bool $skipLinks = false, + int $level = 0, + ?string $template = null, + ?array $additionalData = null, + ): array { + + $data = $this->prepareData($entity, $additionalData); + + if (!$skipLinks && $level === 0 && $entity->hasId()) { + $additionalDataKeys = is_array($additionalData) ? array_keys($additionalData) : []; + + $this->loadRelatedCollections($entity, $template, $data, $additionalDataKeys); + } + + $skipAttributeList = []; + + $this->applyCollections($data, $skipAttributeList, $skipLinks, $level); + + $this->applyValues($entity, $data, $skipAttributeList, $additionalData); + + if (!$skipLinks) { + $this->applyOneLinks($entity, $level, $data); + } + + return $data; + } + + /** + * @param array $data + * @param string[] $skipList + */ + private function loadRelatedCollections(Entity $entity, ?string $template, array &$data, array $skipList): void + { + foreach ($entity->getRelationList() as $relation) { + if (in_array($relation, $skipList)) { + continue; + } + + $collection = $this->loadRelatedCollection($entity, $relation, $template); + + if ($collection) { + $data[$relation] = $collection; + } + } + } + + /** + * @return ?Collection + */ + private function loadRelatedCollection(Entity $entity, string $relation, ?string $template): ?Collection + { + $limit = $this->config->get('htmlizerLinkLimit', self::LINK_LIMIT); + + $orderData = $this->getRelationOrder($entity->getEntityType(), $relation); + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()); + + if ( + $entity instanceof CoreEntity && + $entity->hasLinkMultipleField($relation) && + $entityDefs->hasField($relation) && + !$entityDefs->getField($relation)->getParam('noLoad') + ) { + return $this->entityManager + ->getRDBRepository($entity->getEntityType()) + ->getRelation($entity, $relation) + ->limit(0, $limit) + ->order($orderData) + ->find(); + } + + if ( + $template && + in_array( + $entity->getRelationType($relation), + [ + Entity::HAS_MANY, + Entity::MANY_MANY, + Entity::HAS_CHILDREN, + ] + ) && + mb_stripos($template, '{{#each ' . $relation . '}}') !== false + ) { + $foreignEntityType = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()) + ->getRelation($relation) + ->getForeignEntityType(); + + $selectBuilder = $this->selectBuilderFactory->create(); + + $selectBuilder->from($foreignEntityType); + + if ($this->acl) { + $selectBuilder + ->forUser($this->user) + ->withAccessControlFilter(); + } + + try { + $query = $selectBuilder->build(); + } catch (BadRequest|Forbidden $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + + return $this->entityManager + ->getRDBRepository($entity->getEntityType()) + ->getRelation($entity, $relation) + ->clone($query) + ->limit(0, $limit) + ->order($orderData) + ->find(); + } + + return null; + } + + /** + * @return array + */ + private function getHelpers(): array + { + $helpers = [ + 'and' => function () { + $args = func_get_args(); + + if (count($args) === 1) { + return false; + } + + for ($i = 0; $i < count($args) - 1; $i++) { + $arg = $args[$i]; + + if (!$arg) { + return false; + } + } + + return true; + }, + 'or' => function () { + $args = func_get_args(); + + for ($i = 0; $i < count($args) - 1; $i++) { + $arg = $args[$i]; + + if ($arg) { + return true; + } + } + + return false; + }, + 'not' => function () { + $args = func_get_args(); + + if (count($args) !== 2) { + return false; + } + + $arg = $args[0]; + + return !$arg; + }, + 'equal' => function () { + $args = func_get_args(); + + $arg1 = $args[0] ?? null; + $arg2 = $args[1] ?? null; + + return $arg1 === $arg2; + }, + 'notEqual' => function () { + $args = func_get_args(); + + $arg1 = $args[0] ?? null; + $arg2 = $args[1] ?? null; + + return $arg1 !== $arg2; + }, + 'file' => function () { + $args = func_get_args(); + + $id = $args[0] ?? null; + + if (!$id) { + return ''; + } + + /** @noinspection PhpFullyQualifiedNameUsageInspection */ + return new \DevTheorem\Handlebars\SafeString("?entryPoint=attachment&id=" . $id); + }, + 'pagebreak' => function () { + /** @noinspection PhpFullyQualifiedNameUsageInspection */ + /** @noinspection HtmlUnknownAttribute */ + return new \DevTheorem\Handlebars\SafeString('
'); + }, + 'imageTag' => function () { + $args = func_get_args(); + + $context = $args[count($args) - 1]; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + $field = $context->hash['field'] ?? null; + + $id = null; + + if ($field) { + $id = $context->scope[$field . 'Id'] ?? null; + } else if (count($args) > 1) { + $id = $args[0]; + } + + if (!$id || !is_string($id)) { + return null; + } + + $width = $context->hash['width'] ?? null; + $height = $context->hash['height'] ?? null; + + $attributesPart = ""; + + if ($width) { + $attributesPart .= " width=\"$width\""; + } + + if ($height) { + $attributesPart .= " height=\"$height\""; + } + + /** @noinspection HtmlRequiredAltAttribute */ + $html = ""; + + /** @noinspection PhpFullyQualifiedNameUsageInspection */ + return new \DevTheorem\Handlebars\SafeString($html); + }, + 'var' => function () { + $args = func_get_args(); + + $object = $args[1] ?? []; + $key = $args[0] ?? null; + + if (is_null($key)) { + return null; + } + + return $object[$key] ?? null; + }, + 'numberFormat' => function () { + $args = func_get_args(); + + if (count($args) !== 2) { + return null; + } + + $context = $args[count($args) - 1]; + $number = $args[0] ?? null; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + if (is_null($number)) { + return ''; + } + + $decimals = $context->hash['decimals'] ?? 0; + $decimalPoint = $context->hash['decimalPoint'] ?? '.'; + $thousandsSeparator = $context->hash['thousandsSeparator'] ?? ','; + + return number_format((float) $number, $decimals, $decimalPoint, $thousandsSeparator); + }, + 'dateFormat' => function () { + $args = func_get_args(); + + if (count($args) !== 2) { + return null; + } + + $context = $args[count($args) - 1]; + $dateValue = $args[0]; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + $format = $context->hash['format'] ?? null; + $timezone = $context->hash['timezone'] ?? null; + $language = $context->hash['language'] ?? null; + + $dateTime = $this->dateTime; + + if (strlen($dateValue) > 11) { + return $dateTime->convertSystemDateTime($dateValue, $timezone, $format, $language); + } + + return $dateTime->convertSystemDate($dateValue, $format, $language); + }, + 'barcodeImage' => function () { + $args = func_get_args(); + + if (count($args) !== 2) { + return null; + } + + $context = $args[count($args) - 1]; + $value = $args[0]; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + $params = $context->hash; + $params['value'] = $value; + + /** @phpstan-ignore-next-line */ + $paramsString = urlencode(json_encode($params)); + + /** @noinspection PhpFullyQualifiedNameUsageInspection */ + return new \DevTheorem\Handlebars\SafeString(""); + }, + 'ifEqual' => function () { + $args = func_get_args(); + $context = $args[count($args) - 1]; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + if ($args[0] === $args[1]) { + return $context->fn(); + } + + return $context->inverse(); + }, + 'ifNotEqual' => function () { + $args = func_get_args(); + + $context = $args[count($args) - 1]; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + if ($args[0] !== $args[1]) { + return $context->fn(); + } + + return $context->inverse(); + }, + 'ifInArray' => function () { + $args = func_get_args(); + + $context = $args[count($args) - 1]; + $array = $args[1] ?? []; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + if (in_array($args[0], $array)) { + return $context->fn(); + } + + return $context->inverse(); + }, + 'ifMultipleOf' => function () { + $args = func_get_args(); + + $context = $args[count($args) - 1]; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + if ($args[0] % $args[1] === 0) { + return $context->fn(); + } + + return $context->inverse(); + }, + 'checkboxTag' => function () { + $args = func_get_args(); + + $context = $args[count($args) - 1]; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + if (count($args) < 2) { + return null; + } + + $color = $context->hash['color'] ?? '#000'; + $option = $context->hash['option'] ?? null; + + if (is_null($option)) { + return null; + } + + $option = strval($option); + + $list = $args[0] ?? []; + + if (!is_array($list)) { + return null; + } + + $css = "font-family: zapfdingbats; color: $color"; + + if (in_array($option, $list)) { + $html = + ''; + } else { + $html = ''; + } + + /** @noinspection PhpFullyQualifiedNameUsageInspection */ + return new \DevTheorem\Handlebars\SafeString($html); + }, + ]; + + $customHelper = function () { + $args = func_get_args(); + $argumentList = array_slice($args, 0, -1); + $context = $args[count($args) - 1]; + + if (!$context instanceof HelperOptions) { + throw new RuntimeException(); + } + + $hash = $context->hash; + $rootData = $context->data['root']; + + $injectableFactory = $this->injectableFactory; + $metadata = $this->metadata; + + $name = $context->name; + + /** @var class-string $className */ + $className = $metadata->get("app.templateHelpers.$name"); + + // Not using FQN deliberately. + /** @noinspection PhpFullyQualifiedNameUsageInspection */ + $data = new \Espo\Core\Htmlizer\Helper\Data( + name: $name, + argumentList: $argumentList, + options: (object) $hash, + context: $context->scope, + rootContext: $rootData, + func: fn () => $context->fn(), + inverseFunc: fn () => $context->inverse(), + ); + + $helper = $injectableFactory->create($className); + + $result = $helper->render($data); + + $value = $result->getValue(); + + /** @noinspection PhpFullyQualifiedNameUsageInspection */ + if ($value instanceof \Espo\Core\Htmlizer\Helper\SafeString) { + return $value->getInternal(); + } + + return $value; + }; + + $additionalHelpers = array_filter( + $this->metadata->get(['app', 'templateHelpers']) ?? [], + function (string $item) { + return str_contains($item, '::'); + } + ); + + $helpers = array_merge($helpers, $additionalHelpers); + + $additionalHelper2NameList = array_keys( + array_filter( + $this->metadata->get(['app', 'templateHelpers']) ?? [], + function (string $item) { + return !str_contains($item, '::'); + } + ) + ); + + foreach ($additionalHelper2NameList as $name) { + $helpers[$name] = $customHelper; + } + + return $helpers; + } + + private function getFieldType(string $entityType, string $field): ?string + { + return $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']); + } + + /** + * @return array + */ + private function getRelationOrder(string $entityType, string $relation): array + { + $relationDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->getRelation($relation); + + if ($relationDefs->getParam('orderBy')) { + $orderBy = $relationDefs->getParam('orderBy'); + $order = $relationDefs->getParam('order') ?? Order::ASC; + + return [[$orderBy, $order]]; + } + + if (!$relationDefs->hasForeignEntityType()) { + return []; + } + + $foreignEntityType = $relationDefs->getForeignEntityType(); + + $collectionParams = $this->entityManager + ->getDefs() + ->getEntity($foreignEntityType) + ->getParam('collection') ?? []; + + $order = $collectionParams['order'] ?? null; + $orderBy = $collectionParams['orderBy'] ?? null; + + if ($order === null && $orderBy === null) { + return []; + } + + if ($orderBy === null) { + $orderBy = Attribute::ID; + } + + return [[$orderBy, $order]]; + } + + /** + * @param string[] $helpers + */ + private function handleAttributeHelper(string $template, string $attribute, string $helper, array $helpers): string + { + if ($template === '') { + return $template; + } + + if (!extension_loaded('dom')) { + $this->log->warning("Extension 'dom' is not enabled. HTML templating functionality is restricted."); + + return $template; + } + + $xml = new DOMDocument(); + + $templateModified = "" . $template . ""; + + $loadResult = $xml->loadHTML($templateModified); + + if ($loadResult === false) { + $this->log->warning("HTML template parsing error."); + + return $template; + } + + $xpath = new DOMXPath($xml); + + $found = false; + + $elements = $xpath->query("//*[@$attribute]"); + + if (!$elements) { + return $template; + } + + foreach ($elements as $element) { + if (!$element instanceof DOMElement) { + continue; + } + + try { + $wrapperElement = $xml->createElement("$attribute-wrapper"); + + if (!$wrapperElement) { + throw new LogicException(); + } + + $wrapperElement->setAttribute('v', $element->getAttribute($attribute)); + } catch (DOMException $e) { + throw new LogicException($e->getMessage()); + } + + $parentNode = $element->parentNode; + + if (!$parentNode) { + throw new LogicException(); + } + + $newElement = $xml->importNode($element->cloneNode(true)); + + if (!$newElement instanceof DOMElement) { + throw new LogicException(); + } + + $newElement->removeAttribute($attribute); + + $wrapperElement->appendChild($newElement); + $parentNode->replaceChild($wrapperElement, $element); + + $found = true; + } + + if (!$found) { + return $template; + } + + $newTemplate = $xml->saveXML(); + + if ($newTemplate === false || !is_string($newTemplate)) { + $this->log->warning("DOM save error."); + + return $template; + } + + $newTemplate = str_replace("", "{{/$helper}}", $newTemplate); + + $from = strpos($newTemplate,'') + 6; + $to = strrpos($newTemplate, '') - strlen($newTemplate); + + $newTemplate = substr($newTemplate, $from, $to); + + $regExp = '/<' . $attribute . '-wrapper v="{{(.*?)}}">/'; + + $newTemplate = preg_replace_callback($regExp, function ($matches) use ($helpers, $helper) { + $expression = trim($matches[1]); + + $isHelper = false; + + foreach ($helpers as $it) { + if (str_starts_with($expression, $it . ' ')) { + $isHelper = true; + + break; + } + } + + if ($isHelper) { + $expression = "($expression)"; + } + + return "{{#$helper $expression}}"; + }, $newTemplate); + + return $newTemplate ?? ''; + } + + /** + * @param string[] $helpers + */ + private function prepare(string $template, array $helpers): string + { + $template = str_replace('handleAttributeHelper($template, 'iterate', 'each', $helpers); + /** @noinspection PhpUnnecessaryLocalVariableInspection */ + $template = $this->handleAttributeHelper($template, 'x-if', 'if', $helpers); + + return $template; + } + + /** + * @return string[] + */ + private function getForbiddenAttributes(string $entityType): array + { + $restrictedList = $this->aclManager->getScopeRestrictedAttributeList($entityType, [ + Acl\GlobalRestriction::TYPE_FORBIDDEN, + Acl\GlobalRestriction::TYPE_INTERNAL, + Acl\GlobalRestriction::TYPE_ONLY_ADMIN, + ]); + + if (!$this->acl) { + return $restrictedList; + } + + return array_merge( + $restrictedList, + $this->acl->getScopeForbiddenAttributeList($entityType), + ); + } + + /** + * @return string[] + */ + private function getRestrictedLinks(Entity $entity): array + { + return $this->aclManager->getScopeRestrictedLinkList( + $entity->getEntityType(), + [ + Acl\GlobalRestriction::TYPE_FORBIDDEN, + Acl\GlobalRestriction::TYPE_INTERNAL, + Acl\GlobalRestriction::TYPE_ONLY_ADMIN, + ] + ); + } + + /** + * @param array $data + */ + private function applyOneLinks(Entity $entity, int $level, array &$data): void + { + $forbiddenLinkList = $this->getRestrictedLinks($entity); + + foreach ($entity->getRelationList() as $relation) { + if (in_array($relation, $forbiddenLinkList)) { + continue; + } + + $relationType = $entity->getRelationType($relation); + + if ( + $relationType !== Entity::BELONGS_TO && + $relationType !== Entity::BELONGS_TO_PARENT && + $relationType !== Entity::HAS_ONE + ) { + continue; + } + + $relatedEntity = $this->entityManager + ->getRelation($entity, $relation) + ->findOne(); + + if (!$relatedEntity) { + continue; + } + + if ($this->acl && !$this->acl->tryCheck($relatedEntity, Acl\Table::ACTION_READ)) { + continue; + } + + $data[$relation] = $this->getDataFromEntity($relatedEntity, true, $level + 1); + } + } + + /** + * @param array $data + * @param string[] $skipAttributeList + * @param ?array $additionalData + */ + private function applyValues(Entity $entity, array &$data, array $skipAttributeList, ?array $additionalData): void + { + $forbiddenAttributeList = $this->getForbiddenAttributes($entity->getEntityType()); + + foreach ($entity->getAttributeList() as $attribute) { + // Should be before unsetting. Additional data is already applied to the data. + if ($additionalData && array_key_exists($attribute, $additionalData)) { + continue; + } + + if (in_array($attribute, $forbiddenAttributeList)) { + unset($data[$attribute]); + + continue; + } + + if (in_array($attribute, $skipAttributeList)) { + continue; + } + + $type = $entity->getAttributeType($attribute); + + $fieldType = null; + + if ($entity instanceof CoreEntity) { + $fieldType = $entity->getAttributeParam($attribute, 'fieldType'); + } + + if ($type == Entity::DATETIME) { + if (!empty($data[$attribute])) { + $data[$attribute . '_RAW'] = $data[$attribute]; + $data[$attribute] = $this->dateTime->convertSystemDateTime($data[$attribute]); + } + } else if ($type == Entity::DATE) { + if (!empty($data[$attribute])) { + $data[$attribute . '_RAW'] = $data[$attribute]; + $data[$attribute] = $this->dateTime->convertSystemDate($data[$attribute]); + } + } else if ($type == Entity::JSON_ARRAY) { + if (!empty($data[$attribute])) { + $list = $data[$attribute]; + + $newList = []; + + foreach ($list as $item) { + $v = $item; + + if ($item instanceof stdClass) { + $v = json_decode( + Json::encode($v, JSON_PRESERVE_ZERO_FRACTION), + true + ); + } + + if (is_array($v)) { + foreach ($v as $k => $w) { + $keyRaw = $k . '_RAW'; + + $v[$keyRaw] = $w; + $v[$k] = $this->format($v[$k]); + } + } + + $newList[] = $v; + } + $data[$attribute] = $newList; + } + } else if ($type == Entity::JSON_OBJECT) { + if (!empty($data[$attribute])) { + $value = $data[$attribute]; + + if ($value instanceof stdClass) { + $data[$attribute] = json_decode( + Json::encode($value, JSON_PRESERVE_ZERO_FRACTION), + true + ); + } + + foreach ($data[$attribute] as $k => $w) { + $keyRaw = $k . '_RAW'; + + $data[$attribute][$keyRaw] = $data[$attribute][$k]; + $data[$attribute][$k] = $this->format($data[$attribute][$k]); + } + } + } else if ($type === Entity::PASSWORD) { + unset($data[$attribute]); + } + + if ( + $fieldType === FieldType::CURRENCY && + $entity instanceof CoreEntity && + $entity->getAttributeParam($attribute, 'attributeRole') === 'currency' + ) { + $currencyValue = $data[$attribute] ?? null; + + if ($currencyValue) { + $data[$attribute . 'Symbol'] = + $this->metadata->get(['app', 'currency', 'symbolMap', $currencyValue]); + } + } + + if (!array_key_exists($attribute, $data)) { + continue; + } + + $keyRaw = $attribute . '_RAW'; + + if (!isset($data[$keyRaw])) { + $data[$keyRaw] = $data[$attribute]; + } + + $fieldType = $this->getFieldType($entity->getEntityType(), $attribute); + + if ($fieldType === FieldType::ENUM) { + $data[$attribute] = $this->language->translateOption( + $data[$attribute], $attribute, $entity->getEntityType() + ); + + $translationPath = $this->metadata + ->get(['entityDefs', $entity->getEntityType(), 'fields', $attribute, 'translation']); + + if ($translationPath && $data[$keyRaw] !== null) { + $path = $translationPath . '.' . $data[$keyRaw]; + + $data[$attribute] = $this->language->get($path, $data[$keyRaw]); + } + } + + if ($fieldType === FieldType::CURRENCY) { + $code = $data[$attribute . 'Currency'] ?? null; + + if (!is_string($code)) { + $code = null; + } + + $data[$attribute] = $this->formatCurrencyValue($data[$attribute], $code); + } + + $data[$attribute] = $this->format($data[$attribute]); + } + } + + /** + * @param array $data + * @param string[] $skipAttributeList + */ + private function applyCollections(array &$data, array &$skipAttributeList, bool $skipLinks, int $level): void + { + foreach ($data as $key => $value) { + if (!$value instanceof Collection) { + continue; + } + + $skipAttributeList[] = $key; + + /** @var iterable $collection */ + $collection = $value; + + $list = []; + + foreach ($collection as $item) { + $list[] = $this->getDataFromEntity($item, $skipLinks, $level + 1); + } + + $data[$key] = $list; + } + } + + /** + * @param ?array $additionalData + * @return array + */ + private function prepareData(Entity $entity, ?array $additionalData): array + { + $data = get_object_vars($entity->getValueMap()); + + if ($additionalData) { + foreach ($additionalData as $k => $value) { + $data[$k] = $value; + } + } + + return $data; + } + + private function formatCurrencyValue(mixed $value, ?string $code): ?string + { + if (!is_string($value) && !is_int($value) && !is_float($value)) { + return null; + } + + if (is_string($value) && !is_numeric($value)) { + return null; + } + + $precision = $this->precisionProvider->get($code); + + return $this->number->format($value, $precision); + } + + /** + * @param ?array $additionalData + * @return array + */ + private function prepareRendererData( + ?array $additionalData, + ?Entity $entity, + bool $skipLinks, + string $template, + ): array { + + if ($additionalData === null) { + $additionalData = []; + } + + $data = $entity ? + $this->getDataFromEntity($entity, $skipLinks, 0, $template, $additionalData) : + $additionalData; + + if (!array_key_exists('today', $data)) { + $data['today'] = $this->dateTime->getTodayString(); + $data['today_RAW'] = $this->dateTime->getTodayString(null, DateTime::SYSTEM_DATE_FORMAT); + } + + if (!array_key_exists('now', $data)) { + $data['now'] = $this->dateTime->getNowString(); + $data['now_RAW'] = date(DateTime::SYSTEM_DATE_TIME_FORMAT); + } + + $data['__entityType'] = $entity?->getEntityType(); + + return $data; + } + + private function postProcessHtml(bool $skipInlineAttachmentHandling, string $html): string + { + if (!$skipInlineAttachmentHandling) { + $html = str_replace('?entryPoint=attachment&', '?entryPoint=attachment&', $html); + } + + if (!$skipInlineAttachmentHandling) { + /** @var string $html */ + $html = preg_replace_callback( + '/\?entryPoint=attachment&id=([A-Za-z0-9\-]*)/', + function ($matches) { + $id = $matches[1]; + + if (!$id) { + return ''; + } + + /** @var Attachment $attachment */ + $attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id); + + if (!$attachment) { + return ''; + } + + /** @var AttachmentRepository $repository */ + $repository = $this->entityManager->getRepository(Attachment::ENTITY_TYPE); + + return $repository->getFilePath($attachment); + }, + $html + ); + } + + return $html; + } +} diff --git a/application/Espo/Core/Htmlizer/HtmlizerFactory.php b/application/Espo/Core/Htmlizer/HtmlizerFactory.php new file mode 100644 index 0000000..a6156db --- /dev/null +++ b/application/Espo/Core/Htmlizer/HtmlizerFactory.php @@ -0,0 +1,90 @@ +. + * + * 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\Htmlizer; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\DateTime\DateTimeFactory; +use Espo\Core\AclManager; +use Espo\Entities\User; + +/** + * Not for direct use. Use `TemplateRenderer`. + * @internal + */ +class HtmlizerFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private DateTimeFactory $dateTimeFactory, + private AclManager $aclManager + ) {} + + public function create(bool $skipAcl = false, ?string $timeZone = null): Htmlizer + { + $with = []; + + if ($skipAcl) { + $with['acl'] = null; + } + + if ($timeZone) { + $with['dateTime'] = $this->dateTimeFactory->createWithTimeZone($timeZone); + } + + return $this->injectableFactory->createWith(Htmlizer::class, $with); + } + + public function createNoAcl(): Htmlizer + { + return $this->create(true); + } + + public function createForUser(User $user, ?CreateForUserParams $params = null): Htmlizer + { + if (!$params) { + $params = new CreateForUserParams(); + $params->useUserTimezone = true; + $params->applyAcl = true; + } + + $deps = []; + + if ($params->useUserTimezone) { + $deps['dateTime'] = $this->dateTimeFactory->createWithUserTimeZone($user); + } + + if ($params->applyAcl) { + $deps['acl'] = $this->aclManager->createUserAcl($user); + $deps['user'] = $user; + } + + return $this->injectableFactory->createWith(Htmlizer::class, $deps); + } +} diff --git a/application/Espo/Core/Htmlizer/TemplateRenderer.php b/application/Espo/Core/Htmlizer/TemplateRenderer.php new file mode 100644 index 0000000..fd96cdb --- /dev/null +++ b/application/Espo/Core/Htmlizer/TemplateRenderer.php @@ -0,0 +1,180 @@ +. + * + * 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\Htmlizer; + +use Espo\Core\ApplicationState; +use Espo\ORM\Entity; +use Espo\Entities\User; + +use stdClass; +use InvalidArgumentException; +use LogicException; + +class TemplateRenderer +{ + /** @var ?array */ + private $data = null; + private ?User $user = null; + private ?Entity $entity = null; + private bool $skipRelations = false; + private bool $skipInlineAttachmentHandling = false; + private bool $applyAcl = false; + private bool $useUserTimezone = false; + private HtmlizerFactory $htmlizerFactory; + private ApplicationState $applicationState; + private ?string $template = null; + + public function __construct(HtmlizerFactory $htmlizerFactory, ApplicationState $applicationState) + { + $this->htmlizerFactory = $htmlizerFactory; + $this->applicationState = $applicationState; + } + + public function setUser(User $user): self + { + $this->user = $user; + + return $this; + } + + public function setEntity(Entity $entity): self + { + $this->entity = $entity; + + return $this; + } + + /** + * @param stdClass|array $data Additional data. + */ + public function setData($data): self + { + /** @var mixed $data */ + + if (!is_array($data) && !$data instanceof stdClass) { + throw new InvalidArgumentException(); + } + + if (is_object($data)) { + $data = get_object_vars($data); + } + + $this->data = $data; + + return $this; + } + + public function setSkipRelations(bool $skipRelations = true): self + { + $this->skipRelations = $skipRelations; + + return $this; + } + + public function setSkipInlineAttachmentHandling(bool $skipInlineAttachmentHandling = true): self + { + $this->skipInlineAttachmentHandling = $skipInlineAttachmentHandling; + + return $this; + } + public function setApplyAcl(bool $applyAcl = true): self + { + $this->applyAcl = $applyAcl; + + return $this; + } + + public function setUseUserTimezone(bool $useUserTimezone = true): self + { + $this->useUserTimezone = $useUserTimezone; + + return $this; + } + + public function setTemplate(string $template): self + { + $this->template = $template; + + return $this; + } + + public function render(): string + { + if (!$this->template) { + throw new LogicException("No template."); + } + + return $this->renderTemplate($this->template); + } + + public function renderTemplate(string $template): string + { + return $this->renderTemplateInternal($template, $this->createHtmlizer()); + } + + private function renderTemplateInternal(string $template, Htmlizer $htmlizer): string + { + return $htmlizer->render( + $this->entity, + $template, + $this->data, + $this->skipRelations, + $this->skipInlineAttachmentHandling + ); + } + + /** + * @return string[] + */ + public function renderMultipleTemplates(string ...$templateList): array + { + $htmlizer = $this->createHtmlizer(); + + $resultList = []; + + foreach ($templateList as $template) { + $resultList[] = $this->renderTemplateInternal($template, $htmlizer); + } + + return $resultList; + } + + private function createHtmlizer(): Htmlizer + { + $user = $this->user ?? $this->applicationState->getUser(); + + $params = new CreateForUserParams(); + + $params->applyAcl = $this->applyAcl; + $params->useUserTimezone = $this->useUserTimezone; + + return $this->htmlizerFactory->createForUser($user, $params); + } +} diff --git a/application/Espo/Core/Htmlizer/TemplateRendererFactory.php b/application/Espo/Core/Htmlizer/TemplateRendererFactory.php new file mode 100644 index 0000000..0cd2b8c --- /dev/null +++ b/application/Espo/Core/Htmlizer/TemplateRendererFactory.php @@ -0,0 +1,47 @@ +. + * + * 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\Htmlizer; + +use Espo\Core\InjectableFactory; + +class TemplateRendererFactory +{ + private $injectableFactory; + + public function __construct(InjectableFactory $injectableFactory) + { + $this->injectableFactory = $injectableFactory; + } + + public function create(): TemplateRenderer + { + return $this->injectableFactory->create(TemplateRenderer::class); + } +} diff --git a/application/Espo/Core/HttpClient/Client.php b/application/Espo/Core/HttpClient/Client.php new file mode 100644 index 0000000..225bf10 --- /dev/null +++ b/application/Espo/Core/HttpClient/Client.php @@ -0,0 +1,185 @@ +. + * + * 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; + +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 + { + $client = $this->prepareGuzzleClient(); + + try { + return $client->send($request); + } catch (GuzzleHttp\Exception\ConnectException $e) { + Util::handleConnectException($e); + } catch (GuzzleHttp\Exception\TooManyRedirectsException $e) { + throw new TooManyRedirectsException(previous: $e); + } catch (GuzzleHttp\Exception\GuzzleException $e) { + throw new RuntimeException(previous: $e); + } + } + + /** + * Send a request in async. + */ + public function sendAsync(RequestInterface $request): Promise + { + $client = $this->prepareGuzzleClient(); + + $promise = $client->sendAsync($request); + + return new Promise($promise); + } + + /** + * @param string[] $allowed + * @throws NotAllowedInternalHost + */ + private function checkUrl(string $url, array $allowed): void + { + if ( + !Util::matchUrlToAddressList($url, $allowed) && + !$this->urlCheck->isUrlAndNotInternal($url) + ) { + throw new NotAllowedInternalHost("Not allowed internal host in '$url'."); + } + } + + /** + * @return array + */ + private function prepareOptions(): array + { + $options = [ + 'protocols' => array_map( + fn (Protocol $protocol) => $protocol->value, + $this->options->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; + } + + $stack = GuzzleHttp\HandlerStack::create(new GuzzleHttp\Handler\CurlHandler()); + + if ($this->options->internalHostRestriction->restrict) { + $stack->push( + GuzzleHttp\Middleware::mapRequest(function (RequestInterface $request) { + $url = (string) $request->getUri(); + + $this->checkUrl($url, $this->options->internalHostRestriction->allowed); + + return $request; + }) + ); + + $stack->push(function (callable $handler) { + return function (RequestInterface $request, array $options) use ($handler) { + $url = (string) $request->getUri(); + + $resolve = $this->urlCheck->getCurlResolve($url); + + if ($resolve === []) { + throw new NotAllowedInternalHost("Could not resolve host for '$url'."); + } + + $allowed = $this->options->internalHostRestriction->allowed; + + if ($resolve !== null && !$this->urlCheck->validateCurlResolveNotInternal($resolve, $allowed)) { + throw new NotAllowedInternalHost("Not allowed internal host in '$url'."); + } + + if ($resolve) { + $options['curl'] ??= []; + $options['curl'][CURLOPT_RESOLVE] = $resolve; + } + + return $handler($request, $options); + }; + }); + } + + $options['handler'] = $stack; + + return $options; + } + + private function prepareGuzzleClient(): GuzzleHttp\Client + { + $options = $this->prepareOptions(); + + return new GuzzleHttp\Client($options); + } +} diff --git a/application/Espo/Core/HttpClient/ClientFactory.php b/application/Espo/Core/HttpClient/ClientFactory.php new file mode 100644 index 0000000..a997ce3 --- /dev/null +++ b/application/Espo/Core/HttpClient/ClientFactory.php @@ -0,0 +1,54 @@ +. + * + * 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 10.0.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); + } +} diff --git a/application/Espo/Core/HttpClient/ConnectErrorReason.php b/application/Espo/Core/HttpClient/ConnectErrorReason.php new file mode 100644 index 0000000..a3c1b84 --- /dev/null +++ b/application/Espo/Core/HttpClient/ConnectErrorReason.php @@ -0,0 +1,35 @@ +. + * + * 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; +} diff --git a/application/Espo/Core/HttpClient/Exceptions/ConnectException.php b/application/Espo/Core/HttpClient/Exceptions/ConnectException.php new file mode 100644 index 0000000..cfc67c9 --- /dev/null +++ b/application/Espo/Core/HttpClient/Exceptions/ConnectException.php @@ -0,0 +1,54 @@ +. + * + * 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; + } +} diff --git a/application/Espo/Core/HttpClient/Exceptions/NotAllowedInternalHost.php b/application/Espo/Core/HttpClient/Exceptions/NotAllowedInternalHost.php new file mode 100644 index 0000000..ec40e7d --- /dev/null +++ b/application/Espo/Core/HttpClient/Exceptions/NotAllowedInternalHost.php @@ -0,0 +1,34 @@ +. + * + * 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 +{ +} diff --git a/application/Espo/Core/HttpClient/Exceptions/SendException.php b/application/Espo/Core/HttpClient/Exceptions/SendException.php new file mode 100644 index 0000000..db1d227 --- /dev/null +++ b/application/Espo/Core/HttpClient/Exceptions/SendException.php @@ -0,0 +1,35 @@ +. + * + * 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 +{} diff --git a/application/Espo/Core/HttpClient/Exceptions/TooManyRedirectsException.php b/application/Espo/Core/HttpClient/Exceptions/TooManyRedirectsException.php new file mode 100644 index 0000000..96177eb --- /dev/null +++ b/application/Espo/Core/HttpClient/Exceptions/TooManyRedirectsException.php @@ -0,0 +1,33 @@ +. + * + * 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 +{} diff --git a/application/Espo/Core/HttpClient/Options.php b/application/Espo/Core/HttpClient/Options.php new file mode 100644 index 0000000..2d31ac0 --- /dev/null +++ b/application/Espo/Core/HttpClient/Options.php @@ -0,0 +1,50 @@ +. + * + * 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(), + ) {} +} diff --git a/application/Espo/Core/HttpClient/Options/InternalHostRestriction.php b/application/Espo/Core/HttpClient/Options/InternalHostRestriction.php new file mode 100644 index 0000000..26dfc20 --- /dev/null +++ b/application/Espo/Core/HttpClient/Options/InternalHostRestriction.php @@ -0,0 +1,41 @@ +. + * + * 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 = [], + ) {} +} diff --git a/application/Espo/Core/HttpClient/Options/Redirect.php b/application/Espo/Core/HttpClient/Options/Redirect.php new file mode 100644 index 0000000..fc5a78f --- /dev/null +++ b/application/Espo/Core/HttpClient/Options/Redirect.php @@ -0,0 +1,47 @@ +. + * + * 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, + ) {} +} diff --git a/application/Espo/Core/HttpClient/Promise.php b/application/Espo/Core/HttpClient/Promise.php new file mode 100644 index 0000000..99de0e0 --- /dev/null +++ b/application/Espo/Core/HttpClient/Promise.php @@ -0,0 +1,83 @@ +. + * + * 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 Closure; +use Espo\Core\HttpClient\Exceptions\ConnectException; +use Espo\Core\HttpClient\Exceptions\TooManyRedirectsException; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp; +use Psr\Http\Message\ResponseInterface; +use RuntimeException; +use UnexpectedValueException; + +class Promise +{ + /** + * @internal + */ + public function __construct( + private PromiseInterface $promise, + ) {} + + /** + * @throws TooManyRedirectsException + * @throws ConnectException + * @internal + */ + public function wait(): ResponseInterface + { + $value = $this->requestCallback(fn () => $this->promise->wait()); + + if (!$value instanceof ResponseInterface) { + throw new UnexpectedValueException(); + } + + return $value; + } + + /** + * @throws TooManyRedirectsException + * @throws ConnectException + * @noinspection PhpRedundantCatchClauseInspection + */ + private function requestCallback(Closure $closure): mixed + { + try { + return $closure(); + } catch (GuzzleHttp\Exception\ConnectException $e) { + Util::handleConnectException($e); + } catch (GuzzleHttp\Exception\TooManyRedirectsException $e) { + throw new TooManyRedirectsException(previous: $e); + } catch (GuzzleHttp\Exception\GuzzleException $e) { + throw new RuntimeException(previous: $e); + } + } +} diff --git a/application/Espo/Core/HttpClient/PromiseUtil.php b/application/Espo/Core/HttpClient/PromiseUtil.php new file mode 100644 index 0000000..b932828 --- /dev/null +++ b/application/Espo/Core/HttpClient/PromiseUtil.php @@ -0,0 +1,52 @@ +. + * + * 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\TooManyRedirectsException; +use Psr\Http\Message\ResponseInterface; + +/** + * @since 10.0.0 + * @noinspection PhpUnused + */ +class PromiseUtil +{ + /** + * @param array $promises + * @return array + * @throws ConnectException + * @throws TooManyRedirectsException + */ + public static function unwrap(array $promises): array + { + return array_map(fn ($promise) => $promise->wait(), $promises); + } +} diff --git a/application/Espo/Core/HttpClient/Protocol.php b/application/Espo/Core/HttpClient/Protocol.php new file mode 100644 index 0000000..e2da6fb --- /dev/null +++ b/application/Espo/Core/HttpClient/Protocol.php @@ -0,0 +1,36 @@ +. + * + * 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'; +} diff --git a/application/Espo/Core/HttpClient/RequestCreator.php b/application/Espo/Core/HttpClient/RequestCreator.php new file mode 100644 index 0000000..7b8c11a --- /dev/null +++ b/application/Espo/Core/HttpClient/RequestCreator.php @@ -0,0 +1,46 @@ +. + * + * 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\Request; +use Psr\Http\Message\RequestInterface; + +class RequestCreator +{ + /** + * Create a request. + * + * @since 10.0.0 + */ + public static function create(string $method, string $url): RequestInterface + { + return new Request($method, $url); + } +} diff --git a/application/Espo/Core/HttpClient/Util.php b/application/Espo/Core/HttpClient/Util.php new file mode 100644 index 0000000..bee9b9a --- /dev/null +++ b/application/Espo/Core/HttpClient/Util.php @@ -0,0 +1,99 @@ +. + * + * 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 GuzzleHttp\Psr7\Utils; +use GuzzleHttp; +use Psr\Http\Message\StreamInterface; + +class Util +{ + /** + * @param resource|string|int|float|bool|StreamInterface $resource + * @since 10.0.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); + } + + /** + * @internal + * @throws ConnectException + */ + public static function handleConnectException(GuzzleHttp\Exception\ConnectException $exception): never + { + $context = $exception->getHandlerContext(); + + $reason = null; + + if (($context['errno'] ?? 0) === CURLE_OPERATION_TIMEDOUT) { + $reason = ConnectErrorReason::Timeout; + } + + throw ConnectException::create(previous: $exception, reason: $reason); + } +} diff --git a/application/Espo/Core/InjectableFactory.php b/application/Espo/Core/InjectableFactory.php new file mode 100644 index 0000000..e88938a --- /dev/null +++ b/application/Espo/Core/InjectableFactory.php @@ -0,0 +1,460 @@ +. + * + * 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; + +use Psr\Container\NotFoundExceptionInterface; + +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\Binding; +use Espo\Core\Binding\Factory; +use Espo\Core\Container\Container; +use ReflectionClass; +use ReflectionException; +use ReflectionParameter; +use ReflectionFunction; +use ReflectionNamedType; +use Throwable; +use RuntimeException; +use Closure; + +/** + * Creates an instance by a class name. Uses constructor param names and type hinting to detect which + * dependencies are needed. Service dependencies are instantiated only once. Non-service dependencies + * are instantiated every time along with a dependent class. + * + * Aware interfaces are also used to detect service dependencies. + */ +class InjectableFactory +{ + public function __construct( + private Container $container, + private ?BindingContainer $bindingContainer = null + ) {} + + /** + * Create an instance by a class name. + * + * @template T of object + * @param class-string $className An instantiatable class. + * @return T + */ + public function create(string $className): object + { + return $this->createInternal($className); + } + + /** + * Create an instance by a class name with specific constructor parameters + * defined in an associative array. A key should match the parameter name. + * + * @template T of object + * @param class-string $className An instantiatable class. + * @param array $with Constructor parameter values. + * @return T + */ + public function createWith(string $className, array $with): object + { + return $this->createInternal($className, $with); + } + + /** + * Create an instance by a class name with a specific binding. + * + * @template T of object + * @param class-string $className An instantiatable class. + * @param BindingContainer $bindingContainer A binding container. + * @return T + */ + public function createWithBinding(string $className, BindingContainer $bindingContainer): object + { + return $this->createInternal($className, null, $bindingContainer); + } + + /** + * Create an instance by an interface with an optional additional binding. + * An interface will be resolved by the global binding. If a class is provided, it will be tried to + * be resolved (if it's bound to an extended class). If the class is not bound, it will be instantiated + * (with the same behavior as with the `createWithBinding` method). + * + * @template T of object + * @param class-string $interfaceName An interface or class. + * @param ?BindingContainer $bindingContainer A binding container. + * @return T + */ + public function createResolved(string $interfaceName, ?BindingContainer $bindingContainer = null): object + { + $binding = $this->bindingContainer && $this->bindingContainer->hasByInterface($interfaceName) ? + $this->bindingContainer->getByInterface($interfaceName) : + null; + + if (!$binding) { + /** @noinspection PhpUnhandledExceptionInspection */ + $class = new ReflectionClass($interfaceName); + + if ($class->isInterface()) { + throw new RuntimeException("Could not resolve interface `$interfaceName`."); + } + + $obj = $this->createInternal($interfaceName, null, $bindingContainer); + + if (!$obj instanceof $interfaceName) { + throw new RuntimeException("Class `$interfaceName` resolved to another type."); + } + + return $obj; + } + + $typeList = [ + Binding::IMPLEMENTATION_CLASS_NAME, + Binding::FACTORY_CLASS_NAME, + Binding::CALLBACK, + ]; + + if (!in_array($binding->getType(), $typeList)) { + throw new RuntimeException("Bad resolution for interface `$interfaceName`."); + } + + $obj = $this->resolveBinding($binding, $bindingContainer); + + if (!$obj instanceof $interfaceName) { + throw new RuntimeException("Class `$interfaceName` resolved to another type."); + } + + /** @noinspection PhpIncompatibleReturnTypeInspection */ + return $obj; + } + + /** + * @template T of object + * @param class-string $className + * @param ?array $with + * @return T + */ + private function createInternal( + string $className, + ?array $with = null, + ?BindingContainer $bindingContainer = null, + ): object { + + if (!class_exists($className)) { + throw new RuntimeException("Class '$className' does not exist."); + } + + $class = new ReflectionClass($className); + + $injectionList = $this->getConstructorInjectionList($class, $with, $bindingContainer); + + try { + $obj = $class->newInstanceArgs($injectionList); + } catch (ReflectionException $e) { + throw new RuntimeException("Reflection error.", previous: $e); + } + + $this->applyAwareInjections($class, $obj); + + return $obj; + } + + /** + * @param ReflectionClass $class + * @param ?array $with + * @return array[] + */ + private function getConstructorInjectionList( + ReflectionClass $class, + ?array $with = null, + ?BindingContainer $bindingContainer = null + ): array { + + $injectionList = []; + + $constructor = $class->getConstructor(); + + if (!$constructor) { + return $injectionList; + } + + $params = $constructor->getParameters(); + + foreach ($params as $param) { + $injectionList[] = $this->getMethodParamInjection($class, $param, $with, $bindingContainer); + } + + return $injectionList; + } + + /** + * @param ?ReflectionClass $class + * @param ?array $with + * @return mixed + */ + private function getMethodParamInjection( + ?ReflectionClass $class, + ReflectionParameter $param, + ?array $with = null, + ?BindingContainer $bindingContainer = null + ) { + + $name = $param->getName(); + + if ($with && array_key_exists($name, $with)) { + return $with[$name]; + } + + $dependencyClass = null; + + $type = $param->getType(); + + /** @noinspection PhpConditionCheckedByNextConditionInspection */ + if ( + $type && + $type instanceof ReflectionNamedType && + !$type->isBuiltin() + ) { + try { + /** @var class-string $dependencyClassName */ + $dependencyClassName = $type->getName(); + + $dependencyClass = new ReflectionClass($dependencyClassName); + } catch (Throwable $e) { + $badClassName = $type->getName(); + + // This trick allows to log syntax errors. + class_exists($badClassName); + + throw new RuntimeException("InjectableFactory: " . $e->getMessage()); + } + } + + if ($bindingContainer && $bindingContainer->hasByParam($class, $param)) { + $binding = $bindingContainer->getByParam($class, $param); + + return $this->resolveBinding($binding, $bindingContainer); + } + + if ($this->bindingContainer && $this->bindingContainer->hasByParam($class, $param)) { + $binding = $this->bindingContainer->getByParam($class, $param); + + return $this->resolveBinding($binding, $bindingContainer); + } + + if (!$dependencyClass && $param->isDefaultValueAvailable()) { + return $param->getDefaultValue(); + } + + if ( + $dependencyClass && $this->container->has($name) && + $this->areDependencyClassesMatching($dependencyClass, $this->container->getClass($name)) + ) { + return $this->container->get($name); + } + + if ($dependencyClass && $param->allowsNull()) { + return null; + } + + if ($dependencyClass) { + return $this->createInternal($dependencyClass->getName(), null, $bindingContainer); + } + + if (!$class) { + throw new RuntimeException( + "InjectableFactory: Could not resolve the dependency '$name' for a callback." + ); + } + + $className = $class->getName(); + + throw new RuntimeException( + "InjectableFactory: Could not create '$className', the dependency '$name' is not resolved." + ); + } + + /** + * @return array[] + */ + private function getCallbackInjectionList(callable $callback): array + { + $injectionList = []; + + if (!$callback instanceof Closure) { + $callback = $callback(...); + } + + try { + $function = new ReflectionFunction($callback); + } catch (ReflectionException $e) { + throw new RuntimeException("Reflection error.", previous: $e); + } + + foreach ($function->getParameters() as $param) { + $injectionList[] = $this->getMethodParamInjection(null, $param); + } + + return $injectionList; + } + + private function resolveBinding(Binding $binding, ?BindingContainer $bindingContainer): mixed + { + $type = $binding->getType(); + $value = $binding->getValue(); + + if ($type === Binding::CONTAINER_SERVICE) { + try { + return $this->container->get($value); + } catch (NotFoundExceptionInterface $e) { + throw new RuntimeException($e->getMessage()); + } + } + + if ($type === Binding::IMPLEMENTATION_CLASS_NAME) { + /** @var class-string $value */ + return $this->createInternal($value, null, $bindingContainer); + } + + if ($type === Binding::VALUE) { + return $value; + } + + if ($type === Binding::CALLBACK) { + $callback = $value; + + $dependencyList = $this->getCallbackInjectionList($callback); + + return $callback(...$dependencyList); + } + + if ($type === Binding::FACTORY_CLASS_NAME) { + /** @var class-string $value */ + /** @var Factory $factory */ + $factory = $this->createInternal($value, null, $bindingContainer); + + return $factory->create(); + } + + throw new RuntimeException("InjectableFactory: Bad binding."); + } + + /** + * @param ReflectionClass $paramHintClass + * @param ReflectionClass $returnHintClass + */ + private function areDependencyClassesMatching( + ReflectionClass $paramHintClass, + ReflectionClass $returnHintClass + ): bool { + + if ($paramHintClass->getName() === $returnHintClass->getName()) { + return true; + } + + if ($returnHintClass->isSubclassOf($paramHintClass)) { + return true; + } + + return false; + } + + /** + * @param ReflectionClass $class + */ + private function applyAwareInjections(ReflectionClass $class, object $obj): void + { + foreach ($class->getInterfaces() as $interface) { + $interfaceName = $interface->getShortName(); + + if (!str_ends_with($interfaceName, 'Aware') || strlen($interfaceName) <= 5) { + continue; + } + + $name = lcfirst(substr($interfaceName, 0, -5)); + + if (!$this->classHasDependencySetter($class, $name, true)) { + continue; + } + + $injection = $this->container->get($name); + + $methodName = 'set' . ucfirst($name); + + $obj->$methodName($injection); + } + } + + /** + * @param ReflectionClass $class + */ + private function classHasDependencySetter( + ReflectionClass $class, + string $name, + bool $skipInstanceCheck = false + ): bool { + + $methodName = 'set' . ucfirst($name); + + if (!$class->hasMethod($methodName) || !$class->getMethod($methodName)->isPublic()) { + return false; + } + + $params = $class->getMethod($methodName)->getParameters(); + + if (!count($params)) { + return false; + } + + if ($skipInstanceCheck) { + return true; + } + + $injection = $this->container->get($name); + + $paramClass = null; + + $type = $params[0]->getType(); + + /** @noinspection PhpConditionCheckedByNextConditionInspection */ + if ( + $type && + $type instanceof ReflectionNamedType && + !$type->isBuiltin() + ) { + /** @var class-string $dependencyClassName */ + $dependencyClassName = $type->getName(); + + $paramClass = new ReflectionClass($dependencyClassName); + } + + if ($paramClass && $paramClass->isInstance($injection)) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Core/Job/AsyncPoolFactory.php b/application/Espo/Core/Job/AsyncPoolFactory.php new file mode 100644 index 0000000..5230d51 --- /dev/null +++ b/application/Espo/Core/Job/AsyncPoolFactory.php @@ -0,0 +1,54 @@ +. + * + * 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\Job; + +use Espo\Core\Utils\Config; + +use Spatie\Async\Pool; + +class AsyncPoolFactory +{ + public function __construct(private Config $config) + {} + + public function isSupported(): bool + { + return Pool::isSupported(); + } + + public function create(): Pool + { + return Pool + ::create() + ->autoload(getcwd() . '/vendor/autoload.php') + ->concurrency($this->config->get('jobPoolConcurrencyNumber')) + ->timeout($this->config->get('jobPeriodForActiveProcess')); + } +} diff --git a/application/Espo/Core/Job/ConfigDataProvider.php b/application/Espo/Core/Job/ConfigDataProvider.php new file mode 100644 index 0000000..8ef3f9e --- /dev/null +++ b/application/Espo/Core/Job/ConfigDataProvider.php @@ -0,0 +1,71 @@ +. + * + * 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\Job; + +use Espo\Core\Utils\Config; + +class ConfigDataProvider +{ + private const MAX_PORTION = 15; + + public function __construct( + private Config $config, + private Config\ApplicationConfig $applicationConfig, + ) {} + + public function runInParallel(): bool + { + return (bool) $this->config->get('jobRunInParallel'); + } + + public function getMaxPortion(): int + { + return (int) $this->config->get('jobMaxPortion', self::MAX_PORTION); + } + + public function getCronMinInterval(): int + { + return (int) $this->config->get('cronMinInterval', 0); + } + + public function noTableLocking(): bool + { + return (bool) $this->config->get('jobNoTableLocking'); + } + + public function getTimeZone(): string + { + if ($this->config->get('jobForceUtc')) { + return 'UTC'; + } + + return $this->applicationConfig->getTimeZone(); + } +} diff --git a/application/Espo/Core/Job/CronUtil.php b/application/Espo/Core/Job/CronUtil.php new file mode 100644 index 0000000..7da33f3 --- /dev/null +++ b/application/Espo/Core/Job/CronUtil.php @@ -0,0 +1,84 @@ +. + * + * 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\Job; + +use Espo\Core\Utils\File\Manager as FileManager; +use RuntimeException; + +/** + * @internal + */ +class CronUtil +{ + protected string $lastRunTimeFile = 'data/cache/application/cronLastRunTime.php'; + + public function __construct( + private FileManager $fileManager, + private ConfigDataProvider $configDataProvider, + ) {} + + public function checkLastRunTime(): bool + { + $currentTime = time(); + $lastRunTime = $this->getLastRunTime(); + + $cronMinInterval = $this->configDataProvider->getCronMinInterval(); + + if ($currentTime > ($lastRunTime + $cronMinInterval)) { + return true; + } + + return false; + } + + private function getLastRunTime(): int + { + if ($this->fileManager->isFile($this->lastRunTimeFile)) { + try { + $data = $this->fileManager->getPhpContents($this->lastRunTimeFile); + } catch (RuntimeException) { + $data = null; + } + + if (is_array($data) && isset($data['time'])) { + return (int) $data['time']; + } + } + + return time() - $this->configDataProvider->getCronMinInterval() - 1; + } + + public function updateLastRunTime(): void + { + $data = ['time' => time()]; + + $this->fileManager->putPhpContents($this->lastRunTimeFile, $data, false, true); + } +} diff --git a/application/Espo/Core/Job/Exceptions/TooFrequentRun.php b/application/Espo/Core/Job/Exceptions/TooFrequentRun.php new file mode 100644 index 0000000..bf10989 --- /dev/null +++ b/application/Espo/Core/Job/Exceptions/TooFrequentRun.php @@ -0,0 +1,35 @@ +. + * + * 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\Job\Exceptions; + +use Exception; + +class TooFrequentRun extends Exception +{} diff --git a/application/Espo/Core/Job/Job.php b/application/Espo/Core/Job/Job.php new file mode 100644 index 0000000..b9d3c48 --- /dev/null +++ b/application/Espo/Core/Job/Job.php @@ -0,0 +1,43 @@ +. + * + * 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\Job; + +use Espo\Core\Job\Job\Data; + +/** + * A job. + */ +interface Job +{ + /** + * Run a job. + */ + public function run(Data $data): void; +} diff --git a/application/Espo/Core/Job/Job/Data.php b/application/Espo/Core/Job/Job/Data.php new file mode 100644 index 0000000..739bd7f --- /dev/null +++ b/application/Espo/Core/Job/Job/Data.php @@ -0,0 +1,114 @@ +. + * + * 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\Job\Job; + +use Espo\Core\Utils\ObjectUtil; + +use TypeError; +use stdClass; + +class Data +{ + private stdClass $data; + private ?string $targetId = null; + private ?string $targetType = null; + + public function __construct(?stdClass $data = null) + { + $this->data = $data ?? (object) []; + } + + /** + * Create an instance. + * + * @param stdClass|array|null $data Raw data. + * @return self + */ + public static function create($data = null): self + { + /** @var mixed $data */ + + if ($data !== null && !is_object($data) && !is_array($data)) { + throw new TypeError(); + } + + if (is_array($data)) { + $data = (object) $data; + } + + /** @var ?stdClass $data */ + + return new self($data); + } + + public function getRaw(): stdClass + { + return ObjectUtil::clone($this->data); + } + + /** + * @return mixed + */ + public function get(string $name) + { + return $this->getRaw()->$name ?? null; + } + + public function has(string $name): bool + { + return property_exists($this->data, $name); + } + + public function getTargetId(): ?string + { + return $this->targetId; + } + + public function getTargetType(): ?string + { + return $this->targetType; + } + + public function withTargetId(?string $targetId): self + { + $obj = clone $this; + $obj->targetId = $targetId; + + return $obj; + } + + public function withTargetType(?string $targetType): self + { + $obj = clone $this; + $obj->targetType = $targetType; + + return $obj; + } +} diff --git a/application/Espo/Core/Job/Job/Jobs/AbstractQueueJob.php b/application/Espo/Core/Job/Job/Jobs/AbstractQueueJob.php new file mode 100644 index 0000000..05f9372 --- /dev/null +++ b/application/Espo/Core/Job/Job/Jobs/AbstractQueueJob.php @@ -0,0 +1,51 @@ +. + * + * 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\Job\Job\Jobs; + +use Espo\Core\Job\JobDataLess; +use Espo\Core\Job\JobManager; +use Espo\Core\Job\QueuePortionNumberProvider; + +abstract class AbstractQueueJob implements JobDataLess +{ + protected string $queue; + + public function __construct( + private JobManager $jobManager, + private QueuePortionNumberProvider $portionNumberProvider) + {} + + public function run(): void + { + $limit = $this->portionNumberProvider->get($this->queue); + + $this->jobManager->processQueue($this->queue, $limit); + } +} diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobGroup.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobGroup.php new file mode 100644 index 0000000..c07d077 --- /dev/null +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobGroup.php @@ -0,0 +1,54 @@ +. + * + * 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\Job\Job\Jobs; + +use Espo\Core\Utils\Config; +use Espo\Core\Job\Job; +use Espo\Core\Job\Job\Data; +use Espo\Core\Job\JobManager; + +class ProcessJobGroup implements Job +{ + private const PORTION_NUMBER = 100; + + public function __construct( + private JobManager $jobManager, + private Config $config + ) {} + + public function run(Data $data): void + { + $limit = $this->config->get('jobGroupMaxPortion') ?? self::PORTION_NUMBER; + + $group = $data->get('group'); + + $this->jobManager->processGroup($group, $limit); + } +} diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php new file mode 100644 index 0000000..e0cdfba --- /dev/null +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueE0.php @@ -0,0 +1,39 @@ +. + * + * 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\Job\Job\Jobs; + +use Espo\Core\Job\QueueName; + +class ProcessJobQueueE0 extends AbstractQueueJob +{ + protected string $queue = QueueName::E0; + + public const string NAME = 'ProcessJobQueueE0'; +} diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php new file mode 100644 index 0000000..3b059e0 --- /dev/null +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ0.php @@ -0,0 +1,39 @@ +. + * + * 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\Job\Job\Jobs; + +use Espo\Core\Job\QueueName; + +class ProcessJobQueueQ0 extends AbstractQueueJob +{ + protected string $queue = QueueName::Q0; + + public const string NAME = 'ProcessJobQueueQ0'; +} diff --git a/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php new file mode 100644 index 0000000..bdf1d63 --- /dev/null +++ b/application/Espo/Core/Job/Job/Jobs/ProcessJobQueueQ1.php @@ -0,0 +1,39 @@ +. + * + * 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\Job\Job\Jobs; + +use Espo\Core\Job\QueueName; + +class ProcessJobQueueQ1 extends AbstractQueueJob +{ + protected string $queue = QueueName::Q1; + + public const string NAME = 'ProcessJobQueueQ1'; +} diff --git a/application/Espo/Core/Job/Job/Status.php b/application/Espo/Core/Job/Job/Status.php new file mode 100644 index 0000000..6132731 --- /dev/null +++ b/application/Espo/Core/Job/Job/Status.php @@ -0,0 +1,39 @@ +. + * + * 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\Job\Job; + +class Status +{ + public const PENDING = 'Pending'; + public const READY = 'Ready'; + public const RUNNING = 'Running'; + public const SUCCESS = 'Success'; + public const FAILED = 'Failed'; +} diff --git a/application/Espo/Core/Job/JobDataLess.php b/application/Espo/Core/Job/JobDataLess.php new file mode 100644 index 0000000..6e45d42 --- /dev/null +++ b/application/Espo/Core/Job/JobDataLess.php @@ -0,0 +1,41 @@ +. + * + * 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\Job; + +/** + * A job w/o data. + */ +interface JobDataLess +{ + /** + * Run a job. + */ + public function run(): void; +} diff --git a/application/Espo/Core/Job/JobFactory.php b/application/Espo/Core/Job/JobFactory.php new file mode 100644 index 0000000..6aee738 --- /dev/null +++ b/application/Espo/Core/Job/JobFactory.php @@ -0,0 +1,83 @@ +. + * + * 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\Job; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\ClassFinder; +use RuntimeException; + +class JobFactory +{ + public function __construct( + private ClassFinder $classFinder, + private InjectableFactory $injectableFactory, + private MetadataProvider $metadataProvider, + ) {} + + /** + * Create a job by a scheduled job name. + */ + public function create(string $name): Job|JobDataLess + { + $className = $this->getClassName($name); + + if (!$className) { + throw new RuntimeException("Job '$name' not found."); + } + + return $this->createByClassName($className); + } + + /** + * Create a job by a class name. + * + * @param class-string $className + */ + public function createByClassName(string $className): Job|JobDataLess + { + return $this->injectableFactory->create($className); + } + + /** + * @return ?class-string + */ + private function getClassName(string $name): ?string + { + /** @var ?class-string $className */ + $className = $this->metadataProvider->getJobClassName($name); + + if ($className) { + return $className; + } + + /** @var ?class-string */ + return $this->classFinder->find('Jobs', ucfirst($name)); + } +} diff --git a/application/Espo/Core/Job/JobManager.php b/application/Espo/Core/Job/JobManager.php new file mode 100644 index 0000000..d6cec71 --- /dev/null +++ b/application/Espo/Core/Job/JobManager.php @@ -0,0 +1,109 @@ +. + * + * 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\Job; + +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\QueueProcessors\SequentialQueueProcessor; +use Espo\Entities\Job as JobEntity; + +use Throwable; + +/** + * Handles processing jobs. + */ +class JobManager +{ + public function __construct( + private JobRunner $jobRunner, + private QueueProcessor $queueProcessor, + private ConfigDataProvider $configDataProvider, + private SequentialQueueProcessor $sequentialQueueProcessor, + ) {} + + /** + * Process pending jobs from a specific queue. Jobs within a queue are processed one by one. + */ + public function processQueue(string $queue, int $limit): void + { + $params = Params::create() + ->withQueue($queue) + ->withLimit($limit); + + $this->sequentialQueueProcessor->process($params); + } + + /** + * Process pending jobs from a specific group. Jobs within a group are processed one by one. + */ + public function processGroup(string $group, int $limit): void + { + $params = Params::create() + ->withGroup($group) + ->withLimit($limit); + + $this->sequentialQueueProcessor->process($params); + } + + /** + * Process the main job queue. + */ + public function processMainQueue(): void + { + $limit = $this->configDataProvider->getMaxPortion(); + + $params = Params::create() + ->withLimit($limit); + + $params = $params->withSubQueueParamsList([ + $params->withWeight(0.5), + $params->withQueue(QueueName::M0)->withWeight(0.5), + ]); + + $this->queueProcessor->process($params); + } + + /** + * Run a specific job by ID. A job status should be set to 'Ready'. + */ + public function runJobById(string $id): void + { + $this->jobRunner->runById($id); + } + + /** + * Run a specific job. + * + * @throws Throwable + */ + public function runJob(JobEntity $job): void + { + $this->jobRunner->runThrowingException($job); + } +} diff --git a/application/Espo/Core/Job/JobRunner.php b/application/Espo/Core/Job/JobRunner.php new file mode 100644 index 0000000..56d684b --- /dev/null +++ b/application/Espo/Core/Job/JobRunner.php @@ -0,0 +1,226 @@ +. + * + * 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\Job; + +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\System; +use Espo\Core\Job\Job\Data; +use Espo\Core\Job\Job\Status; +use Espo\Entities\Job as JobEntity; +use Espo\ORM\Name\Attribute; +use LogicException; +use RuntimeException; +use Throwable; + +class JobRunner +{ + public function __construct( + private JobFactory $jobFactory, + private ScheduleUtil $scheduleUtil, + private EntityManager $entityManager, + private Log $log, + ) {} + + /** + * Run a job entity. Does not throw exceptions. + */ + public function run(JobEntity $jobEntity): void + { + try { + $this->runInternal($jobEntity); + } catch (Throwable $e) { + throw new LogicException($e->getMessage()); + } + } + + /** + * Run a job entity. Throws exceptions. + * + * @throws Throwable + */ + public function runThrowingException(JobEntity $jobEntity): void + { + $this->runInternal($jobEntity, true); + } + + /** + * Run a job by ID. A job must have status 'Ready'. + * Used when running jobs in parallel processes. + */ + public function runById(string $id): void + { + if ($id === '') { + throw new RuntimeException("Empty job ID."); + } + + $jobEntity = $this->entityManager->getRDBRepositoryByClass(JobEntity::class)->getById($id); + + if (!$jobEntity) { + throw new RuntimeException("Job '$id' not found."); + } + + if ($jobEntity->getStatus() !== Status::READY) { + throw new RuntimeException("Can't run job '$id' with not Ready status."); + } + + $this->setJobRunning($jobEntity); + $this->run($jobEntity); + } + + /** + * @throws Throwable + */ + private function runInternal(JobEntity $jobEntity, bool $throwException = false): void + { + $isSuccess = true; + $exception = null; + + if ($jobEntity->getStatus() !== Status::RUNNING) { + $this->setJobRunning($jobEntity); + } + + try { + if ($jobEntity->getScheduledJobId()) { + $this->runScheduledJob($jobEntity); + } else if ($jobEntity->getJob()) { + $this->runJobNamed($jobEntity); + } else if ($jobEntity->getClassName()) { + $this->runJobWithClassName($jobEntity); + } else { + $id = $jobEntity->getId(); + + throw new RuntimeException("Not runnable job '$id'."); + } + } catch (Throwable $e) { + $isSuccess = false; + + $jobId = $jobEntity->hasId() ? $jobEntity->getId() : null; + + $this->log->critical("Failed job {id}.", [ + 'exception' => $e, + Attribute::ID => $jobId, + ]); + + if ($throwException) { + $exception = $e; + } + } + + $status = $isSuccess ? Status::SUCCESS : Status::FAILED; + + $jobEntity->setStatus($status); + + if ($isSuccess) { + $jobEntity->setExecutedAtNow(); + } + + $this->entityManager->saveEntity($jobEntity); + + if ($throwException && $exception) { + throw new $exception($exception->getMessage()); + } + + if ($jobEntity->getScheduledJobId()) { + $this->scheduleUtil->addLogRecord( + scheduledJobId: $jobEntity->getScheduledJobId(), + status: $status, + targetId: $jobEntity->getTargetId(), + targetType: $jobEntity->getTargetType(), + ); + } + } + + private function runJobNamed(JobEntity $jobEntity): void + { + $jobName = $jobEntity->getJob(); + + if (!$jobName) { + throw new RuntimeException("No job name."); + } + + $job = $this->jobFactory->create($jobName); + + $this->runJob($job, $jobEntity); + } + + private function runScheduledJob(JobEntity $jobEntity): void + { + $jobName = $jobEntity->getScheduledJobJob(); + + if (!$jobName) { + throw new RuntimeException("Can't run job '{$jobEntity->getId()}'. Not a scheduled job."); + } + + $job = $this->jobFactory->create($jobName); + + $this->runJob($job, $jobEntity); + } + + private function runJobWithClassName(JobEntity $jobEntity): void + { + $className = $jobEntity->getClassName(); + + if (!$className) { + throw new RuntimeException("No className in job {$jobEntity->getId()}."); + } + + $job = $this->jobFactory->createByClassName($className); + + $this->runJob($job, $jobEntity); + } + + private function runJob(Job|JobDataLess $job, JobEntity $jobEntity): void + { + if ($job instanceof JobDataLess) { + $job->run(); + + return; + } + + $data = Data::create($jobEntity->getData()) + ->withTargetId($jobEntity->getTargetId()) + ->withTargetType($jobEntity->getTargetType()); + + $job->run($data); + } + + private function setJobRunning(JobEntity $jobEntity): void + { + if (!$jobEntity->getStartedAt()) { + $jobEntity->setStartedAtNow(); + } + + $jobEntity->setStatus(Status::RUNNING); + $jobEntity->setPid(System::getPid()); + + $this->entityManager->saveEntity($jobEntity); + } +} diff --git a/application/Espo/Core/Job/JobScheduler.php b/application/Espo/Core/Job/JobScheduler.php new file mode 100644 index 0000000..74c32d3 --- /dev/null +++ b/application/Espo/Core/Job/JobScheduler.php @@ -0,0 +1,193 @@ +. + * + * 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\Job; + +use Espo\Core\Field\DateTime as DateTimeField; +use Espo\Core\Job\Job\Data; +use Espo\Core\Job\JobScheduler\Creator; + +use ReflectionClass; +use DateTimeInterface; +use DateTimeImmutable; +use DateInterval; +use RuntimeException; +use TypeError; + +/** + * Creates jobs in a queue. + */ +class JobScheduler +{ + /** @var ?class-string */ + private ?string $className = null; + private ?string $queue = null; + private ?string $group = null; + private ?Data $data = null; + private ?DateTimeImmutable $time = null; + private ?DateInterval $delay = null; + + public function __construct( + private Creator $creator, + ) {} + + /** + * A class name of the job. Should implement the `Job` interface. + * + * @param class-string $className + */ + public function setClassName(string $className): self + { + if (!class_exists($className)) { + throw new RuntimeException("Class '$className' does not exist."); + } + + $class = new ReflectionClass($className); + + if ( + !$class->implementsInterface(Job::class) && + !$class->implementsInterface(JobDataLess::class) + ) { + throw new RuntimeException("Class '$className' does not implement 'Job' or 'JobDataLess' interface."); + } + + $this->className = $className; + + return $this; + } + + /** + * In what queue to run the job. + * + * @param ?string $queue A queue name. Available names are defined in the `QueueName` class. + */ + public function setQueue(?string $queue): self + { + $this->queue = $queue; + + return $this; + } + + /** + * In what group to run the job. Jobs within a group will run one-by-one. Jobs with different group + * can run in parallel. The job can't have both queue and group set. + * + * @param ?string $group A group. Any string ID value can be used as a group name. E.g. a user ID. + */ + public function setGroup(?string $group): self + { + $this->group = $group; + + return $this; + } + + /** + * Set an execution time. If not set, then the current time will be used. + */ + public function setTime(?DateTimeInterface $time): self + { + $timeCopy = $time; + + if (!is_null($time) && !$time instanceof DateTimeImmutable) { + /** @noinspection PhpParamsInspection */ + $timeCopy = DateTimeImmutable::createFromMutable($time); + } + + /** @var ?DateTimeImmutable $timeCopy */ + + $this->time = $timeCopy; + + return $this; + } + + /** + * Set an execution delay. + */ + public function setDelay(?DateInterval $delay): self + { + $this->delay = $delay; + + return $this; + } + + /** + * Set data to be passed to the job. + * + * @param Data|array|null $data + */ + public function setData($data): self + { + /** @var mixed $data */ + + if (!is_null($data) && !is_array($data) && !$data instanceof Data) { + throw new TypeError(); + } + + if (!$data instanceof Data) { + $data = Data::create($data); + } + + $this->data = $data; + + return $this; + } + + public function schedule(): void + { + if (!$this->className) { + throw new RuntimeException("Class name is not set."); + } + + if ($this->group && $this->queue) { + throw new RuntimeException("Can't have both queue and group."); + } + + $time = $this->time; + + if (!$this->time && $this->delay) { + $time = new DateTimeImmutable(); + } + + if ($time && $this->delay) { + $time = $time->add($this->delay); + } + + $data = $this->data ?? Data::create(); + + $creatorData = new Creator\Data( + className: $this->className, + queue: $this->queue, + group: $this->group, + data: $data, + time: $time ? DateTimeField::fromDateTime($time) : null, + ); + + $this->creator->create($creatorData); + } +} diff --git a/application/Espo/Core/Job/JobScheduler/Creator.php b/application/Espo/Core/Job/JobScheduler/Creator.php new file mode 100644 index 0000000..2ca3072 --- /dev/null +++ b/application/Espo/Core/Job/JobScheduler/Creator.php @@ -0,0 +1,40 @@ +. + * + * 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\Job\JobScheduler; + +use Espo\Core\Job\JobScheduler\Creator\Data; + +/** + * @since 9.2.0 + */ +interface Creator +{ + public function create(Data $data): void; +} diff --git a/application/Espo/Core/Job/JobScheduler/Creator/Data.php b/application/Espo/Core/Job/JobScheduler/Creator/Data.php new file mode 100644 index 0000000..fa79a27 --- /dev/null +++ b/application/Espo/Core/Job/JobScheduler/Creator/Data.php @@ -0,0 +1,49 @@ +. + * + * 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\Job\JobScheduler\Creator; + +use Espo\Core\Field\DateTime; +use Espo\Core\Job\Job; +use Espo\Core\Job\Job\Data as JobData; +use Espo\Core\Job\JobDataLess; + +readonly class Data +{ + /** + * @param class-string $className + */ + public function __construct( + public string $className, + public ?string $queue, + public ?string $group, + public JobData $data, + public ?DateTime $time, + ) {} +} diff --git a/application/Espo/Core/Job/JobScheduler/Creators/EntityCreator.php b/application/Espo/Core/Job/JobScheduler/Creators/EntityCreator.php new file mode 100644 index 0000000..69137e6 --- /dev/null +++ b/application/Espo/Core/Job/JobScheduler/Creators/EntityCreator.php @@ -0,0 +1,61 @@ +. + * + * 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\Job\JobScheduler\Creators; + +use Espo\Core\Field\DateTime; +use Espo\Core\Job\JobScheduler\Creator; +use Espo\Entities\Job; +use Espo\ORM\EntityManager; + +class EntityCreator implements Creator +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function create(Creator\Data $data): void + { + $time = $data->time ?? DateTime::createNow(); + + $job = $this->entityManager->getRDBRepositoryByClass(Job::class)->getNew(); + + $job + ->setName($data->className) + ->setClassName($data->className) + ->setQueue($data->queue) + ->setGroup($data->group) + ->setData($data->data) + ->setTargetId($data->data->getTargetId()) + ->setTargetType($data->data->getTargetType()) + ->setExecuteTime($time); + + $this->entityManager->saveEntity($job); + } +} diff --git a/application/Espo/Core/Job/JobSchedulerFactory.php b/application/Espo/Core/Job/JobSchedulerFactory.php new file mode 100644 index 0000000..440fd98 --- /dev/null +++ b/application/Espo/Core/Job/JobSchedulerFactory.php @@ -0,0 +1,46 @@ +. + * + * 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\Job; + +use Espo\Core\InjectableFactory; + +/** + * Creates job scheduler instances. + */ +class JobSchedulerFactory +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function create(): JobScheduler + { + return $this->injectableFactory->create(JobScheduler::class); + } +} diff --git a/application/Espo/Core/Job/JobTask.php b/application/Espo/Core/Job/JobTask.php new file mode 100644 index 0000000..0bfbab7 --- /dev/null +++ b/application/Espo/Core/Job/JobTask.php @@ -0,0 +1,74 @@ +. + * + * 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\Job; + +use Espo\ORM\Name\Attribute; +use Spatie\Async\Task as AsyncTask; + +use Espo\Core\Application; +use Espo\Core\Application\Runner\Params as RunnerParams; +use Espo\Core\ApplicationRunners\Job as JobRunner; +use Espo\Core\Utils\Log; + +use Throwable; + +class JobTask extends AsyncTask +{ + private string $jobId; + + public function __construct(string $jobId) + { + $this->jobId = $jobId; + } + + /** + * @return void + */ + public function configure() + {} + + /** + * @return void + */ + public function run() + { + $app = new Application(); + + $params = RunnerParams::create()->with(Attribute::ID, $this->jobId); + + try { + $app->run(JobRunner::class, $params); + } catch (Throwable $e) { + $log = $app->getContainer()->getByClass(Log::class); + + $log->error("JobTask: Failed to run job '$this->jobId'. Error: " . $e->getMessage()); + } + } +} diff --git a/application/Espo/Core/Job/MetadataProvider.php b/application/Espo/Core/Job/MetadataProvider.php new file mode 100644 index 0000000..4330e9f --- /dev/null +++ b/application/Espo/Core/Job/MetadataProvider.php @@ -0,0 +1,104 @@ +. + * + * 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\Job; + +use Espo\Core\Utils\Metadata; + +class MetadataProvider +{ + public function __construct(private Metadata $metadata) + {} + + /** + * @return string[] + */ + public function getPreparableJobNameList(): array + { + $list = []; + + $items = $this->metadata->get(['app', 'scheduledJobs']) ?? []; + + foreach ($items as $name => $item) { + $isPreparable = (bool) ($item['preparatorClassName'] ?? null); + + if ($isPreparable) { + $list[] = $name; + } + } + + return $list; + } + + public function isJobSystem(string $name): bool + { + return (bool) $this->metadata->get(['app', 'scheduledJobs', $name, 'isSystem']); + } + + public function isJobPreparable(string $name): bool + { + return (bool) $this->metadata->get(['app', 'scheduledJobs', $name, 'preparatorClassName']); + } + + public function getPreparatorClassName(string $name): ?string + { + return $this->metadata->get(['app', 'scheduledJobs', $name, 'preparatorClassName']); + } + + public function getJobClassName(string $name): ?string + { + return $this->metadata->get(['app', 'scheduledJobs', $name, 'jobClassName']); + } + + /** + * @return string[] + */ + public function getScheduledJobNameList(): array + { + /** @var array $items */ + $items = $this->metadata->get(['app', 'scheduledJobs']) ?? []; + + return array_keys($items); + } + + /** + * @return string[] + */ + public function getNonSystemScheduledJobNameList(): array + { + return array_filter( + $this->getScheduledJobNameList(), + function (string $item) { + $isSystem = (bool) $this->metadata->get(['app', 'scheduledJobs', $item, 'isSystem']); + + return !$isSystem; + } + ); + } +} diff --git a/application/Espo/Core/Job/Preparator.php b/application/Espo/Core/Job/Preparator.php new file mode 100644 index 0000000..98665ee --- /dev/null +++ b/application/Espo/Core/Job/Preparator.php @@ -0,0 +1,45 @@ +. + * + * 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\Job; + +use Espo\Core\Job\Preparator\Data; + +use DateTimeImmutable; + +/** + * Creates multiple jobs for different targets according scheduling. + */ +interface Preparator +{ + /** + * Create multiple job records for a scheduled job. + */ + public function prepare(Data $data, DateTimeImmutable $executeTime): void; +} diff --git a/application/Espo/Core/Job/Preparator/CollectionHelper.php b/application/Espo/Core/Job/Preparator/CollectionHelper.php new file mode 100644 index 0000000..13b74e7 --- /dev/null +++ b/application/Espo/Core/Job/Preparator/CollectionHelper.php @@ -0,0 +1,114 @@ +. + * + * 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\Job\Preparator; + +use Espo\Core\Job\Job\Status; +use Espo\Core\Utils\DateTime; +use Espo\Entities\Job; +use Espo\ORM\Collection; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +use DateTimeImmutable; +use Espo\ORM\Name\Attribute; + +/** + * Creates jobs for each entity of a collection. + * To be used by Preparator implementations. + * + * @template TEntity of Entity + */ +class CollectionHelper +{ + public function __construct(private EntityManager $entityManager) + {} + + /** + * @param Collection $collection + */ + public function prepare(Collection $collection, Data $data, DateTimeImmutable $executeTime): void + { + foreach ($collection as $entity) { + $this->prepareItem($entity, $data, $executeTime); + } + } + + /** + * @param TEntity $entity + */ + private function prepareItem(Entity $entity, Data $data, DateTimeImmutable $executeTime): void + { + $running = $this->entityManager + ->getRDBRepositoryByClass(Job::class) + // Reduces the chance of race condition. + ->forUpdate() + ->select(Attribute::ID) + ->where([ + 'scheduledJobId' => $data->getId(), + Job::FIELD_STATUS => [ + Status::RUNNING, + Status::READY, + ], + 'targetType' => $entity->getEntityType(), + 'targetId' => $entity->getId(), + ]) + ->findOne(); + + if ($running) { + return; + } + + $countPending = $this->entityManager + ->getRDBRepositoryByClass(Job::class) + ->where([ + 'scheduledJobId' => $data->getId(), + Job::FIELD_STATUS => Status::PENDING, + 'targetType' => $entity->getEntityType(), + 'targetId' => $entity->getId(), + ]) + ->count(); + + if ($countPending > 1) { + return; + } + + $job = $this->entityManager->getRDBRepositoryByClass(Job::class)->getNew(); + + $job->setMultiple([ + 'name' => $data->getName(), + 'scheduledJobId' => $data->getId(), + 'executeTime' => $executeTime->format(DateTime::SYSTEM_DATE_TIME_FORMAT), + 'targetType' => $entity->getEntityType(), + 'targetId' => $entity->getId(), + ]); + + $this->entityManager->saveEntity($job); + } +} diff --git a/application/Espo/Core/Job/Preparator/Data.php b/application/Espo/Core/Job/Preparator/Data.php new file mode 100644 index 0000000..28c6f8c --- /dev/null +++ b/application/Espo/Core/Job/Preparator/Data.php @@ -0,0 +1,52 @@ +. + * + * 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\Job\Preparator; + +class Data +{ + public function __construct(private string $id, private string $name) + {} + + /** + * A scheduled job ID. + */ + public function getId(): string + { + return $this->id; + } + + /** + * A scheduled job name. + */ + public function getName(): string + { + return $this->name; + } +} diff --git a/application/Espo/Core/Job/Preparator/Preparators/ProcessJobGroupPreparator.php b/application/Espo/Core/Job/Preparator/Preparators/ProcessJobGroupPreparator.php new file mode 100644 index 0000000..fc16020 --- /dev/null +++ b/application/Espo/Core/Job/Preparator/Preparators/ProcessJobGroupPreparator.php @@ -0,0 +1,114 @@ +. + * + * 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\Job\Preparator\Preparators; + +use Espo\Core\Utils\DateTime; +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\Preparator; +use Espo\Core\Job\Preparator\Data; + +use Espo\ORM\EntityManager; + +use Espo\Entities\Job as JobEntity; + +use DateTimeImmutable; +use Espo\ORM\Name\Attribute; + +class ProcessJobGroupPreparator implements Preparator +{ + public function __construct(private EntityManager $entityManager) + {} + + public function prepare(Data $data, DateTimeImmutable $executeTime): void + { + $groupList = []; + + $query = $this->entityManager + ->getQueryBuilder() + ->select('group') + ->from(JobEntity::ENTITY_TYPE) + ->where([ + 'status' => Status::PENDING, + 'queue' => null, + 'group!=' => null, + 'executeTime<=' => $executeTime->format(DateTime::SYSTEM_DATE_TIME_FORMAT), + ]) + ->group('group') + ->build(); + + $sth = $this->entityManager->getQueryExecutor()->execute($query); + + while ($row = $sth->fetch()) { + $group = $row['group']; + + if ($group === null) { + continue; + } + + $groupList[] = $group; + } + + if (!count($groupList)) { + return; + } + + foreach ($groupList as $group) { + $existingJob = $this->entityManager + ->getRDBRepository(JobEntity::ENTITY_TYPE) + ->select(Attribute::ID) + ->where([ + 'scheduledJobId' => $data->getId(), + 'targetGroup' => $group, + 'status' => [ + Status::RUNNING, + Status::READY, + Status::PENDING, + ], + ]) + ->findOne(); + + if ($existingJob) { + continue; + } + + $name = $data->getName() . ' :: ' . $group; + + $this->entityManager->createEntity(JobEntity::ENTITY_TYPE, [ + 'scheduledJobId' => $data->getId(), + 'executeTime' => $executeTime->format(DateTime::SYSTEM_DATE_TIME_FORMAT), + 'name' => $name, + 'data' => [ + 'group' => $group, + ], + 'targetGroup' => $group, + ]); + } + } +} diff --git a/application/Espo/Core/Job/PreparatorFactory.php b/application/Espo/Core/Job/PreparatorFactory.php new file mode 100644 index 0000000..9118195 --- /dev/null +++ b/application/Espo/Core/Job/PreparatorFactory.php @@ -0,0 +1,57 @@ +. + * + * 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\Job; + +use Espo\Core\InjectableFactory; + +use RuntimeException; + +class PreparatorFactory +{ + public function __construct( + private MetadataProvider $metadataProvider, + private InjectableFactory $injectableFactory + ) {} + + /** + * Create a preparator. + */ + public function create(string $name): Preparator + { + /** @var ?class-string $className */ + $className = $this->metadataProvider->getPreparatorClassName($name); + + if (!$className) { + throw new RuntimeException("Preparator for job '$name' not found."); + } + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/Job/PrepareProcessor.php b/application/Espo/Core/Job/PrepareProcessor.php new file mode 100644 index 0000000..3f11411 --- /dev/null +++ b/application/Espo/Core/Job/PrepareProcessor.php @@ -0,0 +1,74 @@ +. + * + * 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\Job; + +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\PrepareProcessor\Params; + +/** + * @since 10.1.0 + */ +class PrepareProcessor +{ + public function __construct( + private QueueUtil $queueUtil, + private ScheduleProcessor $scheduleProcessor, + private CronUtil $cronUtil, + ) {} + + /** + * Jobs are be created according scheduling of scheduled jobs. + * This method is meant to be called on every Cron run or loop iteration of the Daemon. + * + * @throws TooFrequentRun + */ + public function process(Params $params = new Params()): void + { + if (!$this->cronUtil->checkLastRunTime()) { + throw new TooFrequentRun('JobManager: Skip job processing. Too frequent execution.'); + } + + $this->cronUtil->updateLastRunTime(); + + $this->processPrepare($params); + } + + private function processPrepare(Params $params): void + { + $scheduleParams = new ScheduleProcessor\Params( + skipQueues: $params->skipQueues, + ); + + $this->queueUtil->markJobsFailed(); + $this->queueUtil->updateFailedJobAttempts(); + $this->scheduleProcessor->process($scheduleParams); + $this->queueUtil->removePendingJobDuplicates(); + } +} diff --git a/application/Espo/Core/Job/PrepareProcessor/Params.php b/application/Espo/Core/Job/PrepareProcessor/Params.php new file mode 100644 index 0000000..fcb0628 --- /dev/null +++ b/application/Espo/Core/Job/PrepareProcessor/Params.php @@ -0,0 +1,40 @@ +. + * + * 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\Job\PrepareProcessor; + +/** + * @since 10.1.0 + */ +readonly class Params +{ + public function __construct( + public bool $skipQueues = false, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/Consumer.php b/application/Espo/Core/Job/Processing/Consumer.php new file mode 100644 index 0000000..6904d1b --- /dev/null +++ b/application/Espo/Core/Job/Processing/Consumer.php @@ -0,0 +1,42 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Processing\Consumer\Params; + +/** + * @since 10.1.0 + */ +interface Consumer +{ + public function start(Params $params): void; + + public function stop(): void; +} diff --git a/application/Espo/Core/Job/Processing/Consumer/Params.php b/application/Espo/Core/Job/Processing/Consumer/Params.php new file mode 100644 index 0000000..6da9945 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Consumer/Params.php @@ -0,0 +1,38 @@ +. + * + * 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\Job\Processing\Consumer; + +readonly class Params +{ + public function __construct( + public ?int $limit = null, + public ?string $queue = null, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/EnqueueDaemon.php b/application/Espo/Core/Job/Processing/EnqueueDaemon.php new file mode 100644 index 0000000..4597d33 --- /dev/null +++ b/application/Espo/Core/Job/Processing/EnqueueDaemon.php @@ -0,0 +1,217 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\ConfigDataProvider; +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Job\Processing\Util\ExitSetup; +use Espo\Core\Job\QueueName; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\Picker; +use Espo\Core\Job\QueueUtil; +use Espo\Core\Utils\Log; +use Espo\Entities\Job; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use RuntimeException; +use Throwable; + +/** + * @since 10.1.0 + * + * @internal + */ +class EnqueueDaemon +{ + private const float INTERVAL = 1.0; + + private bool $stopped = false; + + public function __construct( + private Picker $picker, + private ConfigDataProvider $configDataProvider, + private Publisher $publisher, + private EntityManager $entityManager, + private QueueUtil $queueUtil, + private Log $log, + private ExitSetup $exitSetup, + private ExitPolicy $exitPolicy, + ) {} + + public function run(EnqueueDaemon\Params $params): void + { + $interval = $this->getInterval($params); + $limit = $params->limit; + + $pickerParams = $this->getPickerParams($params); + $publisherParams = $this->preparePublisherParams($params); + + $this->publisher->initialize($publisherParams); + + $this->exitSetup->setup(function () { + $this->stopped = true; + }); + + $count = 0; + + while (true) { + $jobs = $this->picker->pick($pickerParams); + + foreach ($jobs as $job) { + $this->processJob($job); + + $count ++; + + if ($this->toForceExit() || $this->toExit($limit, $count)) { + break 2; + } + } + + usleep($interval); + + if ($this->toForceExit()) { + break; + } + } + + $this->publisher->close(); + } + + private function isStopped(): bool + { + return $this->stopped; + } + + private function getPickerParams(EnqueueDaemon\Params $daemonParams): Params + { + $limit = $daemonParams->portion ?? $this->configDataProvider->getMaxPortion(); + + $params = Params::create() + ->withLimit($limit); + + if ($daemonParams->queue !== null) { + return $params->withQueue($daemonParams->queue); + } + + return $params->withSubQueueParamsList([ + $params->withWeight(0.5), + $params->withQueue(QueueName::M0)->withWeight(0.5), + ]); + } + + private function getInterval(EnqueueDaemon\Params $params): int + { + $interval = $params->interval ?? self::INTERVAL; + + return (int) ($interval * 1000000); + } + + private function prepareJob(Job $job): void + { + $job + // Needed for failing not started. + ->setStartedAtNow() + ->setStatus(Status::READY); + + $this->entityManager->saveEntity($job); + } + + private function processJob(Job $job): void + { + $this->entityManager->getTransactionManager()->run(function () use ($job) { + $this->processJobInternal($job); + }); + } + + private function toSkip(Job $job): bool + { + if ($job->getStatus() !== Status::PENDING) { + return true; + } + + if ($this->queueUtil->isScheduledJobRunning($job)) { + return true; + } + + return false; + } + + private function fetchLocked(Job $job): ?Job + { + return $this->entityManager + ->getRDBRepositoryByClass(Job::class) + ->forUpdate() + ->where([Attribute::ID => $job->getId()]) + ->findOne(); + } + + private function processJobInternal(Job $job): void + { + $job = $this->fetchLocked($job); + + if (!$job || $this->toSkip($job)) { + return; + } + + $this->prepareJob($job); + + try { + $this->publisher->publish($job); + } catch (Throwable $e) { + $this->log->error("Enqueue: Could not publish job {id}.", [ + 'id' => $job->getId(), + 'exception' => $e, + ]); + + throw new RuntimeException(previous: $e); + } + } + + private function toExit(?int $limit, int $count): bool + { + return $limit && $count >= $limit; + } + + /** + * @phpstan-impure + */ + private function toForceExit(): bool + { + return $this->isStopped() || $this->exitPolicy->toExit(); + } + + private function preparePublisherParams(EnqueueDaemon\Params $params): Publisher\Params + { + return new Publisher\Params( + queue: $params->queue, + ); + } +} diff --git a/application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php b/application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php new file mode 100644 index 0000000..0c78300 --- /dev/null +++ b/application/Espo/Core/Job/Processing/EnqueueDaemon/Params.php @@ -0,0 +1,43 @@ +. + * + * 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\Job\Processing\EnqueueDaemon; + +readonly class Params +{ + /** + * @param ?float $interval Interval in seconds. + */ + public function __construct( + public ?float $interval = null, + public ?int $limit = null, + public ?int $portion = null, + public ?string $queue = null, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/Exceptions/TickFailure.php b/application/Espo/Core/Job/Processing/Exceptions/TickFailure.php new file mode 100644 index 0000000..85cd759 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Exceptions/TickFailure.php @@ -0,0 +1,35 @@ +. + * + * 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\Job\Processing\Exceptions; + +use Exception; + +class TickFailure extends Exception +{} diff --git a/application/Espo/Core/Job/Processing/JobProvider.php b/application/Espo/Core/Job/Processing/JobProvider.php new file mode 100644 index 0000000..7972843 --- /dev/null +++ b/application/Espo/Core/Job/Processing/JobProvider.php @@ -0,0 +1,65 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Job\Status; +use Espo\Entities\Job; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Exception; + +class JobProvider +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + /** + * @throws Exception + */ + public function get(string $id): Job + { + $job = $this->entityManager + ->getRDBRepositoryByClass(Job::class) + ->forUpdate() + ->where([Attribute::ID => $id]) + ->findOne(); + + if (!$job) { + throw new Exception("Job $id not found."); + } + + if ($job->getStatus() !== Status::READY) { + throw new Exception("Job $id is not in status Ready."); + } + + return $job; + } +} diff --git a/application/Espo/Core/Job/Processing/PrepareDaemon.php b/application/Espo/Core/Job/Processing/PrepareDaemon.php new file mode 100644 index 0000000..61b0497 --- /dev/null +++ b/application/Espo/Core/Job/Processing/PrepareDaemon.php @@ -0,0 +1,113 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Exceptions\TooFrequentRun; +use Espo\Core\Job\PrepareProcessor; +use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Job\Processing\Util\ExitSetup; + +/** + * @since 10.1.0 + */ +class PrepareDaemon +{ + private const float INTERVAL = 10.0; + + private bool $stopped = false; + + public function __construct( + private ExitSetup $exitSetup, + private ExitPolicy $exitPolicy, + private PrepareProcessor $prepareProcessor, + ) {} + + public function run(PrepareDaemon\Params $params): void + { + $interval = $this->getInterval($params); + + $prepareParams = new PrepareProcessor\Params( + skipQueues: $params->skipQueues, + ); + + $this->exitSetup->setup(function () { + $this->stopped = true; + }); + + $count = 0; + + while (true) { + try { + $this->prepareProcessor->process($prepareParams); + } catch (TooFrequentRun) { + continue; + } + + $count ++; + + if ($this->toForceExit() || $this->toExit($params, $count)) { + break; + } + + usleep($interval); + + if ($this->toForceExit()) { + break; + } + } + + $this->stopped = false; + } + + private function getInterval(PrepareDaemon\Params $params): int + { + $interval = $params->interval ?? self::INTERVAL; + + return (int) ($interval * 1000000); + } + + private function isStopped(): bool + { + return $this->stopped; + } + + private function toExit(PrepareDaemon\Params $params, int $count): bool + { + return $params->limit && $count >= $params->limit; + } + + /** + * @phpstan-impure + */ + private function toForceExit(): bool + { + return $this->isStopped() || $this->exitPolicy->toExit(); + } +} diff --git a/application/Espo/Core/Job/Processing/PrepareDaemon/Params.php b/application/Espo/Core/Job/Processing/PrepareDaemon/Params.php new file mode 100644 index 0000000..e8b847d --- /dev/null +++ b/application/Espo/Core/Job/Processing/PrepareDaemon/Params.php @@ -0,0 +1,42 @@ +. + * + * 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\Job\Processing\PrepareDaemon; + +readonly class Params +{ + /** + * @param ?float $interval Interval in seconds. + */ + public function __construct( + public ?float $interval = null, + public ?int $limit = null, + public bool $skipQueues = false, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/Publisher.php b/application/Espo/Core/Job/Processing/Publisher.php new file mode 100644 index 0000000..8d8de6f --- /dev/null +++ b/application/Espo/Core/Job/Processing/Publisher.php @@ -0,0 +1,45 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Processing\Publisher\Params; +use Espo\Entities\Job; + +/** + * @since 10.1.0 + */ +interface Publisher +{ + public function initialize(Params $params): void; + + public function publish(Job $job): void; + + public function close(): void; +} diff --git a/application/Espo/Core/Job/Processing/Publisher/Params.php b/application/Espo/Core/Job/Processing/Publisher/Params.php new file mode 100644 index 0000000..8ead955 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Publisher/Params.php @@ -0,0 +1,37 @@ +. + * + * 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\Job\Processing\Publisher; + +readonly class Params +{ + public function __construct( + public ?string $queue, + ) {} +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php b/application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php new file mode 100644 index 0000000..2dd94e4 --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/ConnectionFactory.php @@ -0,0 +1,58 @@ +. + * + * 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\Job\Processing\RabbitMq; + +use Espo\Core\Utils\Config; +use Exception; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use RuntimeException; + +class ConnectionFactory +{ + public function __construct( + private Config $config, + ) {} + + public function create(): AMQPStreamConnection + { + $host = $this->config->get('rabbitMq.host'); + $port = $this->config->get('rabbitMq.port'); + $user = $this->config->get('rabbitMq.user'); + $password = $this->config->get('rabbitMq.password'); + + try { + $connection = new AMQPStreamConnection($host, $port, $user, $password); + } catch (Exception $e) { + throw new RuntimeException("Connection error.", previous: $e); + } + + return $connection; + } +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php new file mode 100644 index 0000000..7957556 --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/Consumer.php @@ -0,0 +1,204 @@ +. + * + * 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\Job\Processing\RabbitMq; + +use Espo\Core\Job\JobRunner; +use Espo\Core\Job\Processing\Exceptions\TickFailure; +use Espo\Core\Job\Processing\JobProvider; +use Espo\Core\Job\Processing\Consumer as ConsumerInterface; +use Espo\Core\Job\Processing\Consumer\Params; +use Espo\Core\Job\Processing\Util\ExitPolicy; +use Espo\Core\Job\Processing\Util\Ticker; +use Espo\Core\Utils\Log; +use Exception; +use PhpAmqpLib\Channel\AMQPChannel; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Exception\AMQPTimeoutException; +use PhpAmqpLib\Message\AMQPMessage; +use RuntimeException; +use Throwable; + +class Consumer implements ConsumerInterface +{ + private bool $stopped = false; + + private const float ITERATION_TIMEOUT = 1.0; + + public function __construct( + private ConnectionFactory $connectionFactory, + private JobRunner $jobRunner, + private Log $log, + private JobProvider $jobProvider, + private ExitPolicy $exitPolicy, + private Ticker $ticker, + ) {} + + public function start(Params $params): void + { + if ($this->stopped) { + $this->stopped = false; + + return; + } + + $queue = Util::composeQueueName($params->queue); + + $connection = $this->connectionFactory->create(); + $channel = $this->prepareChannel($connection, $queue); + + $this->setupConsume($channel, $queue); + + $count = 0; + + while ($channel->is_consuming()) { + try { + $this->ticker->tick(); + } catch (TickFailure $e) { + $this->log->warning("Tick failure.", ['exception' => $e]); + + break; + } + + try { + $channel->wait(timeout: self::ITERATION_TIMEOUT); + } catch (AMQPTimeoutException) { + if ($this->toStop($params, $count)) { + break; + } + + continue; + } + + $count ++; + + if ($this->toStop($params, $count)) { + break; + } + } + + $channel->close(); + $this->closeConnection($connection); + + $this->stopped = false; + } + + public function stop(): void + { + $this->stopped = true; + } + + private function prepareChannel(AMQPStreamConnection $connection, string $queue): AMQPChannel + { + $channel = $connection->channel(); + + $channel->queue_declare( + queue: $queue, + durable: true, + auto_delete: false, + ); + + $channel->basic_qos( + prefetch_size: 0, + prefetch_count: 1, + a_global: false, + ); + + return $channel; + } + + private function closeConnection(AMQPStreamConnection $connection): void + { + try { + $connection->close(); + } catch (Exception $e) { + throw new RuntimeException("Connection closing error.", previous: $e); + } + } + + private function getJobId(AMQPMessage $message): string + { + $payload = json_decode($message->getBody()); + + if (!is_object($payload)) { + throw new RuntimeException("Bad payload."); + } + + $id = $payload->id ?? null; + + if (!is_string($id)) { + throw new RuntimeException("No string ID."); + } + + return $id; + } + + private function setupConsume(AMQPChannel $channel, string $queue): void + { + $channel->basic_consume( + queue: $queue, + callback: function (AMQPMessage $message) { + try { + $id = $this->getJobId($message); + } catch (Throwable $e) { + $this->log->error("Worker: Could not get job ID.", ['exception' => $e]); + + $message->nack(); + + return; + } + + try { + $job = $this->jobProvider->get($id); + + $this->jobRunner->run($job); + } catch (Throwable $e) { + $this->log->error("Worker: Job {id} failed.", [ + 'exception' => $e, + 'id' => $id, + ]); + + $message->nack(); + + return; + } + + $message->ack(); + }, + ); + } + + private function toStop(Params $params, int $count): bool + { + return + $this->stopped || + $params->limit && $count >= $params->limit || + $this->exitPolicy->toExit(); + } +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Publisher.php b/application/Espo/Core/Job/Processing/RabbitMq/Publisher.php new file mode 100644 index 0000000..789b78c --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/Publisher.php @@ -0,0 +1,117 @@ +. + * + * 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\Job\Processing\RabbitMq; + +use Espo\Core\Job\Processing\Publisher as PublisherInterface; +use Espo\Core\Job\Processing\Publisher\Params; +use Espo\Core\Utils\Json; +use Espo\Entities\Job; +use Espo\ORM\Name\Attribute; +use Exception; +use PhpAmqpLib\Channel\AMQPChannel; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Message\AMQPMessage; +use RuntimeException; + +class Publisher implements PublisherInterface +{ + public const string QUEUE = 'espo.jobs'; + + private ?AMQPStreamConnection $connection = null; + private ?AMQPChannel $channel = null; + private string $queue = self::QUEUE; + + public function __construct( + private ConnectionFactory $connectionFactory, + ) {} + + public function initialize(Params $params): void + { + $this->queue = Util::composeQueueName($params->queue); + + $this->connection = $this->connectionFactory->create(); + $this->channel = $this->prepareChannel($this->connection); + } + + public function publish(Job $job): void + { + if (!$this->channel) { + throw new RuntimeException("No channel."); + } + + $message = $this->prepareMessage($job); + + $this->channel->basic_publish( + msg: $message, + routing_key: $this->queue, + ); + } + + public function close(): void + { + $this->channel?->close(); + + if ($this->connection) { + $this->closeConnection($this->connection); + } + + $this->channel = null; + $this->connection = null; + } + + private function prepareChannel(AMQPStreamConnection $connection): AMQPChannel + { + $channel = $connection->channel(); + + $channel->queue_declare( + queue: $this->queue, + durable: true, + auto_delete: false, + ); + + return $channel; + } + + private function closeConnection(AMQPStreamConnection $connection): void + { + try { + $connection->close(); + } catch (Exception $e) { + throw new RuntimeException("Connection closing error.", previous: $e); + } + } + + private function prepareMessage(Job $job): AMQPMessage + { + $payload = Json::encode([Attribute::ID => $job->getId()]); + + return new AMQPMessage($payload, ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]); + } +} diff --git a/application/Espo/Core/Job/Processing/RabbitMq/Util.php b/application/Espo/Core/Job/Processing/RabbitMq/Util.php new file mode 100644 index 0000000..b54df68 --- /dev/null +++ b/application/Espo/Core/Job/Processing/RabbitMq/Util.php @@ -0,0 +1,44 @@ +. + * + * 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\Job\Processing\RabbitMq; + +class Util +{ + public static function composeQueueName(?string $queue): string + { + $name = Publisher::QUEUE; + + if ($queue !== null) { + $name .= '.' . $queue; + } + + return $name; + } +} diff --git a/application/Espo/Core/Job/Processing/Util/ExitPolicy.php b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php new file mode 100644 index 0000000..b656ad1 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Util/ExitPolicy.php @@ -0,0 +1,50 @@ +. + * + * 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\Job\Processing\Util; + +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\StateConfig; + +class ExitPolicy +{ + private int $cacheTimestamp; + + public function __construct( + private StateConfig $stateConfig, + private Config\StateConfigDirect $stateConfigDirect, + ) { + $this->cacheTimestamp = $this->stateConfig->getCacheTimestamp(); + } + + public function toExit(): bool + { + return $this->cacheTimestamp !== $this->stateConfigDirect->getCacheTimestamp(); + } +} diff --git a/application/Espo/Core/Job/Processing/Util/ExitSetup.php b/application/Espo/Core/Job/Processing/Util/ExitSetup.php new file mode 100644 index 0000000..5063d35 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Util/ExitSetup.php @@ -0,0 +1,46 @@ +. + * + * 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\Job\Processing\Util; + +use Closure; + +class ExitSetup +{ + public function setup(Closure $handler): void + { + if (!extension_loaded('pcntl')) { + return; + } + + pcntl_async_signals(true); + pcntl_signal(SIGTERM, fn () => $handler()); + pcntl_signal(SIGINT, fn () => $handler()); + } +} diff --git a/application/Espo/Core/Job/Processing/Util/Ticker.php b/application/Espo/Core/Job/Processing/Util/Ticker.php new file mode 100644 index 0000000..5d4fe09 --- /dev/null +++ b/application/Espo/Core/Job/Processing/Util/Ticker.php @@ -0,0 +1,53 @@ +. + * + * 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\Job\Processing\Util; + +use Espo\Core\Job\Processing\Exceptions\TickFailure; +use Espo\Core\Utils\Event\EventTransport; +use Espo\Core\Utils\Event\Exceptions\TransportNotConnected; + +class Ticker +{ + public function __construct( + private EventTransport $transport + ) {} + + /** + * @throws TickFailure + */ + public function tick(): void + { + try { + $this->transport->tick(); + } catch (TransportNotConnected $e) { + throw new TickFailure(previous: $e); + } + } +} diff --git a/application/Espo/Core/Job/Processing/WorkerDaemon.php b/application/Espo/Core/Job/Processing/WorkerDaemon.php new file mode 100644 index 0000000..e079ffe --- /dev/null +++ b/application/Espo/Core/Job/Processing/WorkerDaemon.php @@ -0,0 +1,71 @@ +. + * + * 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\Job\Processing; + +use Espo\Core\Job\Processing\Consumer\Params; +use Espo\Core\Job\Processing\Util\ExitSetup; +use Espo\Core\Utils\Event\Configuration; + +/** + * @since 10.1.0 + * @internal + */ +class WorkerDaemon +{ + public function __construct( + private Consumer $consumer, + private ExitSetup $exitSetup, + private Configuration $eventConfiguration, + ) {} + + public function run(WorkerDaemon\Params $params): void + { + $consumerParams = $this->prepareParams($params); + + $this->setupExit(); + + $this->eventConfiguration->setSubscribeToCrossInstanceEvents(true); + + $this->consumer->start($consumerParams); + } + + private function setupExit(): void + { + $this->exitSetup->setup(fn () => $this->consumer->stop()); + } + + private function prepareParams(WorkerDaemon\Params $params): Params + { + return new Params( + limit: $params->limit, + queue: $params->queue, + ); + } +} diff --git a/application/Espo/Core/Job/Processing/WorkerDaemon/Params.php b/application/Espo/Core/Job/Processing/WorkerDaemon/Params.php new file mode 100644 index 0000000..6a06a30 --- /dev/null +++ b/application/Espo/Core/Job/Processing/WorkerDaemon/Params.php @@ -0,0 +1,38 @@ +. + * + * 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\Job\Processing\WorkerDaemon; + +readonly class Params +{ + public function __construct( + public ?int $limit = null, + public ?string $queue = null, + ) {} +} diff --git a/application/Espo/Core/Job/QueueName.php b/application/Espo/Core/Job/QueueName.php new file mode 100644 index 0000000..ec2b36f --- /dev/null +++ b/application/Espo/Core/Job/QueueName.php @@ -0,0 +1,58 @@ +. + * + * 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\Job; + +class QueueName +{ + /** + * Executes as soon as possible. Non-parallel by default. + */ + public const string Q0 = 'q0'; + + /** + * Executes every minute. Non-parallel by default. + */ + public const string Q1 = 'q1'; + + /** + * Executes as soon as possible. For email processing. Non-parallel by default. + */ + public const string E0 = 'e0'; + + /** + * Executes in the main queue pool in parallel. Along with jobs without specified queue. + * A portion is always picked for a queue iteration, even if there are no-queue + * jobs ordered before. E.g. if the portion size is 100, and there are 200 empty-queue + * jobs and 5 m0 jobs, 95 and 5 will be picked respectfully. + * + * @since 9.2.0 + */ + const string M0 = 'm0'; +} diff --git a/application/Espo/Core/Job/QueuePortionNumberProvider.php b/application/Espo/Core/Job/QueuePortionNumberProvider.php new file mode 100644 index 0000000..74f01c3 --- /dev/null +++ b/application/Espo/Core/Job/QueuePortionNumberProvider.php @@ -0,0 +1,67 @@ +. + * + * 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\Job; + +use Espo\Core\Utils\Config; + +class QueuePortionNumberProvider +{ + /** @var array */ + private $queueNumberMap = [ + QueueName::Q0 => self::Q0_PORTION_NUMBER, + QueueName::Q1 => self::Q1_PORTION_NUMBER, + QueueName::E0 => self::E0_PORTION_NUMBER, + ]; + + /** @var array */ + private $queueParamNameMap = [ + QueueName::Q0 => 'jobQ0MaxPortion', + QueueName::Q1 => 'jobQ1MaxPortion', + QueueName::E0 => 'jobE0MaxPortion', + ]; + + private const Q0_PORTION_NUMBER = 200; + private const Q1_PORTION_NUMBER = 500; + private const E0_PORTION_NUMBER = 100; + private const DEFAULT_PORTION_NUMBER = 200; + + public function __construct(private Config $config) + {} + + public function get(string $queue): int + { + $paramName = $this->queueParamNameMap[$queue] ?? 'job' . ucfirst($queue) . 'MaxPortion'; + + return + $this->config->get($paramName) ?? + $this->queueNumberMap[$queue] ?? + self::DEFAULT_PORTION_NUMBER; + } +} diff --git a/application/Espo/Core/Job/QueueProcessor.php b/application/Espo/Core/Job/QueueProcessor.php new file mode 100644 index 0000000..07f579e --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor.php @@ -0,0 +1,40 @@ +. + * + * 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\Job; + +use Espo\Core\Job\QueueProcessor\Params; + +/** + * @since 10.1.0 + */ +interface QueueProcessor +{ + public function process(Params $params): void; +} diff --git a/application/Espo/Core/Job/QueueProcessor/Params.php b/application/Espo/Core/Job/QueueProcessor/Params.php new file mode 100644 index 0000000..ce025e4 --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/Params.php @@ -0,0 +1,116 @@ +. + * + * 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\Job\QueueProcessor; + +class Params +{ + private ?string $queue = null; + private ?string $group = null; + private int $limit = 0; + /** @var ?Params[] */ + private ?array $subQueueParamsList = null; + private float $weight = 1.0; + + public function withQueue(?string $queue): self + { + $obj = clone $this; + $obj->queue = $queue; + + return $obj; + } + + public function withGroup(?string $group): self + { + $obj = clone $this; + $obj->group = $group; + + return $obj; + } + + public function withLimit(int $limit): self + { + $obj = clone $this; + $obj->limit = $limit; + + return $obj; + } + + public function withWeight(float $weight): self + { + $obj = clone $this; + $obj->weight = $weight; + + return $obj; + } + + /** + * @param ?Params[] $subQueueParamsList + */ + public function withSubQueueParamsList(?array $subQueueParamsList): self + { + $obj = clone $this; + $obj->subQueueParamsList = $subQueueParamsList; + + return $obj; + } + + public function getQueue(): ?string + { + return $this->queue; + } + + public function getGroup(): ?string + { + return $this->group; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getWeight(): float + { + return $this->weight; + } + + /** + * @return ?Params[] + */ + public function getSubQueueParamsList(): ?array + { + return $this->subQueueParamsList; + } + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Job/QueueProcessor/Picker.php b/application/Espo/Core/Job/QueueProcessor/Picker.php new file mode 100644 index 0000000..ef94572 --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/Picker.php @@ -0,0 +1,134 @@ +. + * + * 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\Job\QueueProcessor; + +use Espo\Core\Job\QueueUtil; +use Espo\Entities\Job; +use RuntimeException; + +/** + * Picks jobs for a portion distributing by weights if needed. + */ +class Picker +{ + public function __construct( + private QueueUtil $queueUtil, + ) {} + + /** + * @param Params $params + * @return iterable + */ + public function pick(Params $params): iterable + { + $paramsList = $params->getSubQueueParamsList(); + + if (!$paramsList) { + return $this->queueUtil->getPendingJobs($params); + } + + $groups = []; + + foreach ($paramsList as $itemParams) { + $groups[] = iterator_to_array($this->queueUtil->getPendingJobs($itemParams)); + } + + return $this->pickJobsRecursively($paramsList, $groups, $params->getLimit()); + } + + /** + * @param Params[] $paramsList, + * @param Job[][] $groups + * @return Job[] + */ + private function pickJobsRecursively( + array $paramsList, + array $groups, + int $limit, + ): array { + + $totalWeight = array_reduce($paramsList, fn ($c, $it) => $c + $it->getWeight(), 0.0); + + /** @var Job[][] $leftovers */ + $leftovers = []; + $output = []; + + foreach ($paramsList as $i => $itemParams) { + if (!array_key_exists($i, $groups)) { + throw new RuntimeException(); + } + + $jobs = $groups[$i]; + $weight = $itemParams->getWeight(); + + $portion = (int) round($weight / $totalWeight * $limit); + + $pickedJobs = []; + + while (count($pickedJobs) < $portion) { + if (count($jobs) === 0) { + break; + } + + $pickedJobs[] = array_shift($jobs); + } + + $output = array_merge($output, $pickedJobs); + + $leftovers[] = $jobs; + } + + $left = $limit - count($output); + $leftoverCount = array_reduce($leftovers, fn ($c, $it) => $c + count($it), 0); + + if ($left && $leftoverCount) { + foreach ($leftovers as $i => $jobs) { + if (count($jobs) === 0) { + unset($leftovers[$i]); + unset($paramsList[$i]); + } + } + + $leftovers = array_values($leftovers); + $paramsList = array_values($paramsList); + + $rest = $this->pickJobsRecursively( + paramsList: $paramsList, + groups: $leftovers, + limit: $leftoverCount, + ); + + $output = array_merge($output, $rest); + $output = array_slice($output, 0, $limit); + } + + return $output; + } +} diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php new file mode 100644 index 0000000..4859d3c --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/DefaultQueueProcessor.php @@ -0,0 +1,74 @@ +. + * + * 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\Job\QueueProcessor\QueueProcessors; + +use Espo\Core\Job\AsyncPoolFactory; +use Espo\Core\Job\ConfigDataProvider; +use Espo\Core\Job\QueueProcessor; +use Espo\Core\Utils\Log; +use Espo\Core\Job\QueueProcessor\Params; + +/** + * @internal + */ +class DefaultQueueProcessor implements QueueProcessor +{ + public function __construct( + private ProcessPoolQueueProcessor $processPoolQueueProcessor, + private SequentialQueueProcessor $sequentialQueueProcessor, + private AsyncPoolFactory $asyncPoolFactory, + private Log $log, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Params $params): void + { + if ($this->inParallel()) { + $this->processPoolQueueProcessor->process($params); + } else { + $this->sequentialQueueProcessor->process($params); + } + } + + private function inParallel(): bool + { + if (!$this->configDataProvider->runInParallel()) { + return false; + } + + if (!$this->asyncPoolFactory->isSupported()) { + $this->log->warning("Enabled `jobRunInParallel` parameter requires pcntl and posix extensions."); + + return false; + } + + return true; + } +} diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php new file mode 100644 index 0000000..0579833 --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/ProcessPoolQueueProcessor.php @@ -0,0 +1,132 @@ +. + * + * 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\Job\QueueProcessor\QueueProcessors; + +use Espo\Core\Job\AsyncPoolFactory; +use Espo\Core\Job\ConfigDataProvider; +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\JobTask; +use Espo\Core\Job\QueueProcessor; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\Picker; +use Espo\Core\Job\QueueUtil; +use Espo\Core\ORM\EntityManager; +use Espo\Entities\Job; +use Spatie\Async\Pool as AsyncPool; + +class ProcessPoolQueueProcessor implements QueueProcessor +{ + public function __construct( + private QueueUtil $queueUtil, + private AsyncPoolFactory $asyncPoolFactory, + private EntityManager $entityManager, + private Picker $picker, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Params $params): void + { + $noLock = $this->skipLock($params); + + $pool = $this->asyncPoolFactory->create(); + + foreach ($this->picker->pick($params) as $job) { + $this->processJob($noLock, $job, $pool); + } + + $pool->wait(); + } + + private function processJob(bool $noLock, Job $job, AsyncPool $pool): void + { + $lockTable = !$noLock && $job->getScheduledJobId(); + + if ($lockTable) { + // MySQL doesn't allow to lock non-existent rows. We resort to locking an entire table. + $this->entityManager->getLocker()->lockExclusive(Job::ENTITY_TYPE); + } + + $skip = $this->toSkip($noLock, $job); + + if ($skip) { + if ($lockTable) { + $this->entityManager->getLocker()->rollback(); + } + + return; + } + + $this->prepareJob($job); + + if ($lockTable) { + $this->entityManager->getLocker()->commit(); + } + + $this->runJob($job, $pool); + } + + private function toSkip(bool $noLock, Job $job): bool + { + if (!$noLock && !$this->queueUtil->isJobPending($job)) { + return true; + } + + if ($this->queueUtil->isScheduledJobRunning($job)) { + return true; + } + + return false; + } + + private function prepareJob(Job $job): void + { + $job + // Needed for failing not started. + ->setStartedAtNow() + ->setStatus(Status::READY); + + $this->entityManager->saveEntity($job); + } + + private function runJob(Job $job, AsyncPool $pool): void + { + $task = new JobTask($job->getId()); + + $pool->add($task); + } + + private function skipLock(Params $params): bool + { + return + $params->getGroup() !== null || + $params->getQueue() !== null || + $this->configDataProvider->noTableLocking(); + } +} diff --git a/application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php new file mode 100644 index 0000000..a8507ca --- /dev/null +++ b/application/Espo/Core/Job/QueueProcessor/QueueProcessors/SequentialQueueProcessor.php @@ -0,0 +1,83 @@ +. + * + * 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\Job\QueueProcessor\QueueProcessors; + +use Espo\Core\Job\Job\Status; +use Espo\Core\Job\JobRunner; +use Espo\Core\Job\QueueProcessor; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\Job\QueueProcessor\Picker; +use Espo\Core\Job\QueueUtil; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\System; +use Espo\Entities\Job; + +class SequentialQueueProcessor implements QueueProcessor +{ + public function __construct( + private QueueUtil $queueUtil, + private JobRunner $jobRunner, + private EntityManager $entityManager, + private Picker $picker, + ) {} + + public function process(Params $params): void + { + foreach ($this->picker->pick($params) as $job) { + $this->processJob($job); + } + } + + private function processJob(Job $job): void + { + if ($this->toSkip($job)) { + return; + } + + $this->prepareJob($job); + + $this->jobRunner->run($job); + } + + private function toSkip(Job $job): bool + { + return $this->queueUtil->isScheduledJobRunning($job); + } + + private function prepareJob(Job $job): void + { + $job + ->setStartedAtNow() + ->setStatus(Status::RUNNING) + ->setPid(System::getPid()); + + $this->entityManager->saveEntity($job); + } +} diff --git a/application/Espo/Core/Job/QueueUtil.php b/application/Espo/Core/Job/QueueUtil.php new file mode 100644 index 0000000..458565f --- /dev/null +++ b/application/Espo/Core/Job/QueueUtil.php @@ -0,0 +1,492 @@ +. + * + * 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\Job; + +use Countable; +use Espo\Core\Job\QueueProcessor\Params; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Core\Utils\System; +use Espo\Core\Job\Job\Status; +use Espo\Entities\Job as JobEntity; +use Espo\ORM\Collection; +use Espo\ORM\Name\Attribute; +use DateTime; +use Exception; +use LogicException; + +class QueueUtil +{ + private const int NOT_EXISTING_PROCESS_PERIOD = 300; + private const int READY_NOT_STARTED_PERIOD = 600; + + public function __construct( + private Config $config, + private EntityManager $entityManager, + private ScheduleUtil $scheduleUtil, + private MetadataProvider $metadataProvider, + ) {} + + public function isJobPending(JobEntity $job): bool + { + $job = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select([Attribute::ID, JobEntity::FIELD_STATUS]) + ->where([Attribute::ID => $job->getId()]) + ->forUpdate() + ->findOne(); + + if (!$job) { + return false; + } + + return $job->getStatus() === Status::PENDING; + } + + /** + * @return Collection&Countable + */ + public function getPendingJobs(Params $params): Collection + { + $queue = $params->getQueue(); + $group = $params->getGroup(); + $limit = $params->getLimit(); + + $builder = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select([ + Attribute::ID, + 'scheduledJobId', + 'scheduledJobJob', + 'executeTime', + 'targetId', + 'targetType', + 'targetGroup', + 'className', + 'job', + 'data', + ]) + ->where([ + 'status' => Status::PENDING, + 'executeTime<=' => DateTimeUtil::getSystemNowString(), + 'queue' => $queue, + 'group' => $group, + ]) + ->order('number'); + + if ($limit) { + $builder->limit(0, $limit); + } + + return $builder->sth()->find(); + } + + public function isScheduledJobRunning(JobEntity $job): bool + { + $scheduledJobId = $job->getScheduledJobId(); + $targetId = $job->getTargetId(); + $targetType = $job->getTargetType(); + $targetGroup = $job->getTargetGroup(); + + if (!$scheduledJobId) { + return false; + } + + $where = [ + 'scheduledJobId' => $scheduledJobId, + JobEntity::FIELD_STATUS => [ + Status::RUNNING, + Status::READY, + ], + ]; + + if ($targetId && $targetType) { + $where['targetId'] = $targetId; + $where['targetType'] = $targetType; + } + + if ($targetGroup) { + $where['targetGroup'] = $targetGroup; + } + + return (bool) $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select([Attribute::ID]) + ->where($where) + ->findOne(); + } + + /** + * @return string[] + */ + public function getRunningScheduledJobIdList(): array + { + $list = []; + + $jobList = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select(['scheduledJobId']) + ->leftJoin('scheduledJob') + ->where([ + 'status' => [ + Status::RUNNING, + Status::READY, + ], + 'scheduledJobId!=' => null, + 'scheduledJob.job!=' => $this->metadataProvider->getPreparableJobNameList(), + ]) + ->order('executeTime') + ->find(); + + foreach ($jobList as $job) { + $scheduledJobId = $job->getScheduledJobId(); + + if (!$scheduledJobId) { + continue; + } + + $list[] = $scheduledJobId; + } + + return $list; + } + + public function hasScheduledJobOnMinute(string $scheduledJobId, string $time): bool + { + try { + $dateObj = new DateTime($time); + } catch (Exception $e) { + throw new LogicException($e->getMessage()); + } + + $fromString = $dateObj->format('Y-m-d H:i:00'); + $toString = $dateObj->format('Y-m-d H:i:59'); + + $job = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select([Attribute::ID]) + ->where([ + 'scheduledJobId' => $scheduledJobId, + 'status' => [ // This forces usage of an appropriate index. + Status::PENDING, + Status::READY, + Status::RUNNING, + Status::SUCCESS, + ], + 'executeTime>=' => $fromString, + 'executeTime<=' => $toString, + ]) + ->findOne(); + + return (bool) $job; + } + + public function getPendingCountByScheduledJobId(string $scheduledJobId): int + { + return $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->where([ + 'scheduledJobId' => $scheduledJobId, + 'status' => Status::PENDING, + ]) + ->count(); + } + + public function markJobsFailed(): void + { + $this->markJobsFailedByNotExistingProcesses(); + $this->markJobsFailedReadyNotStarted(); + $this->markJobsFailedByPeriod(true); + $this->markJobsFailedByPeriod(); + } + + private function markJobsFailedByNotExistingProcesses(): void + { + $timeThreshold = time() - $this->config->get( + 'jobPeriodForNotExistingProcess', + self::NOT_EXISTING_PROCESS_PERIOD + ); + + $dateTimeThreshold = date(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT, $timeThreshold); + + $runningJobList = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select([ + Attribute::ID, + 'scheduledJobId', + 'executeTime', + 'targetId', + 'targetType', + 'pid', + 'startedAt', + ]) + ->where([ + 'status' => Status::RUNNING, + 'startedAt<' => $dateTimeThreshold, + ]) + ->find(); + + $failedJobList = []; + + foreach ($runningJobList as $job) { + $pid = $job->getPid(); + + if ($pid && !System::isProcessActive($pid)) { + $failedJobList[] = $job; + } + } + + $this->markJobListFailed($failedJobList); + } + + private function markJobsFailedReadyNotStarted(): void + { + $timeThreshold = time() - + $this->config->get('jobPeriodForReadyNotStarted', self::READY_NOT_STARTED_PERIOD); + + $dateTimeThreshold = date(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT, $timeThreshold); + + $failedJobList = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select([ + Attribute::ID, + 'scheduledJobId', + 'executeTime', + 'targetId', + 'targetType', + 'pid', + 'startedAt', + ]) + ->where([ + 'status' => Status::READY, + 'startedAt<' => $dateTimeThreshold, + ]) + ->find(); + + $this->markJobListFailed($failedJobList); + } + + protected function markJobsFailedByPeriod(bool $isForActiveProcesses = false): void + { + $period = 'jobPeriod'; + + if ($isForActiveProcesses) { + $period = 'jobPeriodForActiveProcess'; + } + + $timeThreshold = time() - $this->config->get($period, 7800); + + $dateTimeThreshold = date(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT, $timeThreshold); + + $runningJobList = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select([ + Attribute::ID, + 'scheduledJobId', + 'executeTime', + 'targetId', + 'targetType', + 'pid', + 'startedAt' + ]) + ->where([ + 'status' => Status::RUNNING, + 'executeTime<' => $dateTimeThreshold, + ]) + ->find(); + + $failedJobList = []; + + foreach ($runningJobList as $job) { + if ($isForActiveProcesses) { + $failedJobList[] = $job; + + continue; + } + + $pid = $job->getPid(); + + if (!$pid || !System::isProcessActive($pid)) { + $failedJobList[] = $job; + } + } + + $this->markJobListFailed($failedJobList); + } + + /** + * @param iterable $jobList + */ + protected function markJobListFailed(iterable $jobList): void + { + if (is_countable($jobList) && !count($jobList)) { + return; + } + + $jobIdList = []; + + foreach ($jobList as $job) { + $jobIdList[] = $job->getId(); + } + + $updateQuery = $this->entityManager + ->getQueryBuilder() + ->update() + ->in(JobEntity::ENTITY_TYPE) + ->set([ + 'status' => Status::FAILED, + 'attempts' => 0, + ]) + ->where([ + Attribute::ID => $jobIdList, + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($updateQuery); + + foreach ($jobList as $job) { + $scheduledJobId = $job->getScheduledJobId(); + + if (!$scheduledJobId) { + continue; + } + + $this->scheduleUtil->addLogRecord( + $scheduledJobId, + Status::FAILED, + $job->getStartedAt(), + $job->getTargetId(), + $job->getTargetType() + ); + } + } + + /** + * Remove pending duplicate jobs, no need to run twice the same job. + */ + public function removePendingJobDuplicates(): void + { + $duplicateJobList = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select(['scheduledJobId']) + ->leftJoin('scheduledJob') + ->where([ + 'scheduledJobId!=' => null, + 'status' => Status::PENDING, + 'executeTime<=' => DateTimeUtil::getSystemNowString(), + 'scheduledJob.job!=' => $this->metadataProvider->getPreparableJobNameList(), + 'targetId' => null, + ]) + ->group(['scheduledJobId']) + ->having([ + 'COUNT:id>' => 1, + ]) + ->order('MAX:executeTime') + ->find(); + + $scheduledJobIdList = []; + + foreach ($duplicateJobList as $duplicateJob) { + $scheduledJobId = $duplicateJob->getScheduledJobId(); + + if (!$scheduledJobId) { + continue; + } + + $scheduledJobIdList[] = $scheduledJobId; + } + + foreach ($scheduledJobIdList as $scheduledJobId) { + $toRemoveJobList = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select([Attribute::ID]) + ->where([ + 'scheduledJobId' => $scheduledJobId, + 'status' => Status::PENDING, + ]) + ->order('executeTime') + ->limit(0, 1000) + ->find(); + + $jobIdList = []; + + foreach ($toRemoveJobList as $job) { + $jobIdList[] = $job->getId(); + } + + if (!count($jobIdList)) { + continue; + } + + $delete = $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(JobEntity::ENTITY_TYPE) + ->where([Attribute::ID => $jobIdList]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + } + + /** + * Handle job attempts. Change failed to pending if attempts left. + */ + public function updateFailedJobAttempts(): void + { + $jobCollection = $this->entityManager + ->getRDBRepositoryByClass(JobEntity::class) + ->select([ + Attribute::ID, + 'attempts', + 'failedAttempts', + ]) + ->where([ + 'status' => Status::FAILED, + 'executeTime<=' => DateTimeUtil::getSystemNowString(), + 'attempts>' => 0, + ]) + ->find(); + + foreach ($jobCollection as $job) { + $failedAttempts = $job->getFailedAttempts(); + $attempts = $job->getAttempts(); + + $job->set([ + 'status' => Status::PENDING, + 'attempts' => $attempts - 1, + 'failedAttempts' => $failedAttempts + 1, + ]); + + $this->entityManager->saveEntity($job); + } + } +} diff --git a/application/Espo/Core/Job/ScheduleProcessor.php b/application/Espo/Core/Job/ScheduleProcessor.php new file mode 100644 index 0000000..e8e79cc --- /dev/null +++ b/application/Espo/Core/Job/ScheduleProcessor.php @@ -0,0 +1,189 @@ +. + * + * 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\Job; + +use DateTimeZone; +use Espo\Core\Job\Preparator\Data as PreparatorData; +use Espo\Core\Job\ScheduleProcessor\Params; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Core\Utils\Log; +use Espo\Core\Job\Job\Status; +use Espo\Entities\Job as JobEntity; +use Espo\Entities\ScheduledJob as ScheduledJobEntity; + +use Cron\CronExpression; + +use Throwable; +use Exception; +use DateTimeImmutable; + +/** + * Creates jobs from scheduled jobs according scheduling. + */ +class ScheduleProcessor +{ + /** @var string[] */ + private $asSoonAsPossibleSchedulingList = [ + '*', + '* *', + '* * *', + '* * * *', + '* * * * *', + '* * * * * *', + ]; + + public function __construct( + private Log $log, + private EntityManager $entityManager, + private QueueUtil $queueUtil, + private ScheduleUtil $scheduleUtil, + private PreparatorFactory $preparatorFactory, + private MetadataProvider $metadataProvider, + private ConfigDataProvider $configDataProvider, + ) {} + + public function process(Params $params = new Params()): void + { + $activeScheduledJobs = $this->scheduleUtil->getActiveScheduledJobs($params); + $runningScheduledJobIdList = $this->queueUtil->getRunningScheduledJobIdList(); + + foreach ($activeScheduledJobs as $scheduledJob) { + try { + $isRunning = in_array($scheduledJob->getId(), $runningScheduledJobIdList); + + $this->createJobsFromScheduledJob($scheduledJob, $isRunning); + } catch (Throwable $e) { + $id = $scheduledJob->getId(); + + $this->log->error("Scheduled Job '$id': " . $e->getMessage()); + } + } + } + + private function createJobsFromScheduledJob(ScheduledJobEntity $scheduledJob, bool $isRunning): void + { + $id = $scheduledJob->getId(); + + $executeTime = $this->findExecuteTime($scheduledJob); + + if ($executeTime === null) { + return; + } + + $asSoonAsPossible = $this->checkAsSoonAsPossible($scheduledJob); + + if (!$asSoonAsPossible) { + if ($this->queueUtil->hasScheduledJobOnMinute($id, $executeTime)) { + return; + } + } + + $jobName = $scheduledJob->getJob(); + + if ($jobName && $this->metadataProvider->isJobPreparable($jobName)) { + $preparator = $this->preparatorFactory->create($jobName); + + $data = new PreparatorData($scheduledJob->getId(), $scheduledJob->getName() ?? $jobName); + + /** @var DateTimeImmutable $executeTimeObj */ + $executeTimeObj = DateTimeImmutable + ::createFromFormat(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT, $executeTime); + + $preparator->prepare($data, $executeTimeObj); + + return; + } + + if ($isRunning) { + return; + } + + $pendingCount = $this->queueUtil->getPendingCountByScheduledJobId($id); + + $pendingLimit = $asSoonAsPossible ? 0 : 1; + + if ($pendingCount > $pendingLimit) { + return; + } + + $this->entityManager->createEntity(JobEntity::ENTITY_TYPE, [ + 'name' => $scheduledJob->getName(), + 'status' => Status::PENDING, + 'scheduledJobId' => $id, + 'executeTime' => $executeTime, + ]); + } + + private function checkAsSoonAsPossible(ScheduledJobEntity $scheduledJob): bool + { + return in_array($scheduledJob->getScheduling(), $this->asSoonAsPossibleSchedulingList); + } + + private function findExecuteTime(ScheduledJobEntity $scheduledJob): ?string + { + $scheduling = $scheduledJob->getScheduling(); + + if ($scheduling === null) { + return null; + } + + $id = $scheduledJob->getId(); + + $asSoonAsPossible = in_array($scheduling, $this->asSoonAsPossibleSchedulingList); + + if ($asSoonAsPossible) { + return DateTimeUtil::getSystemNowString(); + } + + try { + $cronExpression = CronExpression::factory($scheduling); + } catch (Exception $e) { + $this->log->error( + "Scheduled Job '$id': Scheduling expression error: " . + $e->getMessage() . '.'); + + return null; + } + + $timeZone = $this->configDataProvider->getTimeZone(); + + try { + $next = $cronExpression->getNextRunDate(timeZone: $timeZone) + ->setTimezone(new DateTimeZone('UTC')); + } catch (Exception) { + $this->log->error("Scheduled Job '$id': Unsupported scheduling expression '$scheduling'."); + + return null; + } + + return $next->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } +} diff --git a/application/Espo/Core/Job/ScheduleProcessor/Params.php b/application/Espo/Core/Job/ScheduleProcessor/Params.php new file mode 100644 index 0000000..7fcc091 --- /dev/null +++ b/application/Espo/Core/Job/ScheduleProcessor/Params.php @@ -0,0 +1,40 @@ +. + * + * 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\Job\ScheduleProcessor; + +/** + * @since 10.1.0 + */ +readonly class Params +{ + public function __construct( + public bool $skipQueues = false, + ) {} +} diff --git a/application/Espo/Core/Job/ScheduleUtil.php b/application/Espo/Core/Job/ScheduleUtil.php new file mode 100644 index 0000000..71ee763 --- /dev/null +++ b/application/Espo/Core/Job/ScheduleUtil.php @@ -0,0 +1,125 @@ +. + * + * 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\Job; + +use Espo\Core\Job\Job\Jobs\ProcessJobQueueE0; +use Espo\Core\Job\Job\Jobs\ProcessJobQueueQ0; +use Espo\Core\Job\Job\Jobs\ProcessJobQueueQ1; +use Espo\Core\Job\ScheduleProcessor\Params; +use Espo\Core\Name\Field; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\ORM\Collection; +use Espo\ORM\EntityManager; +use Espo\Entities\ScheduledJob; +use Espo\Entities\ScheduledJobLogRecord as ScheduledJobLogRecordEntity; +use Espo\ORM\Name\Attribute; + +/** + * @internal + */ +class ScheduleUtil +{ + public function __construct(private EntityManager $entityManager) + {} + + /** + * Get active scheduled job list. + * + * @return Collection + */ + public function getActiveScheduledJobs(Params $params): Collection + { + $collection = $this->entityManager + ->getRDBRepositoryByClass(ScheduledJob::class) + ->select([ + Attribute::ID, + ScheduledJob::FIELD_SCHEDULING, + ScheduledJob::FIELD_JOB, + Field::NAME, + ]) + ->where([ + ScheduledJob::FIELD_STATUS => ScheduledJob::STATUS_ACTIVE, + ]) + ->find(); + + if ($params->skipQueues) { + $collection = $collection->filter(function (ScheduledJob $entity) { + return !in_array($entity->getJob(), [ + ProcessJobQueueE0::NAME, + ProcessJobQueueQ0::NAME, + ProcessJobQueueQ1::NAME, + ]); + }); + } + + return $collection; + } + + /** + * Add record to ScheduledJobLogRecord about executed job. + */ + public function addLogRecord( + string $scheduledJobId, + string $status, + ?string $runTime = null, + ?string $targetId = null, + ?string $targetType = null + ): void { + + if (!isset($runTime)) { + $runTime = date(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } + + /** @var ScheduledJob|null $scheduledJob */ + $scheduledJob = $this->entityManager->getEntityById(ScheduledJob::ENTITY_TYPE, $scheduledJobId); + + if (!$scheduledJob) { + return; + } + + $scheduledJob->set('lastRun', $runTime); + + $this->entityManager->saveEntity($scheduledJob, [SaveOption::SILENT => true]); + + $scheduledJobLog = $this->entityManager->getNewEntity(ScheduledJobLogRecordEntity::ENTITY_TYPE); + + $scheduledJobLog->setMultiple([ + 'scheduledJobId' => $scheduledJobId, + 'name' => $scheduledJob->getName(), + 'status' => $status, + 'executionTime' => $runTime, + 'targetId' => $targetId, + 'targetType' => $targetType, + ]); + + $this->entityManager->saveEntity($scheduledJobLog); + } +} diff --git a/application/Espo/Core/Loaders/Acl.php b/application/Espo/Core/Loaders/Acl.php new file mode 100644 index 0000000..a6de33d --- /dev/null +++ b/application/Espo/Core/Loaders/Acl.php @@ -0,0 +1,46 @@ +. + * + * 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\Loaders; + +use Espo\Core\Acl as AclService; +use Espo\Core\AclManager; +use Espo\Core\Container\Loader; +use Espo\Entities\User; + +class Acl implements Loader +{ + public function __construct(private AclManager $aclManager, private User $user) + {} + + public function load(): AclService + { + return new AclService($this->aclManager, $this->user); + } +} diff --git a/application/Espo/Core/Loaders/AclManager.php b/application/Espo/Core/Loaders/AclManager.php new file mode 100644 index 0000000..1c4ebee --- /dev/null +++ b/application/Espo/Core/Loaders/AclManager.php @@ -0,0 +1,45 @@ +. + * + * 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\Loaders; + +use Espo\Core\AclManager as AclManagerService; +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; + +class AclManager implements Loader +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function load(): AclManagerService + { + return $this->injectableFactory->create(AclManagerService::class); + } +} diff --git a/application/Espo/Core/Loaders/ApplicationState.php b/application/Espo/Core/Loaders/ApplicationState.php new file mode 100644 index 0000000..7a912b1 --- /dev/null +++ b/application/Espo/Core/Loaders/ApplicationState.php @@ -0,0 +1,45 @@ +. + * + * 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\Loaders; + +use Espo\Core\ApplicationState as ApplicationStateService; +use Espo\Core\Container; +use Espo\Core\Container\Loader; + +class ApplicationState implements Loader +{ + public function __construct(private Container $container) + {} + + public function load(): ApplicationStateService + { + return new ApplicationStateService($this->container); + } +} diff --git a/application/Espo/Core/Loaders/ApplicationUser.php b/application/Espo/Core/Loaders/ApplicationUser.php new file mode 100644 index 0000000..6fd1234 --- /dev/null +++ b/application/Espo/Core/Loaders/ApplicationUser.php @@ -0,0 +1,45 @@ +. + * + * 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\Loaders; + +use Espo\Core\ApplicationUser as Service; +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; + +class ApplicationUser implements Loader +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function load(): Service + { + return $this->injectableFactory->create(Service::class); + } +} diff --git a/application/Espo/Core/Loaders/BaseLanguage.php b/application/Espo/Core/Loaders/BaseLanguage.php new file mode 100644 index 0000000..83d9814 --- /dev/null +++ b/application/Espo/Core/Loaders/BaseLanguage.php @@ -0,0 +1,52 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Language; + +class BaseLanguage implements Loader +{ + public function __construct( + private Language\LanguageFactory $languageFactory, + protected Config $config + ) {} + + public function load(): Language + { + return $this->languageFactory->create($this->getLanguage()); + } + + protected function getLanguage(): string + { + return 'en_US'; + } +} diff --git a/application/Espo/Core/Loaders/DataManager.php b/application/Espo/Core/Loaders/DataManager.php new file mode 100644 index 0000000..4e092f3 --- /dev/null +++ b/application/Espo/Core/Loaders/DataManager.php @@ -0,0 +1,46 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\DataManager as DataManagerService; +use Espo\Core\InjectableFactory; + +class DataManager implements Loader +{ + + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function load(): DataManagerService + { + return $this->injectableFactory->create(DataManagerService::class); + } +} diff --git a/application/Espo/Core/Loaders/DateTime.php b/application/Espo/Core/Loaders/DateTime.php new file mode 100644 index 0000000..bcceb51 --- /dev/null +++ b/application/Espo/Core/Loaders/DateTime.php @@ -0,0 +1,54 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime as DateTimeService; +use Espo\Core\Utils\DateTime\Clock; + +class DateTime implements Loader +{ + public function __construct( + private Config $config, + private Clock $clock, + ) {} + + public function load(): DateTimeService + { + return new DateTimeService( + dateFormat: $this->config->get('dateFormat'), + timeFormat: $this->config->get('timeFormat'), + timeZone: $this->config->get('timeZone'), + language: $this->config->get('language'), + clock: $this->clock, + ); + } +} diff --git a/application/Espo/Core/Loaders/DefaultLanguage.php b/application/Espo/Core/Loaders/DefaultLanguage.php new file mode 100644 index 0000000..cc70506 --- /dev/null +++ b/application/Espo/Core/Loaders/DefaultLanguage.php @@ -0,0 +1,40 @@ +. + * + * 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\Loaders; + +use Espo\Core\Utils\Language as LanguageService; + +class DefaultLanguage extends BaseLanguage +{ + protected function getLanguage(): string + { + return LanguageService::detectLanguage($this->config) ?? parent::getLanguage(); + } +} diff --git a/application/Espo/Core/Loaders/EntityManager.php b/application/Espo/Core/Loaders/EntityManager.php new file mode 100644 index 0000000..1aae06a --- /dev/null +++ b/application/Espo/Core/Loaders/EntityManager.php @@ -0,0 +1,49 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\ORM\EntityManager as EntityManagerService; +use Espo\Core\ORM\EntityManagerFactory; + +class EntityManager implements Loader +{ + private EntityManagerFactory $entityManagerFactory; + + public function __construct(EntityManagerFactory $entityManagerFactory) + { + $this->entityManagerFactory = $entityManagerFactory; + } + + public function load(): EntityManagerService + { + return $this->entityManagerFactory->create(); + } +} diff --git a/application/Espo/Core/Loaders/EventDispatcher.php b/application/Espo/Core/Loaders/EventDispatcher.php new file mode 100644 index 0000000..703166f --- /dev/null +++ b/application/Espo/Core/Loaders/EventDispatcher.php @@ -0,0 +1,55 @@ +. + * + * 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\Loaders; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Event\EventDispatcher as EventDispatcherService; +use Espo\Core\Utils\Event\OriginProvider; + +class EventDispatcher implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load(): EventDispatcherService + { + $originProvider = $this->injectableFactory->create(OriginProvider::class); + + return $this->injectableFactory->createWithBinding( + EventDispatcherService::class, + BindingContainerBuilder::create() + ->bindInstance(OriginProvider::class, $originProvider) + ->build() + ); + } +} diff --git a/application/Espo/Core/Loaders/HookDataProvider.php b/application/Espo/Core/Loaders/HookDataProvider.php new file mode 100644 index 0000000..5cd5ffd --- /dev/null +++ b/application/Espo/Core/Loaders/HookDataProvider.php @@ -0,0 +1,49 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\Hook\DataProvider; +use Espo\Core\InjectableFactory; + +/** + * @noinspection PhpUnused + */ +class HookDataProvider implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load(): DataProvider + { + return $this->injectableFactory->create(DataProvider::class); + } +} diff --git a/application/Espo/Core/Loaders/InternalAclManager.php b/application/Espo/Core/Loaders/InternalAclManager.php new file mode 100644 index 0000000..966055a --- /dev/null +++ b/application/Espo/Core/Loaders/InternalAclManager.php @@ -0,0 +1,45 @@ +. + * + * 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\Loaders; + +use Espo\Core\AclManager as AclManagerService; +use Espo\Core\Container; +use Espo\Core\Container\Loader; + +class InternalAclManager implements Loader +{ + public function __construct(private Container $container) + {} + + public function load(): AclManagerService + { + return $this->container->getByClass(AclManagerService::class); + } +} diff --git a/application/Espo/Core/Loaders/Language.php b/application/Espo/Core/Loaders/Language.php new file mode 100644 index 0000000..4a04335 --- /dev/null +++ b/application/Espo/Core/Loaders/Language.php @@ -0,0 +1,54 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Language as LanguageService; +use Espo\Entities\Preferences; + +class Language implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Config $config, + private Preferences $preferences, + private Config\SystemConfig $systemConfig, + ) {} + + public function load(): LanguageService + { + return $this->injectableFactory->createWith(LanguageService::class, [ + 'language' => LanguageService::detectLanguage($this->config, $this->preferences), + 'useCache' => $this->systemConfig->useCache(), + ]); + } +} diff --git a/application/Espo/Core/Loaders/Loader.php b/application/Espo/Core/Loaders/Loader.php new file mode 100644 index 0000000..6933c1b --- /dev/null +++ b/application/Espo/Core/Loaders/Loader.php @@ -0,0 +1,39 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader as BaseLoader; + +/** + * @deprecated Since v6.2.0. Use `Espo\Core\Container\Loader`. + * @todo Remove in v11.0. + */ +interface Loader extends BaseLoader +{} diff --git a/application/Espo/Core/Loaders/Log.php b/application/Espo/Core/Loaders/Log.php new file mode 100644 index 0000000..36846c7 --- /dev/null +++ b/application/Espo/Core/Loaders/Log.php @@ -0,0 +1,50 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\Log\LogLoader; +use Espo\Core\Utils\Log as LogService; + +class Log implements Loader +{ + public function __construct(private LogLoader $logLoader) + {} + + public function load(): LogService + { + $log = $this->logLoader->load(); + + // @todo Remove in future. + $GLOBALS['log'] = $log; + + return $log; + } +} diff --git a/application/Espo/Core/Loaders/Metadata.php b/application/Espo/Core/Loaders/Metadata.php new file mode 100644 index 0000000..0cf435b --- /dev/null +++ b/application/Espo/Core/Loaders/Metadata.php @@ -0,0 +1,45 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata as MetadataService; + +class Metadata implements Loader +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function load(): MetadataService + { + return $this->injectableFactory->create(MetadataService::class); + } +} diff --git a/application/Espo/Core/Loaders/NumberUtil.php b/application/Espo/Core/Loaders/NumberUtil.php new file mode 100644 index 0000000..cb9dced --- /dev/null +++ b/application/Espo/Core/Loaders/NumberUtil.php @@ -0,0 +1,45 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\NumberUtil as NumberUtilService; + +class NumberUtil implements Loader +{ + public function __construct(private Config $config) + {} + + public function load(): NumberUtilService + { + return new NumberUtilService($this->config->get('decimalMark'), $this->config->get('thousandSeparator')); + } +} diff --git a/application/Espo/Core/Loaders/OrmDefs.php b/application/Espo/Core/Loaders/OrmDefs.php new file mode 100644 index 0000000..cb38e3e --- /dev/null +++ b/application/Espo/Core/Loaders/OrmDefs.php @@ -0,0 +1,46 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\ORM\Defs; +use Espo\ORM\EntityManager; + +class OrmDefs implements Loader +{ + + public function __construct(private EntityManager $entityManager) + {} + + public function load(): Defs + { + return $this->entityManager->getMetadata()->getDefs(); + } +} diff --git a/application/Espo/Core/Loaders/PortalAclManagerContainer.php b/application/Espo/Core/Loaders/PortalAclManagerContainer.php new file mode 100644 index 0000000..f6a9559 --- /dev/null +++ b/application/Espo/Core/Loaders/PortalAclManagerContainer.php @@ -0,0 +1,44 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Portal\AclManagerContainer as PortalAclManagerContainerService; + +class PortalAclManagerContainer implements Loader +{ + public function __construct(private InjectableFactory $injectableFactory) {} + + public function load(): PortalAclManagerContainerService + { + return new PortalAclManagerContainerService($this->injectableFactory); + } +} diff --git a/application/Espo/Core/Loaders/Preferences.php b/application/Espo/Core/Loaders/Preferences.php new file mode 100644 index 0000000..45378e7 --- /dev/null +++ b/application/Espo/Core/Loaders/Preferences.php @@ -0,0 +1,56 @@ +. + * + * 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\Loaders; + +use Espo\Core\ApplicationState; +use Espo\Core\Container\Loader; +use Espo\Core\ORM\EntityManager; + +use Espo\Core\Utils\SystemUser; +use Espo\Entities\Preferences as PreferencesEntity; + +class Preferences implements Loader +{ + public function __construct( + private EntityManager $entityManager, + private ApplicationState $applicationState, + private SystemUser $systemUser + ) {} + + public function load(): PreferencesEntity + { + $id = $this->applicationState->hasUser() ? + $this->applicationState->getUser()->getId() : + $this->systemUser->getId(); + + /** @var PreferencesEntity */ + return $this->entityManager->getEntityById(PreferencesEntity::ENTITY_TYPE, $id); + } +} diff --git a/application/Espo/Core/Loaders/SystemState.php b/application/Espo/Core/Loaders/SystemState.php new file mode 100644 index 0000000..2a3c332 --- /dev/null +++ b/application/Espo/Core/Loaders/SystemState.php @@ -0,0 +1,49 @@ +. + * + * 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\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\System\SystemState as SystemStateService; + +/** + * @noinspection PhpUnused + */ +class SystemState implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function load(): SystemStateService + { + return $this->injectableFactory->create(SystemStateService::class); + } +} diff --git a/application/Espo/Core/Log/DefaultFormatter.php b/application/Espo/Core/Log/DefaultFormatter.php new file mode 100644 index 0000000..bc3b169 --- /dev/null +++ b/application/Espo/Core/Log/DefaultFormatter.php @@ -0,0 +1,116 @@ +. + * + * 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\Log; + +use Espo\Core\Api\Request; +use Monolog\Formatter\LineFormatter; +use Monolog\LogRecord; +use Throwable; + +class DefaultFormatter extends LineFormatter +{ + private const string LINE_FORMAT = "[%datetime%] %level_name%: %code% %message% %request%\n"; + private const string DATE_FORMAT = 'Y-m-d H:i:s'; + + public function __construct( + private bool $includeTraces = false, + ) { + parent::__construct( + format: self::LINE_FORMAT, + dateFormat: self::DATE_FORMAT, + ignoreEmptyContextAndExtra: true, + includeStacktraces: $this->includeTraces, + ); + } + + public function format(LogRecord $record): string + { + $line = parent::format($record); + + $line = $this->interpolate($record, $line); + $line = $this->addCode($record, $line); + $line = $this->addRequest($record, $line); + $line = $this->addException($record, $line); + + return trim($line) . "\n"; + } + + private function addCode(LogRecord $record, string $line): string + { + $exception = $record->context['exception'] ?? null; + + if (!$exception instanceof Throwable) { + return str_replace('%code% ', '', $line); + } + + $codePart = "({$exception->getCode()})"; + + return str_replace('%code% ', $codePart . ' ', $line); + } + + private function addException(LogRecord $record, string $line): string + { + $exception = $record->context['exception'] ?? null; + + if (!$exception instanceof Throwable) { + return str_replace('%exception%', '', $line); + } + + $line .= $this->normalizeException($exception); + + return $line; + } + + private function addRequest(LogRecord $record, string $line): string + { + $request = $record->context['request'] ?? null; + + if (!$request instanceof Request) { + return str_replace('%request%', '', $line); + } + + $requestPart = ":: {$request->getMethod()} {$request->getResourcePath()}"; + + return str_replace('%request%', $requestPart, $line); + } + + private function interpolate(LogRecord $record, mixed $line): string + { + $replace = []; + + foreach ($record->context as $key => $val) { + if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { + $replace['{' . $key . '}'] = $val; + } + } + + return strtr($line, $replace); + } +} diff --git a/application/Espo/Core/Log/DefaultHandlerLoader.php b/application/Espo/Core/Log/DefaultHandlerLoader.php new file mode 100644 index 0000000..ee00416 --- /dev/null +++ b/application/Espo/Core/Log/DefaultHandlerLoader.php @@ -0,0 +1,143 @@ +. + * + * 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\Log; + +use Monolog\Formatter\FormatterInterface; +use Monolog\Handler\FormattableHandlerInterface; +use Monolog\Handler\HandlerInterface; +use Monolog\Logger; + +use ReflectionClass; +use RuntimeException; + +/** + * @internal + * + * @phpstan-type DefaultHandlerLoaderData array{ + * className?: ?class-string, + * params?: ?array, + * level?: string|null, + * formatter?: ?FormatterData, + * } + * @phpstan-type FormatterData array{ + * className?: ?class-string, + * params?: ?array, + * } + */ +class DefaultHandlerLoader +{ + /** + * @param DefaultHandlerLoaderData $data + */ + public function load(array $data, ?string $defaultLevel = null): HandlerInterface + { + $params = $data['params'] ?? []; + $level = $data['level'] ?? $defaultLevel; + + if ($level) { + /** @phpstan-ignore-next-line */ + $params['level'] = Logger::toMonologLevel($level); + } + + $className = $data['className'] ?? null; + + if (!$className) { + throw new RuntimeException("Log handler does not have className specified."); + } + + $handler = $this->createInstance($className, $params); + + $formatter = $this->loadFormatter($data); + + if ($formatter && $handler instanceof FormattableHandlerInterface) { + $handler->setFormatter($formatter); + } + + return $handler; + } + + /** + * @internal + * @param DefaultHandlerLoaderData $data + */ + public function loadFormatter(array $data): ?FormatterInterface + { + $formatterData = $data['formatter'] ?? null; + + if (!$formatterData || !is_array($formatterData)) { + return null; + } + + $className = $formatterData['className'] ?? null; + + if (!$className) { + return null; + } + + $params = $formatterData['params'] ?? []; + + return $this->createInstance($className, $params); + } + + /** + * @template T of object + * @param class-string $className + * @param array $params + * @return T + */ + private function createInstance(string $className, array $params): object + { + $class = new ReflectionClass($className); + + $constructor = $class->getConstructor(); + + if (!$constructor) { + return $class->newInstanceArgs([]); + } + + $argumentList = []; + + foreach ($constructor->getParameters() as $parameter) { + $name = $parameter->getName(); + + if (array_key_exists($name, $params)) { + $value = $params[$name]; + } else if ($parameter->isDefaultValueAvailable()) { + $value = $parameter->getDefaultValue(); + } else { + continue; + } + + $argumentList[] = $value; + } + + return $class->newInstanceArgs($argumentList); + } +} diff --git a/application/Espo/Core/Log/EspoRotatingFileHandlerLoader.php b/application/Espo/Core/Log/EspoRotatingFileHandlerLoader.php new file mode 100644 index 0000000..8787c2f --- /dev/null +++ b/application/Espo/Core/Log/EspoRotatingFileHandlerLoader.php @@ -0,0 +1,53 @@ +. + * + * 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\Log; + +use Espo\Core\Log\Handler\EspoRotatingFileHandler; +use Espo\Core\Utils\Config; + +use Monolog\Handler\HandlerInterface; +use Monolog\Level; +use Monolog\Logger; + +class EspoRotatingFileHandlerLoader implements HandlerLoader +{ + public function __construct( + private readonly Config $config + ) {} + + public function load(array $params): HandlerInterface + { + $filename = $params['filename'] ?? 'data/logs/espo.log'; + $levelCode = $params['level'] ?? Level::Notice->value; + $level = Logger::toMonologLevel($levelCode); + + return new EspoRotatingFileHandler($this->config, $filename, 0, $level); + } +} diff --git a/application/Espo/Core/Log/Handler/DatabaseHandler.php b/application/Espo/Core/Log/Handler/DatabaseHandler.php new file mode 100644 index 0000000..ea06bfa --- /dev/null +++ b/application/Espo/Core/Log/Handler/DatabaseHandler.php @@ -0,0 +1,142 @@ +. + * + * 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\Log\Handler; + +use Espo\Core\Api\Request; +use Espo\Core\ApplicationState; +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Entities\AppLogRecord; +use Monolog\Handler\HandlerInterface; +use Monolog\Level; +use Monolog\LogRecord; +use Throwable; + +class DatabaseHandler implements HandlerInterface +{ + public function __construct( + private Level $level, + private EntityManagerProxy $entityManager, + private ApplicationState $applicationState + ) {} + + public function isHandling(LogRecord $record): bool + { + if (!$this->applicationState->hasUser()) { + return false; + } + + if ($record->context['isSql'] ?? false) { + return false; + } + + return $record->level->value >= $this->level->value; + } + + public function handle(LogRecord $record): bool + { + if (!$this->isHandling($record)) { + return false; + } + + try { + $logRecord = $this->entityManager->getRDBRepositoryByClass(AppLogRecord::class)->getNew(); + + $level = ucfirst($record->level->toPsrLogLevel()); + $message = $this->interpolate($record, $record->message); + + $logRecord + ->setLevel($level) + ->setMessage($message); + + $this->setException($record, $logRecord); + $this->setRequest($record, $logRecord); + + $this->entityManager->saveEntity($logRecord); + } catch (Throwable) { + // Nowhere to log. + } + + return false; + } + + private function interpolate(LogRecord $record, string $line): string + { + $replace = []; + + foreach ($record->context as $key => $val) { + if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { + $replace['{' . $key . '}'] = $val; + } + } + + return strtr($line, $replace); + } + + /** + * @param LogRecord[] $records + */ + public function handleBatch(array $records): void + { + foreach ($records as $record) { + $this->handle($record); + } + } + + public function close(): void + {} + + private function setException(LogRecord $record, AppLogRecord $logRecord): void + { + $exception = $record->context['exception'] ?? null; + + if (!$exception instanceof Throwable) { + return; + } + + $logRecord + ->setExceptionClass(get_class($exception)) + ->setFile($exception->getFile()) + ->setLine($exception->getLine()) + ->setCode($exception->getCode()); + } + + private function setRequest(LogRecord $record, AppLogRecord $logRecord): void + { + $request = $record->context['request'] ?? null; + + if (!$request instanceof Request) { + return; + } + + $logRecord + ->setRequestMethod($request->getMethod()) + ->setRequestResourcePath($request->getResourcePath()); + } +} diff --git a/application/Espo/Core/Log/Handler/EspoFileHandler.php b/application/Espo/Core/Log/Handler/EspoFileHandler.php new file mode 100644 index 0000000..ab68aae --- /dev/null +++ b/application/Espo/Core/Log/Handler/EspoFileHandler.php @@ -0,0 +1,103 @@ +. + * + * 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\Log\Handler; + +use Espo\Core\Utils\Config; +use Espo\Core\Utils\File\Manager as FileManager; + +use Monolog\Handler\StreamHandler as MonologStreamHandler; +use Monolog\Level; +use Monolog\LogRecord; + +use RuntimeException; +use Throwable; + +class EspoFileHandler extends MonologStreamHandler +{ + protected FileManager $fileManager; + + protected int $maxErrorMessageLength = 10000; + + public function __construct( + Config $config, + string $filename, + Level $level = Level::Debug, + bool $bubble = true + ) { + parent::__construct($filename, $level, $bubble); + + $defaultPermissions = $config->get('defaultPermissions'); + + $this->fileManager = new FileManager($defaultPermissions); + } + + protected function write(LogRecord $record): void + { + if (!$this->url) { + throw new RuntimeException("Missing a logger file path. Check logger params in config."); + } + + try { + if (!is_writable($this->url)) { + $checkFileResult = $this->fileManager->checkCreateFile($this->url); + + if (!$checkFileResult) { + return; + } + } + + $this->fileManager->appendContents( + $this->url, + $this->pruneMessage($record) + ); + } catch (Throwable $e) { + $msg = "Could not write file `$this->url`."; + + if ($e->getMessage()) { + $msg .= " Error message: " . $e->getMessage(); + } + + throw new RuntimeException($msg); + } + } + + private function pruneMessage(LogRecord $record): string + { + if (strlen($record->message) <= $this->maxErrorMessageLength) { + return $record->formatted; + } + + $message = substr($record->message, 0, $this->maxErrorMessageLength) . '...'; + + $record = $record->with(message: $message); + + return $this->getFormatter()->format($record); + } +} diff --git a/application/Espo/Core/Log/Handler/EspoRotatingFileHandler.php b/application/Espo/Core/Log/Handler/EspoRotatingFileHandler.php new file mode 100644 index 0000000..8383b5b --- /dev/null +++ b/application/Espo/Core/Log/Handler/EspoRotatingFileHandler.php @@ -0,0 +1,113 @@ +. + * + * 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\Log\Handler; + +use Espo\Core\Utils\Config; + +use Monolog\Level; + +class EspoRotatingFileHandler extends EspoFileHandler +{ + protected string $dateFormat = 'Y-m-d'; + protected string $filenameFormat = '{filename}-{date}'; + protected string $filename; + protected int $maxFiles; + + public function __construct( + Config $config, + string $filename, + int $maxFiles = 0, + Level $level = Level::Debug, + bool $bubble = true + ) { + $this->filename = $filename; + $this->maxFiles = $maxFiles; + + parent::__construct($config, $this->getTimedFilename(), $level, $bubble); + + $this->rotate(); + } + + protected function rotate(): void + { + if (0 === $this->maxFiles) { + return; // unlimited number of files for 0 + } + + $filePattern = $this->getFilePattern(); + $dirPath = $this->fileManager->getDirName($this->filename); + /** @var string[] $logFiles */ + $logFiles = $this->fileManager->getFileList($dirPath, false, $filePattern, true); + + if (!empty($logFiles) && count($logFiles) > $this->maxFiles) { + usort($logFiles, function ($a, $b) { + return strcmp($b, $a); + }); + + $logFilesToBeRemoved = array_slice($logFiles, $this->maxFiles); + + $this->fileManager->removeFile($logFilesToBeRemoved, $dirPath); + } + } + + protected function getTimedFilename(): string + { + $fileInfo = pathinfo($this->filename); + + $timedFilename = str_replace( + ['{filename}', '{date}'], + [$fileInfo['filename'], date($this->dateFormat)], + ($fileInfo['dirname'] ?? '') . '/' . $this->filenameFormat + ); + + if (!empty($fileInfo['extension'])) { + $timedFilename .= '.' . $fileInfo['extension']; + } + + return $timedFilename; + } + + protected function getFilePattern(): string + { + $fileInfo = pathinfo($this->filename); + + $glob = str_replace( + ['{filename}', '{date}'], + [$fileInfo['filename'], '.*'], + $this->filenameFormat + ); + + if (!empty($fileInfo['extension'])) { + $glob .= '\.'.$fileInfo['extension']; + } + + return '^' . $glob . '$'; + } +} diff --git a/application/Espo/Core/Log/HandlerListLoader.php b/application/Espo/Core/Log/HandlerListLoader.php new file mode 100644 index 0000000..ffbc883 --- /dev/null +++ b/application/Espo/Core/Log/HandlerListLoader.php @@ -0,0 +1,90 @@ +. + * + * 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\Log; + +use Monolog\Handler\FormattableHandlerInterface; +use Monolog\Handler\HandlerInterface; + +use Espo\Core\InjectableFactory; + +class HandlerListLoader +{ + public function __construct( + private readonly InjectableFactory $injectableFactory, + private readonly DefaultHandlerLoader $defaultLoader + ) {} + + /** + * @param array> $dataList + * @return HandlerInterface[] + */ + public function load(array $dataList, ?string $defaultLevel = null): array + { + $handlerList = []; + + foreach ($dataList as $item) { + $handler = $this->loadHandler($item, $defaultLevel); + + $handlerList[] = $handler; + } + + return $handlerList; + } + + /** + * @param array $data + */ + private function loadHandler(array $data, ?string $defaultLevel = null): HandlerInterface + { + $params = $data['params'] ?? []; + $params['level'] ??= $defaultLevel; + + /** @var ?class-string $loaderClassName */ + $loaderClassName = $data['loaderClassName'] ?? null; + + if ($loaderClassName) { + $loader = $this->injectableFactory->create($loaderClassName); + + $handler = $loader->load($params); + + if ($handler instanceof FormattableHandlerInterface) { + $formatter = $this->defaultLoader->loadFormatter($data); + + if ($formatter) { + $handler->setFormatter($formatter); + } + } + + return $handler; + } + + return $this->defaultLoader->load($data, $defaultLevel); + } +} diff --git a/application/Espo/Core/Log/HandlerLoader.php b/application/Espo/Core/Log/HandlerLoader.php new file mode 100644 index 0000000..9601e47 --- /dev/null +++ b/application/Espo/Core/Log/HandlerLoader.php @@ -0,0 +1,40 @@ +. + * + * 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\Log; + +use Monolog\Handler\HandlerInterface; + +interface HandlerLoader +{ + /** + * @param array $params + */ + public function load(array $params): HandlerInterface; +} diff --git a/application/Espo/Core/Log/LogLoader.php b/application/Espo/Core/Log/LogLoader.php new file mode 100644 index 0000000..c90d448 --- /dev/null +++ b/application/Espo/Core/Log/LogLoader.php @@ -0,0 +1,130 @@ +. + * + * 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\Log; + +use Espo\Core\Application\ApplicationParams; +use Espo\Core\ApplicationState; +use Espo\Core\Log\Handler\DatabaseHandler; +use Espo\Core\Log\Handler\EspoFileHandler; +use Espo\Core\Log\Handler\EspoRotatingFileHandler; +use Espo\Core\ORM\EntityManagerProxy; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Log; + +use Monolog\ErrorHandler as MonologErrorHandler; +use Monolog\Handler\HandlerInterface; +use Monolog\Logger; + +class LogLoader +{ + private const PATH = 'data/logs/espo.log'; + + private const MAX_FILE_NUMBER = 30; + private const DEFAULT_LEVEL = 'WARNING'; + + public function __construct( + private readonly Config $config, + private readonly HandlerListLoader $handlerListLoader, + private readonly EntityManagerProxy $entityManagerProxy, + private readonly ApplicationState $applicationState, + private readonly ApplicationParams $applicationParams, + ) {} + + public function load(): Log + { + $log = new Log('Espo'); + + $handlerDataList = $this->config->get('logger.handlerList') ?? null; + + if ($handlerDataList) { + $level = $this->config->get('logger.level'); + + $handlerList = $this->handlerListLoader->load($handlerDataList, $level); + } else { + $handlerList = [$this->createDefaultHandler()]; + } + + if ($this->config->get('logger.databaseHandler')) { + $handlerList[] = $this->createDatabaseHandler(); + } + + foreach ($handlerList as $handler) { + $log->pushHandler($handler); + } + + if (!$this->applicationParams->noErrorHandler) { + $errorHandler = new MonologErrorHandler($log); + + $errorHandler->registerExceptionHandler([], false); + $errorHandler->registerErrorHandler([], false); + } + + return $log; + } + + private function createDefaultHandler(): HandlerInterface + { + $path = $this->config->get('logger.path') ?? self::PATH; + $level = $this->config->get('logger.level') ?? self::DEFAULT_LEVEL; + $rotation = $this->config->get('logger.rotation') ?? true; + + $levelCode = Logger::toMonologLevel($level); + + if ($rotation) { + $maxFileNumber = $this->config->get('logger.maxFileNumber') ?? self::MAX_FILE_NUMBER; + + $handler = new EspoRotatingFileHandler($this->config, $path, $maxFileNumber, $levelCode, true); + } else { + $handler = new EspoFileHandler($this->config, $path, $levelCode, true); + } + + $formatter = new DefaultFormatter($this->printTrace()); + + $handler->setFormatter($formatter); + + return $handler; + } + + private function printTrace(): bool + { + return (bool) $this->config->get('logger.printTrace'); + } + + private function createDatabaseHandler(): HandlerInterface + { + $rawLevel = $this->config->get('logger.databaseHandlerLevel') ?? + $this->config->get('logger.level') ?? + self::DEFAULT_LEVEL; + + $level = Logger::toMonologLevel($rawLevel); + + return new DatabaseHandler($level, $this->entityManagerProxy, $this->applicationState); + } +} diff --git a/application/Espo/Core/Mail/Account/Account.php b/application/Espo/Core/Mail/Account/Account.php new file mode 100644 index 0000000..2942c28 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Account.php @@ -0,0 +1,175 @@ +. + * + * 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\Mail\Account; + +use Espo\Core\Field\Date; +use Espo\Core\Field\DateTime; +use Espo\Core\Field\Link; +use Espo\Core\Field\LinkMultiple; +use Espo\Core\Mail\SmtpParams; +use Espo\Entities\Email; + +interface Account +{ + /** + * Update fetch-data. + */ + public function updateFetchData(FetchData $fetchData): void; + + /** + * Update connected-at. + */ + public function updateConnectedAt(): void; + + /** + * Relate an email with the account. + */ + public function relateEmail(Email $email): void; + + /** + * Max number of emails to be fetched per iteration. + */ + public function getPortionLimit(): int; + + /** + * Is available for fetching. + */ + public function isAvailableForFetching(): bool; + + /** + * An email address of the account. + */ + public function getEmailAddress(): ?string; + + /** + * A user fetched emails will be assigned to (through the `assignedUsers` field). + */ + public function getAssignedUser(): ?Link; + + /** + * A user the account belongs to. + */ + public function getUser(): ?Link; + + /** + * Users email should be related to (put into inbox). + */ + public function getUsers(): LinkMultiple; + + /** + * Teams email should be related to. + */ + public function getTeams(): LinkMultiple; + + /** + * Fetched emails won't be marked as read upon fetching. + */ + public function keepFetchedEmailsUnread(): bool; + + /** + * Get fetch-data. + */ + public function getFetchData(): FetchData; + + /** + * Fetch email since a specific date. + */ + public function getFetchSince(): ?Date; + + /** + * A folder fetched emails should be put into. + */ + public function getEmailFolder(): ?Link; + + /** + * A group folder fetched emails should be put into. + */ + public function getGroupEmailFolder(): ?Link; + + /** + * Folders to fetch from. + * + * @return string[] + */ + public function getMonitoredFolderList(): array; + + /** + * Gen an ID. + */ + public function getId(): ?string; + + /** + * Get an entity type. + */ + public function getEntityType(): string; + + /** + * Get IMAP params. + */ + public function getImapParams(): ?ImapParams; + + /** + * @return ?class-string + */ + public function getImapHandlerClassName(): ?string; + + /** + * Get a SENT folder. + */ + public function getSentFolder(): ?string; + + /** + * Is available for sending. + */ + public function isAvailableForSending(): bool; + + /** + * Get SMTP params. + */ + public function getSmtpParams(): ?SmtpParams; + + /** + * Store sent emails on IMAP. + */ + public function storeSentEmails(): bool; + + /** + * Get the last connection time; + */ + public function getConnectedAt(): ?DateTime; + + + /** + * Get an email folder mapped to the email folder. + * + * @since 9.3.0 + */ + public function getMappedEmailFolder(string $folder): ?Link; +} diff --git a/application/Espo/Core/Mail/Account/CommonStorageFactory.php b/application/Espo/Core/Mail/Account/CommonStorageFactory.php new file mode 100644 index 0000000..78b6087 --- /dev/null +++ b/application/Espo/Core/Mail/Account/CommonStorageFactory.php @@ -0,0 +1,81 @@ +. + * + * 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\Mail\Account; + +use DirectoryTree\ImapEngine\Mailbox; +use Espo\Core\InjectableFactory; +use Espo\Core\Mail\Account\Storage\DirectoryTreeStorage; +use Espo\Core\Mail\Account\Storage\Handler; +use Espo\Core\Mail\Account\Storage\Params; + +/** + * @since 9.3.0 + */ +class CommonStorageFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function create(Params $params): DirectoryTreeStorage + { + $handlerClassName = $params->getImapHandlerClassName(); + + if ($handlerClassName && $params->getId()) { + $handler = $this->injectableFactory->create($handlerClassName); + + if ($handler instanceof Handler || method_exists($handler, 'handle')) { + $params = $handler->handle($params, $params->getId()); + } + } + + $encryption = match ($params->getSecurity()) { + Params::SECURITY_SSL => 'ssl', + Params::SECURITY_START_TLS => 'starttls', + default => null, + }; + + $authentication = $params->getAuthMechanism() === Params::AUTH_MECHANISM_XOAUTH ? + 'oauth' : 'plain'; + + $config = [ + 'host' => $params->getHost(), + 'port' => $params->getPort(), + 'username' => $params->getUsername(), + 'password' => $params->getPassword(), + 'encryption' => $encryption, + 'authentication' => $authentication, + ]; + + $mailbox = new Mailbox($config); + + return new DirectoryTreeStorage($mailbox); + } +} diff --git a/application/Espo/Core/Mail/Account/FetchData.php b/application/Espo/Core/Mail/Account/FetchData.php new file mode 100644 index 0000000..f4460b8 --- /dev/null +++ b/application/Espo/Core/Mail/Account/FetchData.php @@ -0,0 +1,159 @@ +. + * + * 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\Mail\Account; + +use Espo\Core\Utils\ObjectUtil; +use Espo\Core\Field\DateTime; + +use stdClass; +use RuntimeException; + +class FetchData +{ + private stdClass $data; + + /** + * @internal + */ + public function __construct( + ?stdClass $data = null, + private int $validityNumber = 0, + ) { + $this->data = ObjectUtil::clone($data ?? (object) []); + } + + public static function fromRaw(stdClass $data, int $fetchNumber): self + { + return new self($data, $fetchNumber); + } + + public function getRaw(): stdClass + { + return ObjectUtil::clone($this->data); + } + + public function getLastUid(string $folder): ?int + { + $id = $this->data->lastUID->$folder ?? null; + + if ($id === null) { + return null; + } + + // To int for bc. It used to be string. + return (int) $id; + } + + public function getUidValidity(string $folder): ?int + { + $id = $this->data->uidValidity->$folder ?? null; + + if (!is_int($id)) { + return null; + } + + return $id; + } + + public function setUidValidity(string $folder, ?int $uid): void + { + if (!property_exists($this->data, 'uidValidity')) { + $this->data->uidValidity = (object) []; + } + + $this->data->uidValidity->$folder = $uid; + } + + public function getLastDate(string $folder): ?DateTime + { + $value = $this->data->lastDate->$folder ?? null; + + if ($value === null) { + return null; + } + + // For backward compatibility. + if ($value === 0) { + return null; + } + + if (!is_string($value)) { + throw new RuntimeException("Bad value in fetch-data."); + } + + return DateTime::fromString($value); + } + + public function getForceByDate(string $folder): bool + { + return $this->data->byDate->$folder ?? false; + } + + public function setLastUid(string $folder, ?int $uniqueId): void + { + if (!property_exists($this->data, 'lastUID')) { + $this->data->lastUID = (object) []; + } + + $this->data->lastUID->$folder = $uniqueId; + } + + public function setLastDate(string $folder, ?DateTime $lastDate): void + { + if (!property_exists($this->data, 'lastDate')) { + $this->data->lastDate = (object) []; + } + + if ($lastDate === null) { + $this->data->lastDate->$folder = null; + + return; + } + + $this->data->lastDate->$folder = $lastDate->toString(); + } + + public function setForceByDate(string $folder, bool $forceByDate): void + { + if (!property_exists($this->data, 'byDate')) { + $this->data->byDate = (object) []; + } + + $this->data->byDate->$folder = $forceByDate; + } + + /** + * @since 10.0.0 + */ + public function getValidityNumber(): int + { + return $this->validityNumber; + } +} diff --git a/application/Espo/Core/Mail/Account/Fetcher.php b/application/Espo/Core/Mail/Account/Fetcher.php new file mode 100644 index 0000000..bd65fde --- /dev/null +++ b/application/Espo/Core/Mail/Account/Fetcher.php @@ -0,0 +1,457 @@ +. + * + * 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\Mail\Account; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Mail\Account\Fetcher\ConfigDataProvider; +use Espo\Core\Mail\Account\Fetcher\FiltersProvider; +use Espo\Core\Mail\Account\Fetcher\MessageFactory; +use Espo\Core\Mail\Account\Fetcher\Unlocker; +use Espo\Core\Mail\Account\Storage\Flag; +use Espo\Core\Mail\Exceptions\ImapError; +use Espo\Core\Mail\Exceptions\NoImap; +use Espo\Core\Mail\Importer; +use Espo\Core\Mail\Importer\Data as ImporterData; +use Espo\Core\Mail\Message; +use Espo\Core\Mail\ParserFactory; +use Espo\Core\Mail\Account\Hook\BeforeFetch as BeforeFetchHook; +use Espo\Core\Mail\Account\Hook\AfterFetch as AfterFetchHook; +use Espo\Core\Mail\Account\Hook\BeforeFetchResult as BeforeFetchHookResult; +use Espo\Core\Utils\Log; +use Espo\Core\Field\DateTime as DateTimeField; +use Espo\Entities\EmailFilter; +use Espo\Entities\Email; +use Espo\ORM\Collection; +use Throwable; +use DateTime; + +class Fetcher +{ + public function __construct( + private Importer $importer, + private StorageFactory $storageFactory, + private ConfigDataProvider $configDataProvider, + private Log $log, + private ParserFactory $parserFactory, + private FiltersProvider $filtersProvider, + private Unlocker $unlocker, + private MessageFactory $messageFactory, + private ?BeforeFetchHook $beforeFetchHook = null, + private ?AfterFetchHook $afterFetchHook = null, + ) {} + + /** + * @throws Error + * @throws ImapError + * @throws NoImap + */ + public function fetch(Account $account): void + { + if (!$account->isAvailableForFetching()) { + throw new Error("{$account->getEntityType()} {$account->getId()} is not active."); + } + + $monitoredFolderList = $account->getMonitoredFolderList(); + + if (count($monitoredFolderList) === 0) { + return; + } + + $filterList = $this->filtersProvider->get($account); + + $storage = $this->storageFactory->create($account); + + foreach ($monitoredFolderList as $folder) { + $this->fetchFolder($account, $folder, $storage, $filterList); + } + + $storage->close(); + } + + /** + * @param Collection $filterList + * @throws Error + * @throws ImapError + */ + private function fetchFolder( + Account $account, + string $folderOriginal, + Storage $storage, + Collection $filterList, + ): void { + + $fetchData = $account->getFetchData(); + + $folder = mb_convert_encoding($folderOriginal, 'UTF7-IMAP', 'UTF-8'); + + try { + $storage->selectFolder($folderOriginal); + + $uidValidity = $storage->getFolderStatus()->uidValidity; + } catch (Throwable $e) { + $this->log->error("Could not select IMAP folder. {type} {id}", [ + 'exception' => $e, + 'type' => $account->getEntityType(), + 'id' => $account->getId(), + ]); + + return; + } + + $lastUidValidity = $fetchData->getUidValidity($folder); + $lastId = $fetchData->getLastUid($folder); + $lastDate = $fetchData->getLastDate($folder); + $forceByDate = $fetchData->getForceByDate($folder); + $portionLimit = $forceByDate ? 0 : $account->getPortionLimit(); + + $previousLastId = $lastId; + $uidReset = false; + + if ($lastUidValidity !== null && $uidValidity !== $lastUidValidity) { + $forceByDate = true; + $previousLastId = null; + $lastId = null; + + $uidReset = true; + } + + $ids = $this->fetchIds( + account: $account, + storage: $storage, + lastUid: $lastId, + lastDate: $lastDate, + forceByDate: $forceByDate, + ); + + $counter = 0; + + foreach ($ids as $id) { + if ($counter === count($ids) - 1) { + $lastId = $id; + } + + if ($forceByDate && $previousLastId && $id <= $previousLastId) { + $counter++; + + continue; + } + + $email = $this->fetchEmail( + account: $account, + storage: $storage, + id: $id, + filterList: $filterList, + mappedEmailFolderId: $account->getMappedEmailFolder($folderOriginal)?->getId(), + ); + + $isLast = $counter === count($ids) - 1; + $isLastInPortion = $counter === $portionLimit - 1; + + if ($isLast || $isLastInPortion) { + $lastId = $id; + + if ($email && $email->getDateSent()) { + $lastDate = $email->getDateSent(); + + if ($lastDate->toTimestamp() >= (new DateTime())->getTimestamp()) { + $lastDate = DateTimeField::createNow(); + } + } + + break; + } + + $counter ++; + } + + if ($forceByDate) { + $lastDate = DateTimeField::createNow(); + } + + $fetchData->setLastDate($folder, $lastDate); + $fetchData->setLastUid($folder, $lastId); + $fetchData->setUidValidity($folder, $uidValidity); + + if ($forceByDate && $previousLastId) { + $ids = $storage->getUidsFromUid($previousLastId); + + if (count($ids) && $ids[0] > $previousLastId) { + $fetchData->setForceByDate($folder, false); + } + } + + if ($uidReset) { + $fetchData->setForceByDate($folder, false); + } + + if ( + !$forceByDate && + count($ids) && + $previousLastId && + $previousLastId >= $lastId + ) { + // Handling broken numbering. Next time fetch since the last date rather than the last UID. + // Supposed not to happen. + // @todo Consider removing. + $fetchData->setForceByDate($folder, true); + } + + $account->updateFetchData($fetchData); + } + + /** + * @return int[] + * @throws Error + * @throws ImapError + */ + private function fetchIds( + Account $account, + Storage $storage, + ?int $lastUid, + ?DateTimeField $lastDate, + bool $forceByDate, + ): array { + + if ($lastUid !== null && !$forceByDate) { + return $storage->getUidsFromUid($lastUid); + } + + if ($lastDate) { + return $storage->getUidsSinceDate($lastDate); + } + + if (!$account->getFetchSince()) { + throw new Error("{$account->getEntityType()} {$account->getId()}, no fetch-since."); + } + + $fetchSince = $account->getFetchSince()->toDateTime(); + + return $storage->getUidsSinceDate( + DateTimeField::fromDateTime($fetchSince) + ); + } + + /** + * @param Collection $filterList + */ + private function fetchEmail( + Account $account, + Storage $storage, + int $id, + Collection $filterList, + ?string $mappedEmailFolderId, + ): ?Email { + + $teamIdList = $account->getTeams()->getIdList(); + $userIdList = $account->getUsers()->getIdList(); + $userId = $account->getUser() ? $account->getUser()->getId() : null; + $assignedUserId = $account->getAssignedUser() ? $account->getAssignedUser()->getId() : null; + $groupEmailFolderId = $account->getGroupEmailFolder() ? $account->getGroupEmailFolder()->getId() : null; + + $fetchOnlyHeader = $this->checkFetchOnlyHeader($storage, $id); + + $folderData = $this->prepareFolderData($userId, $mappedEmailFolderId, $account); + + $flags = null; + + $parser = $this->parserFactory->create(); + + $importerData = ImporterData + ::create() + ->withTeamIdList($teamIdList) + ->withFilterList($filterList) + ->withFetchOnlyHeader($fetchOnlyHeader) + ->withFolderData($folderData) + ->withUserIdList($userIdList) + ->withAssignedUserId($assignedUserId) + ->withGroupEmailFolderId($groupEmailFolderId); + + try { + $message = $this->messageFactory->create( + id: $id, + storage: $storage, + parser: $parser, + peek: $account->keepFetchedEmailsUnread(), + ); + + $hookResult = null; + + if ($this->beforeFetchHook) { + $hookResult = $this->processBeforeFetchHook($account, $message); + } + + if ($hookResult && $hookResult->toSkip()) { + return null; + } + + if ($message->isFetched() && $account->keepFetchedEmailsUnread()) { + $flags = $message->getFlags(); + } + + $email = $this->importMessage($account, $message, $importerData); + + if (!$email) { + return null; + } + + $this->processUnseen($account, $flags, $storage, $id); + } catch (Throwable $e) { + $this->log->error("Import email message error. {type} {id}", [ + 'exception' => $e, + 'type' => $account->getEntityType(), + 'id' => $account->getId(), + ]); + + return null; + } + + $account->relateEmail($email); + $this->processAfterSaveHook($account, $email, $hookResult); + + return $email; + } + + private function processBeforeFetchHook(Account $account, Message $message): BeforeFetchHookResult + { + assert($this->beforeFetchHook !== null); + + try { + return $this->beforeFetchHook->process($account, $message); + } catch (Throwable $e) { + $this->log->error("Before-fetch message hook error. {type} {id}.", [ + 'exception' => $e, + 'type' => $account->getEntityType(), + 'id' => $account->getId(), + ]); + } + + return BeforeFetchHookResult::create()->withToSkip(); + } + + private function checkFetchOnlyHeader(Storage $storage, int $id): bool + { + $maxSize = $this->configDataProvider->getMessageMaxSize(); + + if (!$maxSize) { + return false; + } + + try { + $size = $storage->getSize($id); + } catch (Throwable) { + return false; + } + + if ($size > $maxSize * 1024 * 1024) { + return true; + } + + return false; + } + + private function importMessage( + Account $account, + Message $message, + ImporterData $data, + ): ?Email { + + try { + return $this->importer->import($message, $data); + } catch (Throwable $e) { + $this->log->error("Import message error. {type} {id}.", [ + 'exception' => $e, + 'type' => $account->getEntityType(), + 'id' => $account->getId(), + ]); + + $this->unlocker->process(); + } + + return null; + } + + /** + * @return array + */ + private function prepareFolderData(?string $userId, ?string $mappedEmailFolderId, Account $account): array + { + if (!$userId) { + return []; + } + + $folderData = []; + + if ($mappedEmailFolderId) { + $folderData[$userId] = $mappedEmailFolderId; + } else if ($account->getEmailFolder()) { + $folderData[$userId] = $account->getEmailFolder()->getId(); + } + + return $folderData; + } + + private function processAfterSaveHook(Account $account, Email $email, ?BeforeFetchHookResult $hookResult): void + { + if (!$this->afterFetchHook) { + return; + } + + try { + $this->afterFetchHook->process( + account: $account, + email: $email, + beforeFetchResult: $hookResult ?? BeforeFetchHookResult::create(), + ); + } catch (Throwable $e) { + $this->log->error("After-fetch message hook error. {type} {id}.", [ + 'exception' => $e, + 'type' => $account->getEntityType(), + 'id' => $account->getId(), + ]); + } + } + + /** + * Handles cases where the PEEK command is not supported by the IMAP server. + * + * @param ?string[] $flags + * @throws ImapError + */ + private function processUnseen(Account $account, ?array $flags, Storage $storage, int $id): void + { + if ( + $account->keepFetchedEmailsUnread() && + $flags !== null && + !in_array(Flag::SEEN, $flags) + ) { + $storage->unmarkSeen($id); + } + } +} diff --git a/application/Espo/Core/Mail/Account/Fetcher/ConfigDataProvider.php b/application/Espo/Core/Mail/Account/Fetcher/ConfigDataProvider.php new file mode 100644 index 0000000..f691d59 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Fetcher/ConfigDataProvider.php @@ -0,0 +1,44 @@ +. + * + * 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\Mail\Account\Fetcher; + +use Espo\Core\Utils\Config; + +class ConfigDataProvider +{ + public function __construct( + private Config $config, + ) {} + + public function getMessageMaxSize(): ?float + { + return $this->config->get('emailMessageMaxSize'); + } +} diff --git a/application/Espo/Core/Mail/Account/Fetcher/FiltersProvider.php b/application/Espo/Core/Mail/Account/Fetcher/FiltersProvider.php new file mode 100644 index 0000000..3c4fb17 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Fetcher/FiltersProvider.php @@ -0,0 +1,86 @@ +. + * + * 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\Mail\Account\Fetcher; + +use Espo\Core\Mail\Account\Account; +use Espo\Entities\EmailFilter; +use Espo\Entities\InboundEmail; +use Espo\ORM\Collection; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\Part\Order; + +class FiltersProvider +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + /** + * @return Collection + */ + public function get(Account $account): Collection + { + $actionList = [EmailFilter::ACTION_SKIP]; + + if ($account->getEntityType() === InboundEmail::ENTITY_TYPE) { + $actionList[] = EmailFilter::ACTION_MOVE_TO_GROUP_FOLDER; + } + + $builder = $this->entityManager + ->getRDBRepository(EmailFilter::ENTITY_TYPE) + ->where([ + 'action' => $actionList, + 'OR' => [ + [ + 'parentType' => $account->getEntityType(), + 'parentId' => $account->getId(), + 'action' => $actionList, + ], + [ + 'parentId' => null, + 'action' => EmailFilter::ACTION_SKIP, + ], + ] + ]); + + if (count($actionList) > 1) { + $builder->order( + Order::createByPositionInList( + Expression::column('action'), + $actionList + ) + ); + } + + /** @var Collection */ + return $builder->find(); + } +} diff --git a/application/Espo/Core/Mail/Account/Fetcher/MessageFactory.php b/application/Espo/Core/Mail/Account/Fetcher/MessageFactory.php new file mode 100644 index 0000000..a2a843a --- /dev/null +++ b/application/Espo/Core/Mail/Account/Fetcher/MessageFactory.php @@ -0,0 +1,62 @@ +. + * + * 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\Mail\Account\Fetcher; + +use Espo\Core\Mail\Account\Storage; +use Espo\Core\Mail\Exceptions\ImapError; +use Espo\Core\Mail\Message; +use Espo\Core\Mail\MessageWrapper; +use Espo\Core\Mail\Parser; + +/** + * @internal + */ +class MessageFactory +{ + public function __construct() {} + + /** + * @throws ImapError + */ + public function create( + int $id, + Storage $storage, + Parser $parser, + bool $peek, + ): Message { + + return new MessageWrapper( + id: $id, + storage: $storage, + parser: $parser, + peek: $peek, + ); + } +} diff --git a/application/Espo/Core/Mail/Account/Fetcher/Unlocker.php b/application/Espo/Core/Mail/Account/Fetcher/Unlocker.php new file mode 100644 index 0000000..27f0b21 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Fetcher/Unlocker.php @@ -0,0 +1,49 @@ +. + * + * 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\Mail\Account\Fetcher; + +use Espo\ORM\EntityManager; + +/** + * @internal + */ +class Unlocker +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function process(): void + { + if ($this->entityManager->getLocker()->isLocked()) { + $this->entityManager->getLocker()->rollback(); + } + } +} diff --git a/application/Espo/Core/Mail/Account/GroupAccount/Account.php b/application/Espo/Core/Mail/Account/GroupAccount/Account.php new file mode 100644 index 0000000..ab35cbc --- /dev/null +++ b/application/Espo/Core/Mail/Account/GroupAccount/Account.php @@ -0,0 +1,409 @@ +. + * + * 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\Mail\Account\GroupAccount; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Field\Date; +use Espo\Core\Field\DateTime; +use Espo\Core\Field\Link; +use Espo\Core\Field\LinkMultiple; +use Espo\Core\Mail\Exceptions\NoSmtp; +use Espo\Core\Mail\Account\ImapParams; +use Espo\Core\Mail\Smtp\HandlerProcessor; +use Espo\Core\Mail\SmtpParams; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Crypt; +use Espo\Entities\GroupEmailFolder; +use Espo\Entities\InboundEmail; +use Espo\Entities\User; +use Espo\Entities\Email; +use Espo\ORM\EntityManager; +use Espo\Core\Mail\Account\Account as AccountInterface; +use Espo\Core\Mail\Account\FetchData; + +use Espo\ORM\Mapper\Util; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\UpdateBuilder; +use RuntimeException; + +class Account implements AccountInterface +{ + private const PORTION_LIMIT = 20; + + private ?LinkMultiple $users = null; + private ?LinkMultiple $teams = null; + private ?GroupEmailFolder $groupEmailFolder = null; + + public function __construct( + private InboundEmail $entity, + private EntityManager $entityManager, + private Config $config, + private HandlerProcessor $handlerProcessor, + private Crypt $crypt + ) {} + + public function updateFetchData(FetchData $fetchData): void + { + $query = UpdateBuilder::create() + ->in(InboundEmail::ENTITY_TYPE) + ->where([ + InboundEmail::FIELD_FETCH_VALIDITY_NUMBER => $fetchData->getValidityNumber(), + Attribute::ID => $this->entity->getId(), + Attribute::DELETED => false, + ]) + ->set([ + InboundEmail::FIELD_FETCH_DATA => Util::prepareValue(FieldType::JSON_OBJECT, $fetchData->getRaw()), + ]) + ->build(); + + $sth = $this->entityManager->getQueryExecutor()->execute($query); + + if ($sth->rowCount() !== 0) { + $this->entity->setFetchData($fetchData); + $this->entity->setAsFetched(); + } + } + + public function updateConnectedAt(): void + { + $this->entity->set('connectedAt', DateTime::createNow()->toString()); + + $this->entityManager->saveEntity($this->entity, [SaveOption::SILENT => true]); + } + + public function relateEmail(Email $email): void + { + $this->entityManager + ->getRDBRepository(InboundEmail::ENTITY_TYPE) + ->getRelation($this->entity, 'emails') + ->relate($email); + } + + public function getEntity(): InboundEmail + { + return $this->entity; + } + + public function getPortionLimit(): int + { + return $this->config->get('inboundEmailMaxPortionSize', self::PORTION_LIMIT); + } + + public function isAvailableForFetching(): bool + { + return $this->entity->isAvailableForFetching(); + } + + public function getEmailAddress(): ?string + { + return $this->entity->getEmailAddress(); + } + + public function getUsers(): LinkMultiple + { + if (!$this->users) { + $this->users = $this->loadUsers(); + } + + return $this->users; + } + + private function getGroupEmailFolderEntity(): ?GroupEmailFolder + { + if ($this->groupEmailFolder) { + return $this->groupEmailFolder; + } + + if ($this->entity->getGroupEmailFolder()) { + $this->groupEmailFolder = $this->entityManager + ->getRDBRepositoryByClass(GroupEmailFolder::class) + ->getById($this->entity->getGroupEmailFolder()->getId()); + + return $this->groupEmailFolder; + } + + return null; + } + + private function loadUsers(): LinkMultiple + { + $teamIds = []; + + if ($this->entity->getGroupEmailFolder()) { + $groupEmailFolder = $this->getGroupEmailFolderEntity(); + + if ($groupEmailFolder) { + $teamIds = $groupEmailFolder->getTeams()->getIdList(); + } + } + + if ($this->entity->addAllTeamUsers()) { + $teamIds = array_merge($teamIds, $this->entity->getTeams()->getIdList()); + } + + $teamIds = array_unique($teamIds); + $teamIds = array_values($teamIds); + + if ($teamIds === []) { + return LinkMultiple::create(); + } + + $users = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->select([Attribute::ID]) + ->distinct() + ->join(Field::TEAMS) + ->where([ + 'type' => [User::TYPE_REGULAR, User::TYPE_ADMIN], + 'isActive' => true, + 'teamsMiddle.teamId' => $teamIds, + ]) + ->find(); + + $linkMultiple = LinkMultiple::create(); + + foreach ($users as $user) { + $linkMultiple = $linkMultiple->withAddedId($user->getId()); + } + + return $linkMultiple; + } + + public function getUser(): ?Link + { + return null; + } + + public function getAssignedUser(): ?Link + { + return $this->entity->getAssignToUser(); + } + + public function getTeams(): LinkMultiple + { + if (!$this->teams) { + $this->teams = $this->loadTeams(); + } + + return $this->teams; + } + + private function loadTeams(): LinkMultiple + { + $teams = $this->entity->getTeams(); + + if ($this->entity->getTeam()) { + $teams = $teams->withAddedId($this->entity->getTeam()->getId()); + } + + if ($this->getGroupEmailFolder()) { + $groupEmailFolder = $this->getGroupEmailFolderEntity(); + + if ($groupEmailFolder) { + $teams = $teams->withAddedIdList($groupEmailFolder->getTeams()->getIdList()); + } + } + + return $teams; + } + + public function keepFetchedEmailsUnread(): bool + { + return $this->entity->keepFetchedEmailsUnread(); + } + + public function getFetchData(): FetchData + { + return FetchData::fromRaw( + $this->entity->getFetchData(), + $this->entity->getFetchValidityNumber(), + ); + } + + public function getFetchSince(): ?Date + { + return $this->entity->getFetchSince(); + } + + public function getEmailFolder(): ?Link + { + return $this->entity->getEmailFolder(); + } + + /** + * @return string[] + */ + public function getMonitoredFolderList(): array + { + return $this->entity->getMonitoredFolderList(); + } + + public function getId(): ?string + { + return $this->entity->getId(); + } + + public function getEntityType(): string + { + return $this->entity->getEntityType(); + } + + /** + * @return ?class-string + */ + public function getImapHandlerClassName(): ?string + { + return $this->entity->getImapHandlerClassName(); + } + + public function createCase(): bool + { + return $this->entity->createCase(); + } + + public function autoReply(): bool + { + return $this->entity->autoReply(); + } + + public function getSentFolder(): ?string + { + return $this->entity->getSentFolder(); + } + + public function getGroupEmailFolder(): ?Link + { + return $this->entity->getGroupEmailFolder(); + } + + public function isAvailableForSending(): bool + { + return $this->entity->isAvailableForSending(); + } + + /** + * @throws NoSmtp + */ + public function getSmtpParams(): ?SmtpParams + { + $host = $this->entity->getSmtpHost(); + + if (!$host) { + return null; + } + + $port = $this->entity->getSmtpPort(); + + if ($port === null) { + throw new NoSmtp("Empty port."); + } + + $smtpParams = SmtpParams::create($host, $port) + ->withSecurity($this->entity->getSmtpSecurity()) + ->withAuth($this->entity->getSmtpAuth()); + + if ($this->entity->getSmtpAuth()) { + $password = $this->entity->getSmtpPassword(); + + if ($password !== null) { + $password = $this->crypt->decrypt($password); + } + + $smtpParams = $smtpParams + ->withUsername($this->entity->getSmtpUsername()) + ->withPassword($password) + ->withAuthMechanism($this->entity->getSmtpAuthMechanism()); + } + + if ($this->entity->getFromName()) { + $smtpParams = $smtpParams->withFromName($this->entity->getFromName()); + } + + $handlerClassName = $this->entity->getSmtpHandlerClassName(); + + if (!$handlerClassName) { + return $smtpParams; + } + + return $this->handlerProcessor->handle($handlerClassName, $smtpParams, $this->getId()); + } + + public function storeSentEmails(): bool + { + return $this->entity->storeSentEmails(); + } + + public function getImapParams(): ?ImapParams + { + $host = $this->entity->getHost(); + $port = $this->entity->getPort(); + $username = $this->entity->getUsername(); + $password = $this->entity->getPassword(); + $security = $this->entity->getSecurity(); + + if (!$host) { + return null; + } + + if ($port === null) { + throw new RuntimeException("No port."); + } + + if ($username === null) { + throw new RuntimeException("No username."); + } + + if ($password !== null) { + $password = $this->crypt->decrypt($password); + } + + return new ImapParams( + $host, + $port, + $username, + $password, + $security + ); + } + + public function getConnectedAt(): ?DateTime + { + /** @var DateTime */ + return $this->entity->getValueObject('connectedAt'); + } + + public function getMappedEmailFolder(string $folder): ?Link + { + return null; + } +} diff --git a/application/Espo/Core/Mail/Account/GroupAccount/AccountFactory.php b/application/Espo/Core/Mail/Account/GroupAccount/AccountFactory.php new file mode 100644 index 0000000..52e2add --- /dev/null +++ b/application/Espo/Core/Mail/Account/GroupAccount/AccountFactory.php @@ -0,0 +1,63 @@ +. + * + * 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\Mail\Account\GroupAccount; + +use Espo\Core\Exceptions\Error; +use Espo\Core\InjectableFactory; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Entities\InboundEmail; +use Espo\ORM\EntityManager; + +class AccountFactory +{ + + public function __construct( + private InjectableFactory $injectableFactory, + private EntityManager $entityManager + ) {} + + /** + * @throws Error + */ + public function create(string $id): Account + { + $entity = $this->entityManager->getEntityById(InboundEmail::ENTITY_TYPE, $id); + + if (!$entity) { + throw new Error("InboundEmail '{$id}' not found."); + } + + $binding = BindingContainerBuilder::create() + ->bindInstance(InboundEmail::class, $entity) + ->build(); + + return $this->injectableFactory->createWithBinding(Account::class, $binding); + } +} diff --git a/application/Espo/Core/Mail/Account/GroupAccount/BouncedRecognizer.php b/application/Espo/Core/Mail/Account/GroupAccount/BouncedRecognizer.php new file mode 100644 index 0000000..81cfdfb --- /dev/null +++ b/application/Espo/Core/Mail/Account/GroupAccount/BouncedRecognizer.php @@ -0,0 +1,156 @@ +. + * + * 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\Mail\Account\GroupAccount; + +use Espo\Core\Mail\Exceptions\ImapError; +use Espo\Core\Mail\Message; +use Espo\Core\Mail\Message\Part; + +use const PREG_SPLIT_NO_EMPTY; + +class BouncedRecognizer +{ + /** @var string[] */ + private array $hardBounceCodeList = [ + '5.0.0', + '5.1.1', // bad destination mailbox address + '5.1.2', // bad destination system address + '5.1.6', // destination mailbox has moved, no forwarding address + '5.4.1', // no answer from host + ]; + + public function isBounced(Message $message): bool + { + $from = $message->getHeader('From'); + $contentType = $message->getHeader('Content-Type'); + + if (preg_match('/MAILER-DAEMON|POSTMASTER/i', $from ?? '')) { + return true; + } + + if (str_starts_with($contentType ?? '', 'multipart/report')) { + // @todo Check whether ever works. + $deliveryStatusPart = $this->getDeliveryStatusPart($message); + + if ($deliveryStatusPart) { + return true; + } + + try { + $content = $message->getRawContent(); + } catch (ImapError) { + return false; + } + + if ( + str_contains($content, 'message/delivery-status') && + str_contains($content, 'Status: ') + ) { + return true; + } + } + + return false; + } + + public function isHard(Message $message): bool + { + $content = $message->getRawContent(); + + /** @noinspection RegExpSimplifiable */ + /** @noinspection RegExpDuplicateCharacterInClass */ + if (preg_match('/permanent[ ]*[error|failure]/', $content)) { + return true; + } + + $m = null; + + $has5xxStatus = preg_match('/Status: (5\.[0-9]\.[0-9])/', $content, $m); + + if ($has5xxStatus) { + $status = $m[1] ?? null; + + if (in_array($status, $this->hardBounceCodeList)) { + return true; + } + } + + return false; + } + + public function extractStatus(Message $message): ?string + { + $content = $message->getRawContent(); + + $m = null; + + $hasStatus = preg_match('/Status: ([0-9]\.[0-9]\.[0-9])/', $content, $m); + + if ($hasStatus) { + return $m[1] ?? null; + } + + return null; + } + + public function extractQueueItemId(Message $message): ?string + { + $content = $message->getRawContent(); + + if (preg_match('/X-Queue-Item-Id: [a-z0-9\-]*/', $content, $m)) { + /** @var array{string} $arr */ + $arr = preg_split('/X-Queue-Item-Id: /', $m[0], -1, PREG_SPLIT_NO_EMPTY); + + return $arr[0]; + } + + $to = $message->getHeader('to'); + + if (preg_match('/\+bounce-qid-[a-z0-9\-]*/', $to ?? '', $m)) { + /** @var array{string} $arr */ + $arr = preg_split('/\+bounce-qid-/', $m[0], -1, PREG_SPLIT_NO_EMPTY); + + return $arr[0]; + } + + return null; + } + + private function getDeliveryStatusPart(Message $message): ?Part + { + foreach ($message->getPartList() as $part) { + if ($part->getContentType() === 'message/delivery-status') { + return $part; + } + } + + return null; + } +} diff --git a/application/Espo/Core/Mail/Account/GroupAccount/FetcherFactory.php b/application/Espo/Core/Mail/Account/GroupAccount/FetcherFactory.php new file mode 100644 index 0000000..d519897 --- /dev/null +++ b/application/Espo/Core/Mail/Account/GroupAccount/FetcherFactory.php @@ -0,0 +1,67 @@ +. + * + * 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\Mail\Account\GroupAccount; + +use Espo\Core\Binding\Factory; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\InjectableFactory; + +use Espo\Core\Mail\Account\Hook\BeforeFetch; +use Espo\Core\Mail\Account\Hook\AfterFetch; +use Espo\Core\Mail\Account\GroupAccount\Hooks\BeforeFetch as GroupAccountBeforeFetch; +use Espo\Core\Mail\Account\GroupAccount\Hooks\AfterFetch as GroupAccountAfterFetch; + +use Espo\Core\Mail\Account\StorageFactory; +use Espo\Core\Mail\Account\GroupAccount\StorageFactory as GroupAccountStorageFactory; +use Espo\Core\Mail\Account\Fetcher; + +/** + * @implements Factory + */ +class FetcherFactory implements Factory +{ + private InjectableFactory $injectableFactory; + + public function __construct(InjectableFactory $injectableFactory) + { + $this->injectableFactory = $injectableFactory; + } + + public function create(): Fetcher + { + $binding = BindingContainerBuilder::create() + ->bindImplementation(BeforeFetch::class, GroupAccountBeforeFetch::class) + ->bindImplementation(AfterFetch::class, GroupAccountAfterFetch::class) + ->bindImplementation(StorageFactory::class, GroupAccountStorageFactory::class) + ->build(); + + return $this->injectableFactory->createWithBinding(Fetcher::class, $binding); + } +} diff --git a/application/Espo/Core/Mail/Account/GroupAccount/Hooks/AfterFetch.php b/application/Espo/Core/Mail/Account/GroupAccount/Hooks/AfterFetch.php new file mode 100644 index 0000000..034991a --- /dev/null +++ b/application/Espo/Core/Mail/Account/GroupAccount/Hooks/AfterFetch.php @@ -0,0 +1,612 @@ +. + * + * 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\Mail\Account\GroupAccount\Hooks; + +use Espo\Core\Name\Field; +use Espo\Core\Name\Link; + +use Espo\Core\Mail\Account\GroupAccount\AccountFactory as GroupAccountFactory; +use Espo\Core\Mail\SenderParams; +use Espo\Core\Templates\Entities\Person; +use Espo\Core\Utils\SystemUser; +use Espo\Modules\Crm\Entities\Contact; +use Espo\Modules\Crm\Entities\Lead; +use Espo\Tools\Email\Util; +use Espo\Tools\EmailTemplate\Data as EmailTemplateData; +use Espo\Tools\EmailTemplate\Params as EmailTemplateParams; +use Espo\Tools\EmailTemplate\Service as EmailTemplateService; +use Espo\Core\Mail\Account\Account; +use Espo\Core\Mail\Account\Hook\BeforeFetchResult; +use Espo\Core\Mail\Account\Hook\AfterFetch as AfterFetchInterface; +use Espo\Core\Mail\Account\GroupAccount\Account as GroupAccount; +use Espo\Core\Mail\EmailSender; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Core\Utils\Log; +use Espo\Tools\Stream\Service as StreamService; +use Espo\Entities\InboundEmail; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\Entities\Team; +use Espo\Entities\EmailAddress; +use Espo\Entities\Attachment; +use Espo\Modules\Crm\Entities\CaseObj; +use Espo\Repositories\EmailAddress as EmailAddressRepository; +use Espo\Repositories\Attachment as AttachmentRepository; +use Espo\ORM\EntityManager; +use Espo\Modules\Crm\Tools\Case\Distribution\RoundRobin; +use Espo\Modules\Crm\Tools\Case\Distribution\LeastBusy; + +use Throwable; +use DateTime; + +class AfterFetch implements AfterFetchInterface +{ + private const DEFAULT_AUTOREPLY_LIMIT = 5; + private const DEFAULT_AUTOREPLY_SUPPRESS_PERIOD = '2 hours'; + + public function __construct( + private EntityManager $entityManager, + private StreamService $streamService, + private Config $config, + private EmailSender $emailSender, + private Log $log, + private RoundRobin $roundRobin, + private LeastBusy $leastBusy, + private GroupAccountFactory $groupAccountFactory, + private EmailTemplateService $emailTemplateService, + private SystemUser $systemUser + ) {} + + public function process(Account $account, Email $email, BeforeFetchResult $beforeFetchResult): void + { + if (!$account instanceof GroupAccount) { + return; + } + + if (!$account->createCase() && !$email->isFetched()) { + $this->noteAboutEmail($email); + } + + if ($account->createCase()) { + if ($beforeFetchResult->get('isAutoSubmitted')) { + return; + } + + $emailToProcess = $email; + + if ($email->isFetched()) { + $emailToProcess = $this->entityManager->getEntityById(Email::ENTITY_TYPE, $email->getId()); + } else { + $emailToProcess->updateFetchedValues(); + } + + if ($emailToProcess) { + $this->createCase($account, $email); + } + + return; + } + + if ($account->autoReply()) { + if ( + $beforeFetchResult->get('isAutoSubmitted') || + $beforeFetchResult->get('skipAutoReply') + ) { + return; + } + + $user = null; + + if ($account->getAssignedUser()) { + $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $account->getAssignedUser()->getId()); + } + + $this->autoReply($account, $email, null, $user); + } + } + + private function noteAboutEmail(Email $email): void + { + if (!$email->getParent()) { + return; + } + + $this->streamService->noteEmailReceived($email->getParent(), $email); + } + + private function autoReply( + GroupAccount $account, + Email $email, + ?CaseObj $case = null, + ?User $user = null + ): void { + + $inboundEmail = $account->getEntity(); + + $fromAddress = $email->getFromAddress(); + + if (!$fromAddress) { + return; + } + + $replyEmailTemplateId = $inboundEmail->get('replyEmailTemplateId'); + + if (!$replyEmailTemplateId) { + return; + } + + $limit = $this->config->get('emailAutoReplyLimit', self::DEFAULT_AUTOREPLY_LIMIT); + + $d = new DateTime(); + + $period = $this->config->get('emailAutoReplySuppressPeriod', self::DEFAULT_AUTOREPLY_SUPPRESS_PERIOD); + + $d->modify('-' . $period); + + $threshold = $d->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + + /** @var EmailAddressRepository $emailAddressRepository */ + $emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + $emailAddress = $emailAddressRepository->getByAddress($fromAddress); + + if ($emailAddress) { + $sentCount = $this->entityManager + ->getRDBRepository(Email::ENTITY_TYPE) + ->where([ + 'toEmailAddresses.id' => $emailAddress->getId(), + 'dateSent>' => $threshold, + 'status' => Email::STATUS_SENT, + 'createdById' => $this->systemUser->getId(), + ]) + ->join('toEmailAddresses') + ->count(); + + if ($sentCount >= $limit) { + return; + } + } + + $sender = $this->emailSender->create(); + + if ($email->getMessageId()) { + $sender->withAddedHeader('In-Reply-To', $email->getMessageId()); + } + + $sender + ->withAddedHeader('Auto-Submitted', 'auto-replied') + ->withAddedHeader('X-Auto-Response-Suppress', 'All') + ->withAddedHeader('Precedence', 'auto_reply'); + + try { + $entityHash = []; + + $contact = null; + + if ($case) { + $entityHash[CaseObj::ENTITY_TYPE] = $case; + + $contact = $case->getContact(); + } + + if (!$contact) { + $contact = $this->entityManager->getNewEntity(Contact::ENTITY_TYPE); + + $fromName = Util::parseFromName($email->getFromString() ?? ''); + + if (!empty($fromName)) { + $contact->set(Field::NAME, $fromName); + } + } + + $entityHash[Person::TEMPLATE_TYPE] = $contact; + $entityHash[Contact::ENTITY_TYPE] = $contact; + $entityHash[Email::ENTITY_TYPE] = $email; + + if ($user) { + $entityHash[User::ENTITY_TYPE] = $user; + } + + $replyData = $this->emailTemplateService->process( + $replyEmailTemplateId, + EmailTemplateData::create() + ->withEntityHash($entityHash), + EmailTemplateParams::create() + ->withApplyAcl(false) + ->withCopyAttachments() + ); + + $subject = $replyData->getSubject(); + + if ($case && $case->getNumber() !== null) { + $subject = "[#{$case->getNumber()}] $subject"; + } + + $reply = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew(); + + $reply + ->addToAddress($fromAddress) + ->setSubject($subject) + ->setBody($replyData->getBody()) + ->setIsHtml($replyData->isHtml()) + ->setIsAutoReply() + ->setTeams($email->getTeams()); + + if ($email->getParentId() && $email->getParentType()) { + $reply->setParent($email->getParent()); + } + + $this->entityManager->saveEntity($reply); + + $senderParams = SenderParams::create(); + + if ($inboundEmail->isAvailableForSending()) { + $groupAccount = $this->groupAccountFactory->create($inboundEmail->getId()); + + $smtpParams = $groupAccount->getSmtpParams(); + + if ($smtpParams) { + $sender->withSmtpParams($smtpParams); + } + + if ($groupAccount->getEmailAddress()) { + $senderParams = $senderParams->withFromAddress($groupAccount->getEmailAddress()); + } + } + + if ($inboundEmail->getFromName()) { + $senderParams = $senderParams->withFromName($inboundEmail->getFromName()); + } + + if ($inboundEmail->getReplyFromAddress()) { + $senderParams = $senderParams->withFromAddress($inboundEmail->getReplyFromAddress()); + } + + if ($inboundEmail->getReplyFromName()) { + $senderParams = $senderParams->withFromName($inboundEmail->getReplyFromName()); + } + + if ($inboundEmail->getReplyToAddress()) { + $senderParams = $senderParams->withReplyToAddress($inboundEmail->getReplyToAddress()); + } + + $sender + ->withParams($senderParams) + ->withAttachments($replyData->getAttachmentList()) + ->send($reply); + + $this->entityManager->saveEntity($reply); + } catch (Throwable $e) { + $this->log->error("Inbound Email: Auto-reply error: " . $e->getMessage(), ['exception' => $e]); + } + } + + private function createCase(GroupAccount $account, Email $email): void + { + $inboundEmail = $account->getEntity(); + + $parentId = $email->getParentId(); + + if ( + $email->getParentType() === CaseObj::ENTITY_TYPE && + $parentId + ) { + $case = $this->entityManager + ->getRDBRepositoryByClass(CaseObj::class) + ->getById($parentId); + + if (!$case) { + return; + } + + $this->processCaseToEmailFields($case, $email); + + if (!$email->isFetched()) { + $this->streamService->noteEmailReceived($case, $email); + } + + return; + } + + /** @noinspection RegExpRedundantEscape */ + if (preg_match('/\[#([0-9]+)[^0-9]*\]/', $email->get(Field::NAME), $m)) { + $caseNumber = $m[1]; + + $case = $this->entityManager + ->getRDBRepository(CaseObj::ENTITY_TYPE) + ->where([ + 'number' => $caseNumber, + ]) + ->findOne(); + + if (!$case) { + return; + } + + $email->set('parentType', CaseObj::ENTITY_TYPE); + $email->set('parentId', $case->getId()); + + $this->processCaseToEmailFields($case, $email); + + if (!$email->isFetched()) { + $this->streamService->noteEmailReceived($case, $email); + } + + return; + } + + $params = [ + 'caseDistribution' => $inboundEmail->getCaseDistribution(), + 'teamId' => $inboundEmail->get('teamId'), + 'userId' => $inboundEmail->get('assignToUserId'), + 'targetUserPosition' => $inboundEmail->getTargetUserPosition(), + 'inboundEmailId' => $inboundEmail->getId(), + ]; + + $case = $this->emailToCase($email, $params); + + $assignedUserLink = $case->getAssignedUser(); + + $user = $assignedUserLink ? + $this->entityManager->getEntityById(User::ENTITY_TYPE, $assignedUserLink->getId()) + : null; + + $this->streamService->noteEmailReceived($case, $email, true); + + if ($account->autoReply()) { + $this->autoReply($account, $email, $case, $user); + } + } + + private function processCaseToEmailFields(CaseObj $case, Email $email): void + { + $userIdList = []; + + if ($case->hasLinkMultipleField(Field::ASSIGNED_USERS)) { + $userIdList = $case->getLinkMultipleIdList(Field::ASSIGNED_USERS); + } else { + $assignedUserLink = $case->getAssignedUser(); + + if ($assignedUserLink) { + $userIdList[] = $assignedUserLink->getId(); + } + } + + foreach ($userIdList as $userId) { + $email->addLinkMultipleId('users', $userId); + } + + $teamIdList = $case->getLinkMultipleIdList(Field::TEAMS); + + foreach ($teamIdList as $teamId) { + $email->addLinkMultipleId(Field::TEAMS, $teamId); + } + + $this->entityManager->saveEntity($email, [ + 'skipLinkMultipleRemove' => true, + 'skipLinkMultipleUpdate' => true, + ]); + } + + /** + * @param array $params + */ + private function emailToCase(Email $email, array $params): CaseObj + { + /** @var CaseObj $case */ + $case = $this->entityManager->getNewEntity(CaseObj::ENTITY_TYPE); + + $case->populateDefaults(); + + $case->set(Field::NAME, $email->get(Field::NAME)); + + $bodyPlain = $email->getBodyPlain() ?? ''; + + /** @var string $replacedBodyPlain */ + $replacedBodyPlain = preg_replace('/\s+/', '', $bodyPlain); + + if (trim($replacedBodyPlain) === '') { + $bodyPlain = ''; + } + + if ($bodyPlain) { + $case->set('description', $bodyPlain); + } + + $attachmentIdList = $email->getLinkMultipleIdList('attachments'); + + $copiedAttachmentIdList = []; + + /** @var AttachmentRepository $attachmentRepository*/ + $attachmentRepository = $this->entityManager->getRepository(Attachment::ENTITY_TYPE); + + foreach ($attachmentIdList as $attachmentId) { + $attachment = $attachmentRepository->getById($attachmentId); + + if (!$attachment) { + continue; + } + + $copiedAttachment = $attachmentRepository->getCopiedAttachment($attachment); + + $copiedAttachmentIdList[] = $copiedAttachment->getId(); + } + + if (count($copiedAttachmentIdList)) { + $case->setLinkMultipleIdList('attachments', $copiedAttachmentIdList); + } + + $userId = null; + + if (!empty($params['userId'])) { + $userId = $params['userId']; + } + + if (!empty($params['inboundEmailId'])) { + $case->set('inboundEmailId', $params['inboundEmailId']); + } + + $teamId = false; + + if (!empty($params['teamId'])) { + $teamId = $params['teamId']; + } + + if ($teamId) { + $case->set('teamsIds', [$teamId]); + } + + $caseDistribution = ''; + + if (!empty($params['caseDistribution'])) { + $caseDistribution = $params['caseDistribution']; + } + + $targetUserPosition = null; + + if (!empty($params['targetUserPosition'])) { + $targetUserPosition = $params['targetUserPosition']; + } + + switch ($caseDistribution) { + case InboundEmail::CASE_DISTRIBUTION_DIRECT_ASSIGNMENT: + if ($userId) { + $case->set('assignedUserId', $userId); + $case->set('status', CaseObj::STATUS_ASSIGNED); + } + + break; + + case InboundEmail::CASE_DISTRIBUTION_ROUND_ROBIN: + if ($teamId) { + /** @var ?Team $team */ + $team = $this->entityManager->getEntityById(Team::ENTITY_TYPE, $teamId); + + if ($team) { + $this->assignRoundRobin($case, $team, $targetUserPosition); + } + } + + break; + + case InboundEmail::CASE_DISTRIBUTION_LEAST_BUSY: + if ($teamId) { + /** @var ?Team $team */ + $team = $this->entityManager->getEntityById(Team::ENTITY_TYPE, $teamId); + + if ($team) { + $this->assignLeastBusy($case, $team, $targetUserPosition); + } + } + + break; + } + + $assignedUserLink = $case->getAssignedUser(); + + if ($assignedUserLink) { + $email->set('assignedUserId', $assignedUserLink->getId()); + } + + if ($email->get('accountId')) { + $case->set('accountId', $email->get('accountId')); + } + + $contact = $this->entityManager + ->getRDBRepository(Contact::ENTITY_TYPE) + ->join(Link::EMAIL_ADDRESSES, 'emailAddressesMultiple') + ->where([ + 'emailAddressesMultiple.id' => $email->get('fromEmailAddressId'), + ]) + ->findOne(); + + if ($contact) { + $case->set('contactId', $contact->getId()); + } else { + if (!$case->get('accountId')) { + $lead = $this->entityManager + ->getRDBRepository(Lead::ENTITY_TYPE) + ->join(Link::EMAIL_ADDRESSES, 'emailAddressesMultiple') + ->where([ + 'emailAddressesMultiple.id' => $email->get('fromEmailAddressId') + ]) + ->findOne(); + + if ($lead) { + $case->set('leadId', $lead->getId()); + } + } + } + + $this->entityManager->saveEntity($case); + + $email->set('parentType', CaseObj::ENTITY_TYPE); + $email->set('parentId', $case->getId()); + + $this->entityManager->saveEntity($email, [ + 'skipLinkMultipleRemove' => true, + 'skipLinkMultipleUpdate' => true, + ]); + + // Unknown reason of doing this. + $fetchedCase = $this->entityManager + ->getRDBRepositoryByClass(CaseObj::class) + ->getById($case->getId()); + + if ($fetchedCase) { + return $fetchedCase; + } + + $this->entityManager->refreshEntity($case); + + return $case; + } + + private function assignRoundRobin(CaseObj $case, Team $team, ?string $targetUserPosition): void + { + $user = $this->roundRobin->getUser($team, $targetUserPosition); + + if ($user) { + $case->set('assignedUserId', $user->getId()); + $case->set('status', CaseObj::STATUS_ASSIGNED); + } + } + + private function assignLeastBusy(CaseObj $case, Team $team, ?string $targetUserPosition): void + { + $user = $this->leastBusy->getUser($team, $targetUserPosition); + + if ($user) { + $case->set('assignedUserId', $user->getId()); + $case->set('status', CaseObj::STATUS_ASSIGNED); + } + } +} diff --git a/application/Espo/Core/Mail/Account/GroupAccount/Hooks/BeforeFetch.php b/application/Espo/Core/Mail/Account/GroupAccount/Hooks/BeforeFetch.php new file mode 100644 index 0000000..5dce44b --- /dev/null +++ b/application/Espo/Core/Mail/Account/GroupAccount/Hooks/BeforeFetch.php @@ -0,0 +1,166 @@ +. + * + * 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\Mail\Account\GroupAccount\Hooks; + +use Espo\Core\Mail\Account\Hook\BeforeFetch as BeforeFetchInterface; +use Espo\Core\Mail\Account\Hook\BeforeFetchResult; +use Espo\Core\Mail\Account\Account; +use Espo\Core\Mail\Importer\AutoReplyDetector; +use Espo\Core\Mail\Message; +use Espo\Core\Mail\Account\GroupAccount\BouncedRecognizer; +use Espo\Core\Utils\Log; +use Espo\Entities\EmailAddress; +use Espo\ORM\EntityManager; +use Espo\Repositories\EmailAddress as EmailAddressRepository; +use Espo\Modules\Crm\Entities\EmailQueueItem; +use Espo\Modules\Crm\Tools\Campaign\LogService as CampaignService; + +use Throwable; + +class BeforeFetch implements BeforeFetchInterface +{ + public function __construct( + private Log $log, + private EntityManager $entityManager, + private BouncedRecognizer $bouncedRecognizer, + private CampaignService $campaignService, + private AutoReplyDetector $autoReplyDetector, + ) {} + + public function process(Account $account, Message $message): BeforeFetchResult + { + if ($this->bouncedRecognizer->isBounced($message)) { + try { + $toSkip = $this->processBounced($message); + } catch (Throwable $e) { + $logMessage = 'InboundEmail ' . $account->getId() . ' ' . + 'Process Bounced Message; ' . $e->getCode() . ' ' . $e->getMessage(); + + $this->log->error($logMessage, ['exception' => $e]); + + return BeforeFetchResult::create()->withToSkip(); + } + + if ($toSkip) { + return BeforeFetchResult::create()->withToSkip(); + } + } + + return BeforeFetchResult::create() + ->with('skipAutoReply', $this->checkMessageCannotBeAutoReplied($message)) + ->with('isAutoSubmitted', $this->checkMessageIsAutoSubmitted($message)); + } + + private function processBounced(Message $message): bool + { + $isHard = $this->bouncedRecognizer->isHard($message); + $queueItemId = $this->bouncedRecognizer->extractQueueItemId($message); + + if (!$queueItemId) { + return false; + } + + $queueItem = $this->entityManager->getRDBRepositoryByClass(EmailQueueItem::class)->getById($queueItemId); + + if (!$queueItem) { + return false; + } + + $campaignId = $queueItem->getMassEmail()?->getCampaignId(); + $emailAddress = $queueItem->getEmailAddress(); + + if (!$emailAddress) { + return true; + } + + /** @var EmailAddressRepository $emailAddressRepository */ + $emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + if ($isHard) { + $emailAddressEntity = $emailAddressRepository->getByAddress($emailAddress); + + if ($emailAddressEntity) { + $emailAddressEntity->setInvalid(true); + + $this->entityManager->saveEntity($emailAddressEntity); + } + } + + $targetType = $queueItem->getTargetType(); + $targetId = $queueItem->getTargetId(); + + $target = $this->entityManager->getEntityById($targetType, $targetId); + + if ($campaignId && $target) { + $this->campaignService->logBounced($campaignId, $queueItem, $isHard); + } + + return true; + } + + private function checkMessageIsAutoReply(Message $message): bool + { + if ($this->checkMessageIsAutoSubmitted($message)) { + return true; + } + + return $this->autoReplyDetector->detect($message); + } + + private function checkMessageCannotBeAutoReplied(Message $message): bool + { + if ( + $message->getHeader('X-Auto-Response-Suppress') === 'AutoReply' || + $message->getHeader('X-Auto-Response-Suppress') === 'All' + ) { + return true; + } + + if ($this->checkMessageIsAutoSubmitted($message)) { + return true; + } + + if ($this->checkMessageIsAutoReply($message)) { + return true; + } + + return false; + } + + private function checkMessageIsAutoSubmitted(Message $message): bool + { + if ($this->autoReplyDetector->detect($message)) { + return true; + } + + return $message->getHeader('Auto-Submitted') && + strtolower($message->getHeader('Auto-Submitted')) !== 'no'; + } +} diff --git a/application/Espo/Core/Mail/Account/GroupAccount/Service.php b/application/Espo/Core/Mail/Account/GroupAccount/Service.php new file mode 100644 index 0000000..c11fb6c --- /dev/null +++ b/application/Espo/Core/Mail/Account/GroupAccount/Service.php @@ -0,0 +1,186 @@ +. + * + * 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\Mail\Account\GroupAccount; + +use Espo\Core\Exceptions\ErrorSilent; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Mail\Account\Account as Account; +use Espo\Core\Exceptions\Error; +use Espo\Core\Mail\Account\Fetcher; +use Espo\Core\Mail\Account\Storage\Params; +use Espo\Core\Mail\Account\StorageFactory; +use Espo\Core\Mail\Account\Util\AddressUtil; +use Espo\Core\Mail\Account\Util\NotificationHelper; +use Espo\Core\Mail\Exceptions\ImapError; +use Espo\Core\Mail\Exceptions\NoImap; +use Espo\Core\Mail\Sender\Message; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\Security\HostCheck; +use Exception; + +class Service +{ + public function __construct( + private Fetcher $fetcher, + private AccountFactory $accountFactory, + private StorageFactory $storageFactory, + private Log $log, + private NotificationHelper $notificationHelper, + private HostCheck $hostCheck, + private AddressUtil $addressUtil, + ) {} + + /** + * @param string $id Account ID. + * @throws Error + * @throws NoImap + * @throws ImapError + */ + public function fetch(string $id): void + { + $account = $this->accountFactory->create($id); + + try { + $this->fetcher->fetch($account); + } catch (ImapError $e) { + $this->notificationHelper->processImapError($account); + + throw $e; + } + + $account->updateConnectedAt(); + } + + /** + * @return string[] + * @throws Error + * @throws ImapError + * @throws Forbidden + */ + public function getFolderList(Params $params): array + { + $params = $this->connectPreProcess($params); + + try { + $storage = $this->storageFactory->createWithParams($params); + + return $storage->getFolderNames(); + } catch (Exception $e) { + $this->log->warning("IMAP get folder list failed.", [ + 'exception' => $e, + ]); + + $message = $e instanceof ImapError ? $e->getMessage() : ''; + + throw new ErrorSilent($message); + } + } + + /** + * @throws Error + * @throws Forbidden + */ + public function testConnection(Params $params): void + { + $params = $this->connectPreProcess($params); + + try { + $storage = $this->storageFactory->createWithParams($params); + $storage->getFolderNames(); + } catch (Exception $e) { + $this->log->warning("IMAP test connection failed.", [ + 'exception' => $e, + ]); + + $message = $e instanceof ImapError ? $e->getMessage() : ''; + + throw new ErrorSilent($message, previous: $e); + } + } + + private function getPassword(Params $params, Account $account): ?string + { + $password = $params->getPassword(); + + if ($password !== null) { + return $password; + } + + $imapParams = $account->getImapParams(); + + return $imapParams?->getPassword(); + } + + /** + * @param string $id Account ID. + * @throws Error + * @throws ImapError + * @throws NoImap + */ + public function storeSentMessage(string $id, Message $message): void + { + $account = $this->accountFactory->create($id); + + $folder = $account->getSentFolder(); + + if (!$folder) { + throw new Error("No sent folder for Group Email Account $id."); + } + + $storage = $this->storageFactory->create($account); + + $storage->appendMessage($message->toString(), $folder); + } + + /** + * @throws Error + * @throws Forbidden + */ + private function connectPreProcess(Params $params): Params + { + if ( + $params->getHost() && + !$this->addressUtil->isAllowedAddress($params) && + !$this->hostCheck->isHostAndNotInternal($params->getHost()) + ) { + throw new Forbidden("Not allowed internal host."); + } + + if ($params->getId()) { + $account = $this->accountFactory->create($params->getId()); + + $params = $params + ->withPassword($this->getPassword($params, $account)) + ->withImapHandlerClassName($account->getImapHandlerClassName()); + } + + return $params; + } +} diff --git a/application/Espo/Core/Mail/Account/GroupAccount/StorageFactory.php b/application/Espo/Core/Mail/Account/GroupAccount/StorageFactory.php new file mode 100644 index 0000000..f96c1d6 --- /dev/null +++ b/application/Espo/Core/Mail/Account/GroupAccount/StorageFactory.php @@ -0,0 +1,70 @@ +. + * + * 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\Mail\Account\GroupAccount; + +use Espo\Core\Mail\Account\CommonStorageFactory; +use Espo\Core\Mail\Account\Storage; +use Espo\Core\Mail\Account\Storage\Params; +use Espo\Core\Mail\Account\Account; +use Espo\Core\Mail\Account\StorageFactory as StorageFactoryInterface; +use Espo\Core\Mail\Exceptions\NoImap; + +class StorageFactory implements StorageFactoryInterface +{ + public function __construct( + private CommonStorageFactory $commonStorageFactory, + ) {} + + public function create(Account $account): Storage + { + $imapParams = $account->getImapParams(); + + if (!$imapParams) { + throw new NoImap("No IMAP params."); + } + + $params = Params::createBuilder() + ->setHost($imapParams->getHost()) + ->setPort($imapParams->getPort()) + ->setSecurity($imapParams->getSecurity()) + ->setUsername($imapParams->getUsername()) + ->setPassword($imapParams->getPassword()) + ->setId($account->getId()) + ->setImapHandlerClassName($account->getImapHandlerClassName()) + ->build(); + + return $this->createWithParams($params); + } + + public function createWithParams(Params $params): Storage + { + return $this->commonStorageFactory->create($params); + } +} diff --git a/application/Espo/Core/Mail/Account/Hook/AfterFetch.php b/application/Espo/Core/Mail/Account/Hook/AfterFetch.php new file mode 100644 index 0000000..6d670e2 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Hook/AfterFetch.php @@ -0,0 +1,38 @@ +. + * + * 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\Mail\Account\Hook; + +use Espo\Core\Mail\Account\Account; +use Espo\Entities\Email; + +interface AfterFetch +{ + public function process(Account $account, Email $email, BeforeFetchResult $beforeFetchResult): void; +} diff --git a/application/Espo/Core/Mail/Account/Hook/BeforeFetch.php b/application/Espo/Core/Mail/Account/Hook/BeforeFetch.php new file mode 100644 index 0000000..22d0ca0 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Hook/BeforeFetch.php @@ -0,0 +1,38 @@ +. + * + * 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\Mail\Account\Hook; + +use Espo\Core\Mail\Account\Account; +use Espo\Core\Mail\Message; + +interface BeforeFetch +{ + public function process(Account $account, Message $message): BeforeFetchResult; +} diff --git a/application/Espo/Core/Mail/Account/Hook/BeforeFetchResult.php b/application/Espo/Core/Mail/Account/Hook/BeforeFetchResult.php new file mode 100644 index 0000000..504f6e4 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Hook/BeforeFetchResult.php @@ -0,0 +1,73 @@ +. + * + * 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\Mail\Account\Hook; + +class BeforeFetchResult +{ + private bool $toSkip = false; + /** @var array */ + private array $data = []; + + public static function create(): self + { + return new self(); + } + + public function withToSkip(bool $toSkip = true): self + { + $obj = clone $this; + $obj->toSkip = $toSkip; + + return $obj; + } + + public function with(string $name, mixed $value): self + { + $obj = clone $this; + $obj->data[$name] = $value; + + return $obj; + } + + public function toSkip(): bool + { + return $this->toSkip; + } + + public function get(string $name): mixed + { + return $this->data[$name] ?? null; + } + + public function has(string $name): bool + { + return array_key_exists($name, $this->data); + } +} diff --git a/application/Espo/Core/Mail/Account/ImapParams.php b/application/Espo/Core/Mail/Account/ImapParams.php new file mode 100644 index 0000000..a2cacbc --- /dev/null +++ b/application/Espo/Core/Mail/Account/ImapParams.php @@ -0,0 +1,69 @@ +. + * + * 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\Mail\Account; + +/** + * Immutable. + */ +class ImapParams +{ + public function __construct( + private string $host, + private int $port, + private string $username, + private ?string $password, + private ?string $security + ) {} + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): int + { + return $this->port; + } + + public function getUsername(): string + { + return $this->username; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getSecurity(): ?string + { + return $this->security; + } +} diff --git a/application/Espo/Core/Mail/Account/PersonalAccount/Account.php b/application/Espo/Core/Mail/Account/PersonalAccount/Account.php new file mode 100644 index 0000000..5acdd14 --- /dev/null +++ b/application/Espo/Core/Mail/Account/PersonalAccount/Account.php @@ -0,0 +1,357 @@ +. + * + * 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\Mail\Account\PersonalAccount; + +use Espo\Core\Exceptions\Error; + +use Espo\Core\Field\Date; +use Espo\Core\Field\DateTime; +use Espo\Core\Field\Link; +use Espo\Core\Field\LinkMultiple; +use Espo\Core\Field\LinkMultipleItem; +use Espo\Core\Mail\Exceptions\NoSmtp; +use Espo\Core\Mail\Account\ImapParams; +use Espo\Core\Mail\Smtp\HandlerProcessor; +use Espo\Core\Mail\SmtpParams; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Crypt; +use Espo\Entities\EmailAccount; +use Espo\Entities\InboundEmail; +use Espo\Entities\User; +use Espo\Entities\Email; +use Espo\Core\Mail\Account\Account as AccountInterface; +use Espo\Core\Mail\Account\FetchData; +use Espo\ORM\EntityManager; + +use Espo\ORM\Mapper\Util; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\UpdateBuilder; +use RuntimeException; + +class Account implements AccountInterface +{ + private const PORTION_LIMIT = 10; + + private User $user; + private Crypt $crypt; + + /** + * @throws Error + */ + public function __construct( + private EmailAccount $entity, + private EntityManager $entityManager, + private Config $config, + private HandlerProcessor $handlerProcessor, + Crypt $crypt + ) { + if (!$this->entity->getAssignedUser()) { + throw new Error("No assigned user."); + } + + $userId = $this->entity->getAssignedUser()->getId(); + + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId); + + if (!$user) { + throw new Error("Assigned user not found."); + } + + $this->user = $user; + $this->crypt = $crypt; + } + + public function updateFetchData(FetchData $fetchData): void + { + $query = UpdateBuilder::create() + ->in(EmailAccount::ENTITY_TYPE) + ->where([ + InboundEmail::FIELD_FETCH_VALIDITY_NUMBER => $fetchData->getValidityNumber(), + Attribute::ID => $this->entity->getId(), + Attribute::DELETED => false, + ]) + ->set([ + InboundEmail::FIELD_FETCH_DATA => Util::prepareValue(FieldType::JSON_OBJECT, $fetchData->getRaw()), + ]) + ->build(); + + $sth = $this->entityManager->getQueryExecutor()->execute($query); + + if ($sth->rowCount() !== 0) { + $this->entity->setFetchData($fetchData); + $this->entity->setAsFetched(); + } + } + + public function updateConnectedAt(): void + { + $this->entity->set('connectedAt', DateTime::createNow()->toString()); + + $this->entityManager->saveEntity($this->entity, [SaveOption::SILENT => true]); + } + + public function relateEmail(Email $email): void + { + $this->entityManager + ->getRDBRepository(EmailAccount::ENTITY_TYPE) + ->getRelation($this->entity, 'emails') + ->relate($email); + } + + public function getEntity(): EmailAccount + { + return $this->entity; + } + + public function getPortionLimit(): int + { + return $this->config->get('personalEmailMaxPortionSize', self::PORTION_LIMIT); + } + + public function isAvailableForFetching(): bool + { + return $this->entity->isAvailableForFetching(); + } + + public function getEmailAddress(): ?string + { + return $this->entity->getEmailAddress(); + } + + public function getUsers(): LinkMultiple + { + $linkMultiple = LinkMultiple::create(); + + $userLink = $this->getUser(); + + return $linkMultiple->withAdded( + LinkMultipleItem + ::create($userLink->getId()) + ->withName($userLink->getName() ?? '') + ); + } + + /** + * A user to assign emails to. Not need for personal accounts. + */ + public function getAssignedUser(): ?Link + { + return null; + } + + /** + * @throws Error + */ + public function getUser(): Link + { + $userLink = $this->entity->getAssignedUser(); + + if (!$userLink) { + throw new Error("No assigned user."); + } + + return $userLink; + } + + public function getTeams(): LinkMultiple + { + $linkMultiple = LinkMultiple::create(); + + $team = $this->user->getDefaultTeam(); + + if (!$team) { + return $linkMultiple; + } + + return $linkMultiple->withAdded( + LinkMultipleItem + ::create($team->getId()) + ->withName($team->getName() ?? '') + ); + } + + public function keepFetchedEmailsUnread(): bool + { + return $this->entity->keepFetchedEmailsUnread(); + } + + public function getFetchData(): FetchData + { + return FetchData::fromRaw( + $this->entity->getFetchData(), + $this->entity->getFetchValidityNumber(), + ); + } + + public function getFetchSince(): ?Date + { + return $this->entity->getFetchSince(); + } + + public function getEmailFolder(): ?Link + { + return $this->entity->getEmailFolder(); + } + + public function getMappedEmailFolder(string $folder): ?Link + { + return $this->entity->getMappedEmailFolder($folder); + } + + /** + * @return string[] + */ + public function getMonitoredFolderList(): array + { + return $this->entity->getMonitoredFolderList(); + } + + public function getId(): ?string + { + return $this->entity->getId(); + } + + public function getEntityType(): string + { + return $this->entity->getEntityType(); + } + + /** + * @return ?class-string + */ + public function getImapHandlerClassName(): ?string + { + return $this->entity->getImapHandlerClassName(); + } + + public function getSentFolder(): ?string + { + return $this->entity->getSentFolder(); + } + + public function getGroupEmailFolder(): ?Link + { + return null; + } + + public function isAvailableForSending(): bool + { + return $this->entity->isAvailableForSending(); + } + + /** + * @throws NoSmtp + */ + public function getSmtpParams(): ?SmtpParams + { + $host = $this->entity->getSmtpHost(); + + if (!$host) { + return null; + } + + $port = $this->entity->getSmtpPort(); + + if ($port === null) { + throw new NoSmtp("Empty port."); + } + + $smtpParams = SmtpParams::create($host, $port) + ->withSecurity($this->entity->getSmtpSecurity()) + ->withAuth($this->entity->getSmtpAuth()); + + if ($this->entity->getSmtpAuth()) { + $password = $this->entity->getSmtpPassword(); + + if ($password !== null) { + $password = $this->crypt->decrypt($password); + } + + $smtpParams = $smtpParams + ->withUsername($this->entity->getSmtpUsername()) + ->withPassword($password) + ->withAuthMechanism($this->entity->getSmtpAuthMechanism()); + } + + $handlerClassName = $this->entity->getSmtpHandlerClassName(); + + if (!$handlerClassName) { + return $smtpParams; + } + + return $this->handlerProcessor->handle($handlerClassName, $smtpParams, $this->getId()); + } + + public function storeSentEmails(): bool + { + return $this->entity->storeSentEmails(); + } + + public function getImapParams(): ?ImapParams + { + $host = $this->entity->getHost(); + $port = $this->entity->getPort(); + $username = $this->entity->getUsername(); + $password = $this->entity->getPassword(); + $security = $this->entity->getSecurity(); + + if (!$host) { + return null; + } + + if ($port === null) { + throw new RuntimeException("No port."); + } + + if ($username === null) { + throw new RuntimeException("No username."); + } + + if ($password !== null) { + $password = $this->crypt->decrypt($password); + } + + return new ImapParams( + $host, + $port, + $username, + $password, + $security + ); + } + + public function getConnectedAt(): ?DateTime + { + /** @var DateTime */ + return $this->entity->getValueObject('connectedAt'); + } +} diff --git a/application/Espo/Core/Mail/Account/PersonalAccount/AccountFactory.php b/application/Espo/Core/Mail/Account/PersonalAccount/AccountFactory.php new file mode 100644 index 0000000..b7d65e9 --- /dev/null +++ b/application/Espo/Core/Mail/Account/PersonalAccount/AccountFactory.php @@ -0,0 +1,62 @@ +. + * + * 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\Mail\Account\PersonalAccount; + +use Espo\Core\Exceptions\Error; +use Espo\Core\InjectableFactory; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Entities\EmailAccount; +use Espo\ORM\EntityManager; + +class AccountFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private EntityManager $entityManager + ) {} + + /** + * @throws Error + */ + public function create(string $id): Account + { + $entity = $this->entityManager->getEntityById(EmailAccount::ENTITY_TYPE, $id); + + if (!$entity) { + throw new Error("EmailAccount '{$id}' not found."); + } + + $binding = BindingContainerBuilder::create() + ->bindInstance(EmailAccount::class, $entity) + ->build(); + + return $this->injectableFactory->createWithBinding(Account::class, $binding); + } +} diff --git a/application/Espo/Core/Mail/Account/PersonalAccount/FetcherFactory.php b/application/Espo/Core/Mail/Account/PersonalAccount/FetcherFactory.php new file mode 100644 index 0000000..1f693ff --- /dev/null +++ b/application/Espo/Core/Mail/Account/PersonalAccount/FetcherFactory.php @@ -0,0 +1,58 @@ +. + * + * 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\Mail\Account\PersonalAccount; + +use Espo\Core\Binding\Factory; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\InjectableFactory; +use Espo\Core\Mail\Account\Hook\AfterFetch; +use Espo\Core\Mail\Account\PersonalAccount\Hooks\AfterFetch as PersonalAccountAfterFetch; +use Espo\Core\Mail\Account\Fetcher; +use Espo\Core\Mail\Account\StorageFactory; +use Espo\Core\Mail\Account\PersonalAccount\StorageFactory as PersonalAccountStorageFactory; + +/** + * @implements Factory + */ +class FetcherFactory implements Factory +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function create(): Fetcher + { + $binding = BindingContainerBuilder::create() + ->bindImplementation(StorageFactory::class, PersonalAccountStorageFactory::class) + ->bindImplementation(AfterFetch::class, PersonalAccountAfterFetch::class) + ->build(); + + return $this->injectableFactory->createWithBinding(Fetcher::class, $binding); + } +} diff --git a/application/Espo/Core/Mail/Account/PersonalAccount/Hooks/AfterFetch.php b/application/Espo/Core/Mail/Account/PersonalAccount/Hooks/AfterFetch.php new file mode 100644 index 0000000..ee230a4 --- /dev/null +++ b/application/Espo/Core/Mail/Account/PersonalAccount/Hooks/AfterFetch.php @@ -0,0 +1,59 @@ +. + * + * 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\Mail\Account\PersonalAccount\Hooks; + +use Espo\Core\Mail\Account\Account; +use Espo\Core\Mail\Account\Hook\BeforeFetchResult; +use Espo\Core\Mail\Account\Hook\AfterFetch as AfterFetchInterface; +use Espo\Tools\Stream\Service as StreamService; +use Espo\Entities\Email; + +class AfterFetch implements AfterFetchInterface +{ + public function __construct( + private StreamService $streamService + ) {} + + public function process(Account $account, Email $email, BeforeFetchResult $beforeFetchResult): void + { + if (!$email->isFetched()) { + $this->noteAboutEmail($email); + } + } + + private function noteAboutEmail(Email $email): void + { + if (!$email->getParent()) { + return; + } + + $this->streamService->noteEmailReceived($email->getParent(), $email); + } +} diff --git a/application/Espo/Core/Mail/Account/PersonalAccount/Service.php b/application/Espo/Core/Mail/Account/PersonalAccount/Service.php new file mode 100644 index 0000000..bed86d9 --- /dev/null +++ b/application/Espo/Core/Mail/Account/PersonalAccount/Service.php @@ -0,0 +1,211 @@ +. + * + * 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\Mail\Account\PersonalAccount; + +use Espo\Core\Exceptions\ErrorSilent; +use Espo\Core\Mail\Account\Util\AddressUtil; +use Espo\Core\Mail\Account\Util\NotificationHelper; +use Espo\Core\Mail\Exceptions\ImapError; +use Espo\Core\Mail\Exceptions\NoImap; +use Espo\Core\Utils\Log; +use Espo\Core\Mail\Account\Account as Account; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\Error; +use Espo\Core\Mail\Account\Fetcher; +use Espo\Core\Mail\Account\Storage\Params; +use Espo\Core\Mail\Account\StorageFactory; +use Espo\Core\Utils\Security\HostCheck; +use Espo\Entities\User; +use Espo\Core\Mail\Sender\Message; + +use Exception; + +class Service +{ + public function __construct( + private Fetcher $fetcher, + private AccountFactory $accountFactory, + private StorageFactory $storageFactory, + private User $user, + private Log $log, + private NotificationHelper $notificationHelper, + private HostCheck $hostCheck, + private AddressUtil $addressUtil, + ) {} + + /** + * @param string $id Account ID. + * @throws Error + * @throws NoImap + * @throws ImapError + */ + public function fetch(string $id): void + { + $account = $this->accountFactory->create($id); + + try { + $this->fetcher->fetch($account); + } catch (ImapError $e) { + $this->notificationHelper->processImapError($account); + + throw $e; + } + + $account->updateConnectedAt(); + } + + /** + * @return string[] + * @throws Forbidden + * @throws Error + * @throws ImapError + */ + public function getFolderList(Params $params): array + { + $params = $this->connectPreProcess($params); + + try { + $storage = $this->storageFactory->createWithParams($params); + + return $storage->getFolderNames(); + } catch (Exception $e) { + $this->log->warning("IMAP get folder list failed.", [ + 'exception' => $e, + ]); + + $message = $e instanceof ImapError ? $e->getMessage() : ''; + + throw new ErrorSilent($message); + } + } + + /** + * @throws Forbidden + * @throws Error + */ + public function testConnection(Params $params): void + { + $params = $this->connectPreProcess($params); + + try { + $storage = $this->storageFactory->createWithParams($params); + + $storage->getFolderNames(); + } catch (Exception $e) { + $this->log->warning("IMAP test connection failed.", [ + 'exception' => $e, + ]); + + $message = $e instanceof ImapError ? $e->getMessage() : ''; + + throw new ErrorSilent($message); + } + } + + private function getPassword(Params $params, Account $account): ?string + { + $password = $params->getPassword(); + + if ($password !== null) { + return $password; + } + + $imapParams = $account->getImapParams(); + + return $imapParams?->getPassword(); + } + + /** + * @param string $id Account ID. + * @throws Error + * @throws ImapError + * @throws NoImap + */ + public function storeSentMessage(string $id, Message $message): void + { + $account = $this->accountFactory->create($id); + + $folder = $account->getSentFolder(); + + if (!$folder) { + throw new Error("No sent folder for Email Account $id."); + } + + $storage = $this->storageFactory->create($account); + + $storage->appendMessage($message->toString(), $folder); + } + + /** + * @throws Error + * @throws Forbidden + */ + private function connectPreProcess(Params $params): Params + { + $userId = $params->getUserId(); + + if ( + $userId && + !$this->user->isEffectiveAdmin() && + $userId !== $this->user->getId() + ) { + throw new Forbidden(); + } + + if (!$params->getId() && $params->getPassword() === null) { + throw new Forbidden(); + } + + if ( + $params->getHost() && + !$this->addressUtil->isAllowedAddress($params) && + !$this->hostCheck->isHostAndNotInternal($params->getHost()) + ) { + throw new Forbidden("Not allowed internal host."); + } + + if ($params->getId()) { + $account = $this->accountFactory->create($params->getId()); + + if ( + !$this->user->isEffectiveAdmin() && + $account->getUser()->getId() !== $this->user->getId() + ) { + throw new Forbidden(); + } + + $params = $params + ->withPassword($this->getPassword($params, $account)) + ->withImapHandlerClassName($account->getImapHandlerClassName()); + } + + return $params; + } +} diff --git a/application/Espo/Core/Mail/Account/PersonalAccount/StorageFactory.php b/application/Espo/Core/Mail/Account/PersonalAccount/StorageFactory.php new file mode 100644 index 0000000..0f9f96e --- /dev/null +++ b/application/Espo/Core/Mail/Account/PersonalAccount/StorageFactory.php @@ -0,0 +1,82 @@ +. + * + * 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\Mail\Account\PersonalAccount; + +use Espo\Core\Mail\Account\CommonStorageFactory; +use Espo\Core\Mail\Account\Storage; +use Espo\Core\Mail\Account\Storage\Params; +use Espo\Core\Mail\Account\StorageFactory as StorageFactoryInterface; +use Espo\Core\Mail\Account\Account; +use Espo\Core\Mail\Exceptions\NoImap; + +use LogicException; + +class StorageFactory implements StorageFactoryInterface +{ + public function __construct( + private CommonStorageFactory $commonStorageFactory, + ) {} + + public function create(Account $account): Storage + { + $userLink = $account->getUser(); + + if (!$userLink) { + throw new LogicException("No user for mail account."); + } + + $userId = $userLink->getId(); + + $imapParams = $account->getImapParams(); + + if (!$imapParams) { + throw new NoImap("No IMAP params."); + } + + $params = Params::createBuilder() + ->setHost($imapParams->getHost()) + ->setPort($imapParams->getPort()) + ->setSecurity($imapParams->getSecurity()) + ->setUsername($imapParams->getUsername()) + ->setPassword($imapParams->getPassword()) + ->setEmailAddress($account->getEmailAddress()) + ->setUserId($userId) + ->setId($account->getId()) + ->setImapHandlerClassName($account->getImapHandlerClassName()) + ->build(); + + return $this->createWithParams($params); + } + + public function createWithParams(Params $params): Storage + { + return $this->commonStorageFactory->create($params); + } +} diff --git a/application/Espo/Core/Mail/Account/SendingAccountProvider.php b/application/Espo/Core/Mail/Account/SendingAccountProvider.php new file mode 100644 index 0000000..6718138 --- /dev/null +++ b/application/Espo/Core/Mail/Account/SendingAccountProvider.php @@ -0,0 +1,248 @@ +. + * + * 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\Mail\Account; + +use Espo\Core\Acl\Table; +use Espo\Core\AclManager; +use Espo\Core\Exceptions\Error; +use Espo\Core\Mail\Account\GroupAccount\AccountFactory as GroupAccountFactory; +use Espo\Core\Mail\Account\PersonalAccount\AccountFactory as PersonalAccountFactory; +use Espo\Core\Mail\ConfigDataProvider; +use Espo\Core\Name\Field; +use Espo\Entities\EmailAccount as EmailAccountEntity; +use Espo\Entities\InboundEmail as InboundEmailEntity; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Expression; +use RuntimeException; + +class SendingAccountProvider +{ + private ?Account $system = null; + private bool $systemIsCached = false; + + public function __construct( + private EntityManager $entityManager, + private GroupAccountFactory $groupAccountFactory, + private PersonalAccountFactory $personalAccountFactory, + private AclManager $aclManager, + private ConfigDataProvider $configDataProvider, + ) {} + + public function getShared(User $user, string $emailAddress): ?Account + { + $level = $this->aclManager->getPermissionLevel($user, 'groupEmailAccount'); + + $entity = null; + + if ($level === Table::LEVEL_TEAM) { + $teamIdList = $user->getTeamIdList(); + + if ($teamIdList === []) { + return null; + } + + $entity = $this->entityManager + ->getRDBRepositoryByClass(InboundEmailEntity::class) + ->select([Attribute::ID]) + ->distinct() + ->join(Field::TEAMS) + ->where([ + 'status' => InboundEmailEntity::STATUS_ACTIVE, + 'useSmtp' => true, + 'smtpIsShared' => true, + 'teamsMiddle.teamId' => $teamIdList, + ]) + ->where( + Condition::equal( + Expression::lowerCase( + Expression::column('emailAddress') + ), + strtolower($emailAddress) + ) + ) + ->findOne(); + } + + if ($level === Table::LEVEL_ALL) { + $entity = $this->entityManager + ->getRDBRepositoryByClass(InboundEmailEntity::class) + ->select([Attribute::ID]) + ->where([ + 'status' => InboundEmailEntity::STATUS_ACTIVE, + 'useSmtp' => true, + 'smtpIsShared' => true, + ]) + ->where( + Condition::equal( + Expression::lowerCase( + Expression::column('emailAddress') + ), + strtolower($emailAddress) + ) + ) + ->findOne(); + } + + if (!$entity) { + return null; + } + + try { + return $this->groupAccountFactory->create($entity->getId()); + } catch (Error) { + throw new RuntimeException(); + } + } + + public function getGroup(string $emailAddress): ?Account + { + $entity = $this->entityManager + ->getRDBRepositoryByClass(InboundEmailEntity::class) + ->select([Attribute::ID]) + ->where([ + 'status' => InboundEmailEntity::STATUS_ACTIVE, + 'useSmtp' => true, + 'smtpHost!=' => null, + ]) + ->where( + Condition::equal( + Expression::lowerCase( + Expression::column('emailAddress') + ), + strtolower($emailAddress) + ) + ) + ->findOne(); + + if (!$entity) { + return null; + } + + try { + return $this->groupAccountFactory->create($entity->getId()); + } catch (Error) { + throw new RuntimeException(); + } + } + + /** + * Get a personal user account. + */ + public function getPersonal(User $user, ?string $emailAddress): ?Account + { + if (!$emailAddress) { + $emailAddress = $user->getEmailAddress(); + } + + if (!$emailAddress) { + return null; + } + + $entity = $this->entityManager + ->getRDBRepositoryByClass(EmailAccountEntity::class) + ->select([Attribute::ID]) + ->where([ + 'assignedUserId' => $user->getId(), + 'status' => EmailAccountEntity::STATUS_ACTIVE, + 'useSmtp' => true, + ]) + ->where( + Condition::equal( + Expression::lowerCase( + Expression::column('emailAddress') + ), + strtolower($emailAddress) + ) + ) + ->findOne(); + + if (!$entity) { + return null; + } + + try { + return $this->personalAccountFactory->create($entity->getId()); + } catch (Error) { + throw new RuntimeException(); + } + } + + /** + * Get a system account. + */ + public function getSystem(): ?Account + { + if (!$this->systemIsCached) { + $this->loadSystem(); + + $this->systemIsCached = true; + } + + return $this->system; + } + + private function loadSystem(): void + { + $address = $this->configDataProvider->getSystemOutboundAddress(); + + if (!$address) { + return; + } + + $entity = $this->entityManager + ->getRDBRepositoryByClass(InboundEmailEntity::class) + ->where([ + 'status' => InboundEmailEntity::STATUS_ACTIVE, + 'useSmtp' => true, + ]) + ->where( + Condition::equal( + Expression::lowerCase( + Expression::column('emailAddress') + ), + strtolower($address) + ) + ) + ->findOne(); + + if (!$entity) { + return; + } + + try { + $this->system = $this->groupAccountFactory->create($entity->getId()); + } catch (Error) { + throw new RuntimeException(); + } + } +} diff --git a/application/Espo/Core/Mail/Account/Storage.php b/application/Espo/Core/Mail/Account/Storage.php new file mode 100644 index 0000000..a1feae3 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Storage.php @@ -0,0 +1,121 @@ +. + * + * 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\Mail\Account; + +use Espo\Core\Field\DateTime; +use Espo\Core\Mail\Account\Storage\FolderStatus; +use Espo\Core\Mail\Exceptions\ImapError; + +interface Storage +{ + /** + * Mark as unseen. + * + * @todo Move to the Message interface. Call from the MessageWrapper. + * + * @throws ImapError + */ + public function unmarkSeen(int $id): void; + + /** + * Get a message size. + * + * @throws ImapError + */ + public function getSize(int $id): int; + + /** + * Get message raw content. + * + * @throws ImapError + */ + public function getRawContent(int $id, bool $peek): string; + + /** + * Get IDs from unique ID. + * + * @return int[] + * + * @throws ImapError + */ + public function getUidsFromUid(int $id): array; + + /** + * Get IDs since a specific date. + * + * @return int[] + * + * @throws ImapError + */ + public function getUidsSinceDate(DateTime $since): array; + + /** + * Get only header and flags. Won't fetch the whole email. + * + * @return array{header: string, flags: string[]} + * + * @throws ImapError + */ + public function getHeaderAndFlags(int $id): array; + + /** + * Close the resource. + */ + public function close(): void; + + /** + * @return string[] + * + * @throws ImapError + */ + public function getFolderNames(): array; + + /** + * Select a folder. + * + * @throws ImapError + */ + public function selectFolder(string $name): void; + + /** + * Store a message. + * + * @throws ImapError + */ + public function appendMessage(string $content, string $folder): void; + + /** + * Get folder status. Should be called after the folder is selected. + * + * @throws ImapError + * @since 10.0 + */ + public function getFolderStatus(): FolderStatus; +} diff --git a/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php b/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php new file mode 100644 index 0000000..048cd1d --- /dev/null +++ b/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php @@ -0,0 +1,358 @@ +. + * + * 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\Mail\Account\Storage; + +use DirectoryTree\ImapEngine\Exceptions\Exception as CommonException; +use DirectoryTree\ImapEngine\FolderInterface; +use DirectoryTree\ImapEngine\Mailbox; +use DirectoryTree\ImapEngine\Message; +use DirectoryTree\ImapEngine\MessageInterface; +use DirectoryTree\ImapEngine\MessageQuery; +use Espo\Core\Field\DateTime; +use Espo\Core\Mail\Account\Storage; +use Espo\Core\Mail\Exceptions\ImapError; +use LogicException; + +class DirectoryTreeStorage implements Storage +{ + private ?FolderInterface $selectedFolder = null; + + public function __construct( + private Mailbox $mailbox, + ) {} + + /** + * @inheritDoc + * @noinspection PhpRedundantCatchClauseInspection + */ + public function unmarkSeen(int $id): void + { + $folder = $this->getSelectedFolder(); + + try { + $message = $folder->messages() + ->withFlags() + ->find($id); + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + + if (!$message) { + throw new ImapError("Could not fetch message $id."); + } + + try { + $message->unmarkSeen(); + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + } + + /** + * @noinspection PhpRedundantCatchClauseInspection + */ + public function getSize(int $id): int + { + $folder = $this->getSelectedFolder(); + + try { + $message = $folder->messages() + ->withSize() + ->find($id); + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + + if (!$message) { + throw new ImapError("Could not fetch message $id."); + } + + return $message->size() ?? 0; + } + + /** + * @inheritDoc + * @noinspection PhpRedundantCatchClauseInspection + */ + public function getRawContent(int $id, bool $peek): string + { + $folder = $this->getSelectedFolder(); + + try { + $message = $folder->messages() + ->withHeaders() + ->withFlags() + ->withBody() + ->find($id); + + if ($message instanceof Message && !$peek) { + $message->markSeen(); + } + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + + if (!$message) { + throw new ImapError("Could not fetch message $id."); + } + + if (!$message instanceof Message) { + throw new LogicException("Not supported message instance."); + } + + return $message->body(); + } + + /** + * @inheritDoc + * @noinspection PhpRedundantCatchClauseInspection + */ + public function getUidsFromUid(int $id): array + { + $output = []; + + $query = $this->getSelectedFolder() + ->messages() + ->withoutHeaders() + ->uid($id, INF); + + /** + * Magic methods are used. + * @noinspection PhpConditionAlreadyCheckedInspection + * @phpstan-ignore-next-line + */ + assert($query instanceof MessageQuery); + + $query->setFetchOrderAsc(); + + try { + $query->each(function (MessageInterface $message) use (&$output) { + $output[] = $message->uid(); + }); + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + + // May return wrong items. Do not return one with matching id. + $output = array_filter($output, fn ($it) => $it > $id); + $output = array_values($output); + + // Otherwise, it's in reverse order. + sort($output); + + return $output; + } + + /** + * @inheritDoc + * @noinspection PhpRedundantCatchClauseInspection + */ + public function getUidsSinceDate(DateTime $since): array + { + $output = []; + + $query = $this->getSelectedFolder() + ->messages() + ->withoutHeaders() + ->since($since->toDateTime()); + + /** + * Magic methods are used. + * @noinspection PhpConditionAlreadyCheckedInspection + * @phpstan-ignore-next-line + */ + assert($query instanceof MessageQuery); + + $query->setFetchOrderAsc(); + + try { + $query->each(function (MessageInterface $message) use (&$output) { + $output[] = $message->uid(); + }); + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + + // Otherwise, it's in reverse order. + sort($output); + + return $output; + } + + /** + * @return array{header: string, flags: string[]} + * @inheritDoc + * @noinspection PhpRedundantCatchClauseInspection + */ + public function getHeaderAndFlags(int $id): array + { + try { + $folder = $this->getSelectedFolder(); + + $message = $folder->messages() + ->withHeaders() + ->withFlags() + ->find($id); + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + + if (!$message) { + throw new ImapError("Could not fetch message $id."); + } + + if (!$message instanceof Message) { + // Whenever the library is upgraded, it's reasonable to check whether + // the Message instance is still returned by the library. + throw new LogicException("Not supported message instance."); + } + + return [ + 'header' => $message->head(), + 'flags' => $message->flags(), + ]; + } + + public function close(): void + { + $this->mailbox->disconnect(); + $this->selectedFolder = null; + } + + /** + * @return string[] + * @inheritDoc + * @noinspection PhpRedundantCatchClauseInspection + */ + public function getFolderNames(): array + { + $output = []; + + try { + $folders = $this->mailbox->folders()->get(); + + foreach ($folders as $folder) { + $output[] = mb_convert_encoding($folder->path(), 'UTF-8', 'UTF7-IMAP'); + } + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + + return $output; + } + + /** + * @inheritDoc + * @noinspection PhpRedundantCatchClauseInspection + */ + public function selectFolder(string $name): void + { + try { + $folder = $this->getFolder($name); + $this->selectedFolder = $folder; + $folder->select(); + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + } + + /** + * @inheritDoc + * @noinspection PhpRedundantCatchClauseInspection + */ + public function appendMessage(string $content, string $folder): void + { + try { + $this->getFolder($folder) + ->messages() + ->append($content); + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + } + + private function getSelectedFolder(): FolderInterface + { + return $this->selectedFolder ?: $this->mailbox->inbox(); + } + + /** + * @throws ImapError + * @noinspection PhpRedundantCatchClauseInspection + */ + private function getFolder(string $name): FolderInterface + { + try { + $folder = $this->mailbox->folders()->find($name); + } catch (CommonException $e) { + throw new ImapError($e->getMessage(), previous: $e); + } + + if (!$folder) { + throw new ImapError("Could not select folder '$name'."); + } + + return $folder; + } + + /** + * @noinspection PhpRedundantCatchClauseInspection + */ + public function getFolderStatus(): FolderStatus + { + if (!$this->selectedFolder) { + throw new LogicException("Folder is not selected."); + } + + try { + $statusData = $this->selectedFolder->status(); + } catch (CommonException $e) { + throw new ImapError("Get folder status error.", previous: $e); + } + + $uidValidity = $statusData['UIDVALIDITY'] ?? null; + + if (is_numeric($uidValidity)) { + $uidValidity = (int) $uidValidity; + } + + if (!is_int($uidValidity)) { + throw new ImapError("Bad or no UIDVALIDITY value."); + } + + if ($uidValidity === 0) { + throw new ImapError("UIDVALIDITY value is zero."); + } + + return new FolderStatus( + uidValidity: $uidValidity, + ); + } +} diff --git a/application/Espo/Core/Mail/Account/Storage/Flag.php b/application/Espo/Core/Mail/Account/Storage/Flag.php new file mode 100644 index 0000000..2f3e472 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Storage/Flag.php @@ -0,0 +1,49 @@ +. + * + * 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\Mail\Account\Storage; + +class Flag +{ + public const PASSED = 'Passed'; + + public const SEEN = '\Seen'; + + public const UNSEEN = '\Unseen'; + + public const ANSWERED = '\Answered'; + + public const FLAGGED = '\Flagged'; + + public const DELETED = '\Deleted'; + + public const DRAFT = '\Draft'; + + public const RECENT = '\Recent'; +} diff --git a/application/Espo/Core/Mail/Account/Storage/FolderStatus.php b/application/Espo/Core/Mail/Account/Storage/FolderStatus.php new file mode 100644 index 0000000..b2d7f22 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Storage/FolderStatus.php @@ -0,0 +1,40 @@ +. + * + * 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\Mail\Account\Storage; + +/** + * @since 10.0.0 + */ +readonly class FolderStatus +{ + public function __construct( + public int $uidValidity, + ) {} +} diff --git a/application/Espo/Core/Mail/Account/Storage/Handler.php b/application/Espo/Core/Mail/Account/Storage/Handler.php new file mode 100644 index 0000000..e0fe4df --- /dev/null +++ b/application/Espo/Core/Mail/Account/Storage/Handler.php @@ -0,0 +1,41 @@ +. + * + * 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\Mail\Account\Storage; + +/** + * Handle storage parameters. + * To be used by extensions. + * + * @since 9.3.0 + */ +interface Handler +{ + public function handle(Params $params, string $id): Params; +} diff --git a/application/Espo/Core/Mail/Account/Storage/Params.php b/application/Espo/Core/Mail/Account/Storage/Params.php new file mode 100644 index 0000000..8995943 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Storage/Params.php @@ -0,0 +1,175 @@ +. + * + * 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\Mail\Account\Storage; + +use SensitiveParameter; + +/** + * Immutable. + */ +class Params +{ + /** @since 9.3.0 */ + public const string SECURITY_SSL = 'SSL'; + /** @since 9.3.0 */ + public const string SECURITY_START_TLS = 'TLS'; + + /** @var ?class-string */ + private ?string $imapHandlerClassName; + + /** @since 9.3.0 */ + public const string AUTH_MECHANISM_PLAIN = 'plain'; + + /** @since 9.3.0 */ + public const string AUTH_MECHANISM_XOAUTH = 'xoauth'; + + /** + * @param ?class-string $imapHandlerClassName + * @param self::AUTH_MECHANISM_* $authMechanism + */ + public function __construct( + private ?string $host, + private ?int $port, + private ?string $username, + private ?string $password, + private ?string $security, + ?string $imapHandlerClassName, + private ?string $id, + private ?string $userId, + private ?string $emailAddress, + private string $authMechanism = self::AUTH_MECHANISM_PLAIN, + ) { + $this->imapHandlerClassName = $imapHandlerClassName; + } + + public static function createBuilder(): ParamsBuilder + { + return new ParamsBuilder(); + } + + public function getHost(): ?string + { + return $this->host; + } + + public function getPort(): ?int + { + return $this->port; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getSecurity(): ?string + { + return $this->security; + } + + /** + * @return ?class-string + */ + public function getImapHandlerClassName(): ?string + { + return $this->imapHandlerClassName; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getUserId(): ?string + { + return $this->userId; + } + + public function getEmailAddress(): ?string + { + return $this->emailAddress; + } + + /** + * @return self::AUTH_MECHANISM_* + * @since 9.3.0 + */ + public function getAuthMechanism(): string + { + return $this->authMechanism; + } + + /** + * @param self::AUTH_MECHANISM_* $authMechanism + * @since 9.3.0 + */ + public function withAuthMechanism(string $authMechanism): self + { + $obj = clone $this; + $obj->authMechanism = $authMechanism; + + return $obj; + } + + /** + * @since 9.3.0 + */ + public function withUsername(?string $username): self + { + $obj = clone $this; + $obj->username = $username; + + return $obj; + } + + public function withPassword(#[SensitiveParameter] ?string $password): self + { + $obj = clone $this; + $obj->password = $password; + + return $obj; + } + + /** + * @param ?class-string $imapHandlerClassName + */ + public function withImapHandlerClassName(?string $imapHandlerClassName): self + { + $obj = clone $this; + $obj->imapHandlerClassName = $imapHandlerClassName; + + return $obj; + } +} diff --git a/application/Espo/Core/Mail/Account/Storage/ParamsBuilder.php b/application/Espo/Core/Mail/Account/Storage/ParamsBuilder.php new file mode 100644 index 0000000..6907f6b --- /dev/null +++ b/application/Espo/Core/Mail/Account/Storage/ParamsBuilder.php @@ -0,0 +1,127 @@ +. + * + * 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\Mail\Account\Storage; + +use SensitiveParameter; + +class ParamsBuilder +{ + private ?string $host = null; + private ?int $port = null; + private ?string $username = null; + private ?string $password = null; + private ?string $security = null; + /** @var ?class-string */ + private ?string $imapHandlerClassName = null; + private ?string $id = null; + private ?string $userId = null; + private ?string $emailAddress = null; + + public function build(): Params + { + return new Params( + $this->host, + $this->port, + $this->username, + $this->password, + $this->security, + $this->imapHandlerClassName, + $this->id, + $this->userId, + $this->emailAddress + ); + } + + public function setHost(?string $host): self + { + $this->host = $host; + + return $this; + } + + public function setPort(?int $port): self + { + $this->port = $port; + + return $this; + } + + public function setUsername(?string $username): self + { + $this->username = $username; + + return $this; + } + + public function setPassword(#[SensitiveParameter] ?string $password): self + { + $this->password = $password; + + return $this; + } + + public function setSecurity(?string $security): self + { + $this->security = $security; + + return $this; + } + + /** + * @param ?class-string $imapHandlerClassName + */ + public function setImapHandlerClassName(?string $imapHandlerClassName): self + { + $this->imapHandlerClassName = $imapHandlerClassName; + + return $this; + } + + public function setId(?string $id): self + { + $this->id = $id; + + return $this; + } + + public function setUserId(?string $userId): self + { + $this->userId = $userId; + + return $this; + } + + public function setEmailAddress(?string $emailAddress): self + { + $this->emailAddress = $emailAddress; + + return $this; + } +} diff --git a/application/Espo/Core/Mail/Account/StorageFactory.php b/application/Espo/Core/Mail/Account/StorageFactory.php new file mode 100644 index 0000000..1f33961 --- /dev/null +++ b/application/Espo/Core/Mail/Account/StorageFactory.php @@ -0,0 +1,52 @@ +. + * + * 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\Mail\Account; + +use Espo\Core\Mail\Account\Storage\Params; +use Espo\Core\Mail\Exceptions\ImapError; +use Espo\Core\Mail\Exceptions\NoImap; + +interface StorageFactory +{ + /** + * Create an account. + * + * @throws NoImap + * @throws ImapError + */ + public function create(Account $account): Storage; + + /** + * Create an account with parameters. + * + * @throws ImapError + */ + public function createWithParams(Params $params): Storage; +} diff --git a/application/Espo/Core/Mail/Account/Util/AddressUtil.php b/application/Espo/Core/Mail/Account/Util/AddressUtil.php new file mode 100644 index 0000000..a98d6e4 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Util/AddressUtil.php @@ -0,0 +1,69 @@ +. + * + * 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\Mail\Account\Util; + +use Espo\Core\Mail\Account\Storage\Params; +use Espo\Core\Mail\SmtpParams; +use Espo\Core\Utils\Config; + +/** + * @internal + */ +class AddressUtil +{ + public function __construct( + private Config $config, + ) {} + + /** + * @internal + */ + public function isAllowedAddress(Params|SmtpParams $params): bool + { + $host = $params instanceof Params ? $params->getHost() : $params->getServer(); + $port = $params->getPort(); + + if ($port === null || !$host) { + return false; + } + + $address = $host . ':' . $port; + + return in_array($address, $this->getAllowedAddressList()); + } + + /** + * @return string[] + */ + private function getAllowedAddressList(): array + { + return $this->config->get('emailServerAllowedAddressList') ?? []; + } +} diff --git a/application/Espo/Core/Mail/Account/Util/NotificationHelper.php b/application/Espo/Core/Mail/Account/Util/NotificationHelper.php new file mode 100644 index 0000000..0833d7e --- /dev/null +++ b/application/Espo/Core/Mail/Account/Util/NotificationHelper.php @@ -0,0 +1,147 @@ +. + * + * 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\Mail\Account\Util; + +use Espo\Core\Field\DateTime; +use Espo\Core\Field\LinkParent; +use Espo\Core\Mail\Account\Account; +use Espo\Core\Utils\Language; +use Espo\Entities\InboundEmail; +use Espo\Entities\Notification; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +class NotificationHelper +{ + private const PERIOD = '1 day'; + private const WAIT_PERIOD = '10 minutes'; + + public function __construct( + private EntityManager $entityManager, + private Language $language + ) {} + + public function processImapError(Account $account): void + { + $userId = $account->getUser()?->getId(); + $id = $account->getId(); + $entityType = $account->getEntityType(); + + if (!$id) { + return; + } + + if ( + $account->getConnectedAt() && + DateTime::createNow() + ->modify('-' . self::WAIT_PERIOD) + ->isLessThan($account->getConnectedAt()) + ) { + return; + } + + $userIds = []; + + if ($entityType === InboundEmail::ENTITY_TYPE) { + $userIds = $this->getAdminUserIds(); + } else if ($userId) { + $userIds[] = $userId; + } + + foreach ($userIds as $userId) { + $this->processImapErrorForUser($entityType, $id, $userId); + } + } + + private function exists(string $entityType, string $id, string $userId): bool + { + $one = $this->entityManager + ->getRDBRepositoryByClass(Notification::class) + ->where([ + 'relatedId' => $id, + 'relatedType' => $entityType, + 'userId' => $userId, + 'createdAt>' => DateTime::createNow()->modify('-' . self::PERIOD)->toString(), + ]) + ->findOne(); + + return $one !== null; + } + + private function getMessage(string $entityType, string $id): string + { + $message = $this->language->translateLabel('imapNotConnected', 'messages', $entityType); + + return str_replace('{id}', $id, $message); + } + + private function processImapErrorForUser(string $entityType, string $id, string $userId): void + { + if ($this->exists($entityType, $id, $userId)) { + return; + } + + $notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew(); + + $message = $this->getMessage($entityType, $id); + + $notification + ->setType(Notification::TYPE_MESSAGE) + ->setMessage($message) + ->setUserId($userId) + ->setRelated(LinkParent::create($entityType, $id)); + + $this->entityManager->saveEntity($notification); + } + + /** + * @return string[] + */ + private function getAdminUserIds(): array + { + $users = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->select([Attribute::ID]) + ->where([ + 'isActive' => true, + 'type' => User::TYPE_ADMIN, + ]) + ->find(); + + $ids = []; + + foreach ($users as $user) { + $ids[] = $user->getId(); + } + + return $ids; + } +} diff --git a/application/Espo/Core/Mail/ConfigDataProvider.php b/application/Espo/Core/Mail/ConfigDataProvider.php new file mode 100644 index 0000000..4d6e176 --- /dev/null +++ b/application/Espo/Core/Mail/ConfigDataProvider.php @@ -0,0 +1,49 @@ +. + * + * 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\Mail; + +use Espo\Core\Utils\Config; + +class ConfigDataProvider +{ + public function __construct( + private Config $config, + ) {} + + public function getSystemOutboundAddress(): ?string + { + return $this->config->get('outboundEmailFromAddress'); + } + + public function isSystemOutboundAddressShared(): bool + { + return (bool) $this->config->get('outboundEmailIsShared'); + } +} diff --git a/application/Espo/Core/Mail/EmailFactory.php b/application/Espo/Core/Mail/EmailFactory.php new file mode 100644 index 0000000..bfb047b --- /dev/null +++ b/application/Espo/Core/Mail/EmailFactory.php @@ -0,0 +1,53 @@ +. + * + * 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\Mail; + +use Espo\ORM\EntityManager; +use Espo\Entities\Email; + +/** + * Creates email instances. + */ +class EmailFactory +{ + public function __construct(private EntityManager $entityManager) + {} + + /** + * Create an email instance. + */ + public function create(): Email + { + /** @var Email $email */ + $email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE); + + return $email; + } +} diff --git a/application/Espo/Core/Mail/EmailSender.php b/application/Espo/Core/Mail/EmailSender.php new file mode 100644 index 0000000..419c5dd --- /dev/null +++ b/application/Espo/Core/Mail/EmailSender.php @@ -0,0 +1,168 @@ +. + * + * 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\Mail; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\InjectableFactory; +use Espo\Core\Mail\Account\SendingAccountProvider; +use Espo\Core\Name\Field; +use Espo\Core\Utils\Config; +use Espo\Entities\Attachment; +use Espo\Entities\Email; + +/** + * A service for email sending. Can send with SMTP parameters of the system email account or with specific parameters. + * Uses a builder to send with specific parameters. + */ +class EmailSender +{ + public function __construct( + private Config $config, + private SendingAccountProvider $accountProvider, + private InjectableFactory $injectableFactory + ) {} + + private function createSender(): Sender + { + return $this->injectableFactory->createWithBinding( + Sender::class, + BindingContainerBuilder + ::create() + ->bindInstance(SendingAccountProvider::class, $this->accountProvider) + ->build() + ); + } + + /** + * Create a builder. + */ + public function create(): Sender + { + return $this->createSender(); + } + + /** + * With parameters. + * + * @param SenderParams|array $params + */ + public function withParams($params): Sender + { + return $this->createSender()->withParams($params); + } + + /** + * With specific SMTP parameters. + * + * @param SmtpParams|array $params + */ + public function withSmtpParams($params): Sender + { + return $this->createSender()->withSmtpParams($params); + } + + /** + * With specific attachments. + * + * @param iterable $attachmentList + * @noinspection PhpUnused + */ + public function withAttachments(iterable $attachmentList): Sender + { + return $this->createSender()->withAttachments($attachmentList); + } + + /** + * With an envelope from address. + * + * @since 9.1.0 + * @noinspection PhpUnused + */ + public function withEnvelopeFromAddress(string $fromAddress): void + { + $this->createSender()->withEnvelopeFromAddress($fromAddress); + } + + /** + * Add a header. + * + * @param string $name A header name. + * @param string $value A header value. + * @since 9.1.0 + */ + public function withAddedHeader(string $name, string $value): Sender + { + return $this->createSender()->withAddedHeader($name, $value); + } + + /** + * Whether system SMTP is configured. + */ + public function hasSystemSmtp(): bool + { + if ($this->config->get('smtpServer')) { + return true; + } + + if ($this->accountProvider->getSystem()) { + return true; + } + + return false; + } + + /** + * Send an email. + * + * @throws Exceptions\SendingError + */ + public function send(Email $email): void + { + $this->createSender()->send($email); + } + + /** + * Generate a message ID. + */ + static public function generateMessageId(Email $email): string + { + $rand = mt_rand(1000, 9999); + + $messageId = $email->getParentType() && $email->getParentId() ? + sprintf("%s/%s/%s/%s@espo", $email->getParentType(), $email->getParentId(), time(), $rand) : + sprintf("%s/%s/%s@espo", md5($email->get(Field::NAME)), time(), $rand); + + if ($email->get('isSystem')) { + $messageId .= '-system'; + } + + return $messageId; + } +} diff --git a/application/Espo/Core/Mail/Event/Event.php b/application/Espo/Core/Mail/Event/Event.php new file mode 100644 index 0000000..7fde6d3 --- /dev/null +++ b/application/Espo/Core/Mail/Event/Event.php @@ -0,0 +1,246 @@ +. + * + * 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\Mail\Event; + +use Espo\Core\Utils\DateTime as DateTimeUtil; + +use DateTime; +use DateTimeZone; +use RuntimeException; + +class Event +{ + private ?string $attendees = null; + private ?string $organizer = null; + private ?string $dateStart = null; + private ?string $dateEnd = null; + private ?string $location = null; + private ?string $name = null; + private ?string $description = null; + private ?string $timezone = null; + private ?string $uid = null; + + private bool $isAllDay = false; + + public function withAttendees(?string $attendees): self + { + $obj = clone $this; + $obj->attendees = $attendees; + + return $obj; + } + + public function withOrganizer(?string $organizer): self + { + $obj = clone $this; + $obj->organizer = $organizer; + + return $obj; + } + + public function withDateStart(?string $dateStart): self + { + $obj = clone $this; + $obj->dateStart = $dateStart; + + return $obj; + } + + public function withDateEnd(?string $dateEnd): self + { + $obj = clone $this; + $obj->dateEnd = $dateEnd; + + return $obj; + } + + public function withLocation(?string $location): self + { + $obj = clone $this; + $obj->location = $location; + + return $obj; + } + + public function withName(?string $name): self + { + $obj = clone $this; + $obj->name = $name; + + return $obj; + } + + public function withDescription(?string $description): self + { + $obj = clone $this; + $obj->description = $description; + + return $obj; + } + + public function withTimezone(?string $timezone): self + { + $obj = clone $this; + $obj->timezone = $timezone; + + return $obj; + } + + public function withUid(?string $uid): self + { + $obj = clone $this; + $obj->uid = $uid; + + return $obj; + } + + public function withIsAllDay(bool $isAllDay): self + { + $obj = clone $this; + $obj->isAllDay = $isAllDay; + + return $obj; + } + + public function getUid(): ?string + { + return $this->uid; + } + + public function isAllDay(): bool + { + return $this->isAllDay; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getDateStart(): ?string + { + return $this->convertDate($this->dateStart); + } + + public function getDateEnd(): ?string + { + return $this->convertDate($this->dateEnd, true); + } + + public function getLocation(): ?string + { + return $this->location; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public static function create(): self + { + return new self(); + } + + private function convertDate(?string $value, bool $isEnd = false): ?string + { + if ($value === null) { + return null; + } + + if ($this->isAllDay) { + $dt = DateTime::createFromFormat('Ymd', $value); + + if ($dt === false) { + throw new RuntimeException("Could not parse '{$value}'."); + } + + if ($isEnd) { + $dt->modify('-1 day'); + } + + return $dt->format(DateTimeUtil::SYSTEM_DATE_FORMAT); + } + + $timezone = $this->timezone ?? 'UTC'; + + $dt = DateTime::createFromFormat('Ymd\THis', $value, new DateTimeZone($timezone)); + + if ($dt === false) { + throw new RuntimeException("Could not parse '{$value}'."); + } + + $dt->setTimezone(new DateTimeZone('UTC')); + + return $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } + + public function getOrganizerEmailAddress(): ?string + { + return $this->getEmailAddressFromAttendee($this->organizer); + } + + /** + * @return string[] + */ + public function getAttendeeEmailAddressList(): array + { + if ($this->attendees === null || $this->attendees === '') { + return []; + } + + $list = []; + + foreach (explode(',', $this->attendees) as $item) { + $emailAddress = $this->getEmailAddressFromAttendee($item); + + if ($emailAddress === null) { + continue; + } + + $list[] = $emailAddress; + } + + return $list; + } + + private function getEmailAddressFromAttendee(?string $item): ?string + { + if ($item === null || $item === '') { + return null; + } + + if (explode(':', $item)[0] !== 'MAILTO') { + return null; + } + + return explode(':', $item)[1] ?? null; + } +} diff --git a/application/Espo/Core/Mail/Event/EventFactory.php b/application/Espo/Core/Mail/Event/EventFactory.php new file mode 100644 index 0000000..737321a --- /dev/null +++ b/application/Espo/Core/Mail/Event/EventFactory.php @@ -0,0 +1,70 @@ +. + * + * 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\Mail\Event; + +use ICal\Event as ICalEvent; +use ICal\ICal as U01jmg3ICal; + +use RuntimeException; + +class EventFactory +{ + public static function createFromU01jmg3Ical(U01jmg3ICal $ical): Event + { + /* @var ?ICalEvent $event */ + $event = $ical->events()[0] ?? null; + + if (!$event) { + throw new RuntimeException(); + } + + $dateStart = $event->dtstart_tz ?? null; + $dateEnd = $event->dtend_tz ?? null; + + $isAllDay = strlen($event->dtstart) === 8; + + if ($isAllDay) { + $dateStart = $event->dtstart ?? null; + $dateEnd = $event->dtend ?? null; + } + + return Event::create() + ->withUid($event->uid ?? null) + ->withIsAllDay($isAllDay) + ->withDateStart($dateStart) + ->withDateEnd($dateEnd) + ->withName($event->summary ?? null) + ->withLocation($event->location ?? null) + ->withDescription($event->description ?? null) + ->withTimezone($ical->calendarTimeZone() ?? null) /** @phpstan-ignore-line */ + ->withOrganizer($event->organizer ?? null) + ->withAttendees($event->attendee ?? null); + } +} diff --git a/application/Espo/Core/Mail/Exceptions/ImapError.php b/application/Espo/Core/Mail/Exceptions/ImapError.php new file mode 100644 index 0000000..18bfe37 --- /dev/null +++ b/application/Espo/Core/Mail/Exceptions/ImapError.php @@ -0,0 +1,32 @@ +. + * + * 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\Mail\Exceptions; + +class ImapError extends \Exception {} diff --git a/application/Espo/Core/Mail/Exceptions/NoImap.php b/application/Espo/Core/Mail/Exceptions/NoImap.php new file mode 100644 index 0000000..913e747 --- /dev/null +++ b/application/Espo/Core/Mail/Exceptions/NoImap.php @@ -0,0 +1,32 @@ +. + * + * 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\Mail\Exceptions; + +class NoImap extends \Exception {} diff --git a/application/Espo/Core/Mail/Exceptions/NoSmtp.php b/application/Espo/Core/Mail/Exceptions/NoSmtp.php new file mode 100644 index 0000000..c9596d9 --- /dev/null +++ b/application/Espo/Core/Mail/Exceptions/NoSmtp.php @@ -0,0 +1,32 @@ +. + * + * 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\Mail\Exceptions; + +class NoSmtp extends SendingError {} diff --git a/application/Espo/Core/Mail/Exceptions/SendingError.php b/application/Espo/Core/Mail/Exceptions/SendingError.php new file mode 100644 index 0000000..ca730b9 --- /dev/null +++ b/application/Espo/Core/Mail/Exceptions/SendingError.php @@ -0,0 +1,32 @@ +. + * + * 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\Mail\Exceptions; + +class SendingError extends \Exception {} diff --git a/application/Espo/Core/Mail/FiltersMatcher.php b/application/Espo/Core/Mail/FiltersMatcher.php new file mode 100644 index 0000000..ba801ea --- /dev/null +++ b/application/Espo/Core/Mail/FiltersMatcher.php @@ -0,0 +1,208 @@ +. + * + * 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\Mail; + +use Espo\Entities\Email; +use Espo\Entities\EmailFilter; + +class FiltersMatcher +{ + /** + * @param iterable $filterList + * @param bool $skipBody Not to match if the body-contains is not empty. + */ + public function findMatch(Email $email, $filterList, bool $skipBody = false): ?EmailFilter + { + foreach ($filterList as $filter) { + if ($this->match($email, $filter, $skipBody)) { + return $filter; + } + } + + return null; + } + + /** + * @param bool $skipBody Not to match if the body-contains is not empty. + */ + public function match(Email $email, EmailFilter $filter, bool $skipBody = false): bool + { + $filterCount = 0; + + $from = $filter->getFrom(); + $subject = $filter->getSubject(); + + if ($from) { + $filterCount++; + + if ( + !$this->matchString( + strtolower($from), + strtolower($email->getFromAddress() ?? '') + ) + ) { + return false; + } + } + + if ($filter->getTo()) { + $filterCount++; + + if (!$this->matchTo($email, $filter)) { + return false; + } + } + + if ($subject) { + $filterCount++; + + if ( + !$this->matchString($subject, $email->getSubject() ?? '') + ) { + return false; + } + } + + if (count($filter->getBodyContains())) { + $filterCount++; + + if ($skipBody) { + return false; + } + + if (!$this->matchBody($email, $filter)) { + return false; + } + } + + if (count($filter->getBodyContainsAll())) { + $filterCount++; + + if ($skipBody) { + return false; + } + + if (!$this->matchBodyAll($email, $filter)) { + return false; + } + } + + if ($filterCount) { + return true; + } + + return false; + } + + private function matchTo(Email $email, EmailFilter $filter): bool + { + $filterTo = $filter->getTo(); + + if ($filterTo === null) { + return false; + } + + if (count($email->getToAddressList())) { + foreach ($email->getToAddressList() as $to) { + if ( + $this->matchString( + strtolower($filterTo), + strtolower($to) + ) + ) { + return true; + } + } + } + + return false; + } + + private function matchBody(Email $email, EmailFilter $filter): bool + { + $phraseList = $filter->getBodyContains(); + $body = $email->getBody(); + $bodyPlain = $email->getBodyPlain(); + + foreach ($phraseList as $phrase) { + if ($phrase === '') { + continue; + } + + if ($bodyPlain && stripos($bodyPlain, $phrase) !== false) { + return true; + } + + if ($body && stripos($body, $phrase) !== false) { + return true; + } + } + + return false; + } + + private function matchBodyAll(Email $email, EmailFilter $filter): bool + { + $phraseList = $filter->getBodyContainsAll(); + $body = $email->getBody() ?? $email->getBodyPlain() ?? ''; + + if ($phraseList === []) { + return true; + } + + foreach ($phraseList as $phrase) { + if ($phrase === '') { + continue; + } + + if (stripos($body, $phrase) === false) { + return false; + } + } + + return true; + } + + private function matchString(string $pattern, string $value): bool + { + if ($pattern == $value) { + return true; + } + + $pattern = preg_quote($pattern, '#'); + $pattern = str_replace('\*', '.*', $pattern) . '\z'; + + if (preg_match('#^' . $pattern . '#', $value)) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Core/Mail/Importer.php b/application/Espo/Core/Mail/Importer.php new file mode 100644 index 0000000..8c2997c --- /dev/null +++ b/application/Espo/Core/Mail/Importer.php @@ -0,0 +1,41 @@ +. + * + * 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\Mail; + +use Espo\Core\Mail\Importer\Data; +use Espo\Entities\Email; + +/** + * Imports email messages. Handles duplicate checking, parent look-up. + */ +interface Importer +{ + public function import(Message $message, Data $data): ?Email; +} diff --git a/application/Espo/Core/Mail/Importer/AutoReplyDetector.php b/application/Espo/Core/Mail/Importer/AutoReplyDetector.php new file mode 100644 index 0000000..87d7c3e --- /dev/null +++ b/application/Espo/Core/Mail/Importer/AutoReplyDetector.php @@ -0,0 +1,42 @@ +. + * + * 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\Mail\Importer; + +use Espo\Core\Mail\Message; + +/** + * Detects if an email is auto-response. + * + * @since 9.2.0 + */ +interface AutoReplyDetector +{ + public function detect(Message $message): bool; +} diff --git a/application/Espo/Core/Mail/Importer/Data.php b/application/Espo/Core/Mail/Importer/Data.php new file mode 100644 index 0000000..1cf3e56 --- /dev/null +++ b/application/Espo/Core/Mail/Importer/Data.php @@ -0,0 +1,172 @@ +. + * + * 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\Mail\Importer; + +use Espo\Entities\EmailFilter; + +/** + * Immutable. + */ +class Data +{ + private ?string $assignedUserId = null; + /** @var string[] */ + private array $teamIdList = []; + /** @var string[] */ + private array $userIdList = []; + /** @var iterable */ + private iterable $filterList = []; + private bool $fetchOnlyHeader = false; + /** @var array */ + private array $folderData = []; + private ?string $groupEmailFolderId = null; + + public static function create(): self + { + return new self(); + } + + public function getAssignedUserId(): ?string + { + return $this->assignedUserId; + } + + /** + * @return string[] + */ + public function getTeamIdList(): array + { + return $this->teamIdList; + } + + /** + * @return string[] + */ + public function getUserIdList(): array + { + return $this->userIdList; + } + + /** + * @return iterable + */ + public function getFilterList(): iterable + { + return $this->filterList; + } + + public function fetchOnlyHeader(): bool + { + return $this->fetchOnlyHeader; + } + + /** + * @return array + */ + public function getFolderData(): array + { + return $this->folderData; + } + + public function getGroupEmailFolderId(): ?string + { + return $this->groupEmailFolderId; + } + + public function withAssignedUserId(?string $assignedUserId): self + { + $obj = clone $this; + + $obj->assignedUserId = $assignedUserId; + + return $obj; + } + + /** + * @param string[] $teamIdList + */ + public function withTeamIdList(array $teamIdList): self + { + $obj = clone $this; + + $obj->teamIdList = $teamIdList; + + return $obj; + } + + /** + * @param string[] $userIdList + */ + public function withUserIdList(array $userIdList): self + { + $obj = clone $this; + $obj->userIdList = $userIdList; + + return $obj; + } + + /** + * @param iterable $filterList + */ + public function withFilterList(iterable $filterList): self + { + $obj = clone $this; + $obj->filterList = $filterList; + + return $obj; + } + + public function withFetchOnlyHeader(bool $fetchOnlyHeader = true): self + { + $obj = clone $this; + $obj->fetchOnlyHeader = $fetchOnlyHeader; + + return $obj; + } + + /** + * @param array $folderData + */ + public function withFolderData(array $folderData): self + { + $obj = clone $this; + $obj->folderData = $folderData; + + return $obj; + } + + public function withGroupEmailFolderId(?string $groupEmailFolderId): self + { + $obj = clone $this; + $obj->groupEmailFolderId = $groupEmailFolderId; + + return $obj; + } +} diff --git a/application/Espo/Core/Mail/Importer/DefaultAutoReplyDetector.php b/application/Espo/Core/Mail/Importer/DefaultAutoReplyDetector.php new file mode 100644 index 0000000..1753d24 --- /dev/null +++ b/application/Espo/Core/Mail/Importer/DefaultAutoReplyDetector.php @@ -0,0 +1,55 @@ +. + * + * 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\Mail\Importer; + +use Espo\Core\Mail\Message; + +class DefaultAutoReplyDetector implements AutoReplyDetector +{ + public function detect(Message $message): bool + { + if ($message->getHeader('X-Autoreply')) { + return true; + } + + if ($message->getHeader('X-Autorespond')) { + return true; + } + + if ( + $message->getHeader('Auto-Submitted') && + strtolower($message->getHeader('Auto-Submitted')) === 'auto-replied' + ) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Core/Mail/Importer/DefaultDuplicateFinder.php b/application/Espo/Core/Mail/Importer/DefaultDuplicateFinder.php new file mode 100644 index 0000000..ede459a --- /dev/null +++ b/application/Espo/Core/Mail/Importer/DefaultDuplicateFinder.php @@ -0,0 +1,56 @@ +. + * + * 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\Mail\Importer; + +use Espo\Core\Mail\Message; +use Espo\Entities\Email; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +class DefaultDuplicateFinder implements DuplicateFinder +{ + public function __construct(private EntityManager $entityManager) + {} + + public function find(Email $email, Message $message): ?Email + { + if (!$email->getMessageId()) { + return null; + } + + return $this->entityManager + ->getRDBRepositoryByClass(Email::class) + ->select([Attribute::ID, 'status']) + ->where([ + 'messageId' => $email->getMessageId(), + ]) + ->findOne(); + } +} diff --git a/application/Espo/Core/Mail/Importer/DefaultImporter.php b/application/Espo/Core/Mail/Importer/DefaultImporter.php new file mode 100644 index 0000000..f0c77cd --- /dev/null +++ b/application/Espo/Core/Mail/Importer/DefaultImporter.php @@ -0,0 +1,720 @@ +. + * + * 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\Mail\Importer; + +use Espo\Core\Field\DateTime as DateTimeField; +use Espo\Core\Field\LinkMultiple; +use Espo\Core\Field\LinkParent; +use Espo\Core\Job\Job\Data as JobData; +use Espo\Core\Job\JobSchedulerFactory; +use Espo\Core\Mail\FiltersMatcher; +use Espo\Core\Mail\Importer; +use Espo\Core\Mail\Message; +use Espo\Core\Mail\MessageWrapper; +use Espo\Core\Mail\Parser; +use Espo\Core\Mail\ParserFactory; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\Notification\AssignmentNotificator; +use Espo\Core\Notification\AssignmentNotificatorFactory; +use Espo\Core\Notification\AssignmentNotificator\Params as AssignmentNotificatorParams; +use Espo\Core\Utils\Config; +use Espo\Core\FieldProcessing\Relation\LinkMultipleSaver; +use Espo\Core\FieldProcessing\Saver\Params as SaverParams; +use Espo\Core\Job\QueueName; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Entities\Attachment; +use Espo\Entities\Email; +use Espo\Entities\EmailFilter; +use Espo\Entities\GroupEmailFolder; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Defs\Params\AttributeParam; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\SelectBuilder; +use Espo\Repositories\Email as EmailRepository; +use Espo\ORM\EntityManager; +use Espo\Tools\Stream\Jobs\ProcessNoteAcl; + +use DateTime; +use Exception; + +class DefaultImporter implements Importer +{ + private const SUBJECT_MAX_LENGTH = 255; + private const PROCESS_ACL_DELAY_PERIOD = '5 seconds'; + + /** @var AssignmentNotificator */ + private AssignmentNotificator $notificator; + private FiltersMatcher $filtersMatcher; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + AssignmentNotificatorFactory $notificatorFactory, + private ParserFactory $parserFactory, + private LinkMultipleSaver $linkMultipleSaver, + private DuplicateFinder $duplicateFinder, + private JobSchedulerFactory $jobSchedulerFactory, + private ParentFinder $parentFinder, + private AutoReplyDetector $autoReplyDetector, + private EmailSaver $emailSaver, + ) { + $this->notificator = $notificatorFactory->createByClass(Email::class); + $this->filtersMatcher = new FiltersMatcher(); + } + + public function import(Message $message, Data $data): ?Email + { + $parser = $this->getParser($message); + + $email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew(); + $email->set('isBeingImported', true); + + $subject = $this->getSubject($parser, $message); + + $email + ->setSubject($subject) + ->setStatus(Email::STATUS_ARCHIVED) + ->setIsHtml(false) + ->setGroupFolderId($data->getGroupEmailFolderId()) + ->setTeams(LinkMultiple::create()->withAddedIdList($data->getTeamIdList())); + + if ($data->getAssignedUserId()) { + $email->setAssignedUserId($data->getAssignedUserId()); + $email->addAssignedUserId($data->getAssignedUserId()); + } + + foreach ($data->getUserIdList() as $uId) { + $email->addUserId($uId); + } + + $this->setFromStrings($parser, $message, $email); + $this->setAddresses($parser, $message, $email); + + foreach ($data->getFolderData() as $uId => $folderId) { + $email->setUserColumnFolderId($uId, $folderId); + } + + $toSkip = $this->processFilters($email, $data->getFilterList(), true); + + if ($toSkip) { + return null; + } + + $isSystemEmail = $this->processMessageId($parser, $message, $email); + + if ($isSystemEmail) { + return null; + } + + $this->processDate($parser, $message, $email); + + $duplicate = $this->findDuplicate($email, $message); + + if ($duplicate && $duplicate->getStatus() !== Email::STATUS_BEING_IMPORTED) { + $this->entityManager->refreshEntity($duplicate); + + $this->processDuplicate($duplicate, $data, $email->getGroupFolder()?->getId()); + + return $duplicate; + } + + $email->setIsAutoReply($this->autoReplyDetector->detect($message)); + + $this->processDeliveryDate($parser, $message, $email); + + if (!$email->getDateSent()) { + $email->setDateSent(DateTimeField::createNow()); + } + + $inlineAttachmentList = []; + + if (!$data->fetchOnlyHeader()) { + $inlineAttachmentList = $parser->getInlineAttachmentList($message, $email); + + $toSkip = $this->processFilters($email, $data->getFilterList()); + + if ($toSkip) { + return null; + } + } else { + $email->setBody('Not fetched. The email size exceeds the limit.'); + $email->setIsHtml(false); + } + + $this->processInReplyTo($parser, $message, $email); + + $parentFound = $this->parentFinder->find($email, $message); + + if ($parentFound) { + $email->setParent($parentFound); + } + + if (!$duplicate) { + $this->entityManager->getLocker()->lockExclusive(Email::ENTITY_TYPE); + + $duplicate = $this->findDuplicate($email, $message); + + if ($duplicate) { + $this->entityManager->getLocker()->rollback(); + + if ($duplicate->getStatus() !== Email::STATUS_BEING_IMPORTED) { + $this->entityManager->refreshEntity($duplicate); + + $this->processDuplicate($duplicate, $data, $email->getGroupFolder()?->getId()); + + return $duplicate; + } + } + } + + if ($duplicate) { + $this->copyAttributesToDuplicate($email, $duplicate); + $this->getEmailRepository()->fillAccount($duplicate); + + $this->processDuplicate($duplicate, $data, $email->getGroupFolder()?->getId()); + + return $duplicate; + } + + if (!$email->getMessageId()) { + $email->setDummyMessageId(); + } + + $email->setStatus(Email::STATUS_BEING_IMPORTED); + + $this->entityManager->saveEntity($email, [ + SaveOption::SKIP_ALL => true, + SaveOption::KEEP_NEW => true, + ]); + + $this->entityManager->getLocker()->commit(); + + if ($parentFound) { + $this->processEmailWithParent($email); + } + + $email->setStatus(Email::STATUS_ARCHIVED); + + $this->emailSaver->save($email); + + $this->processAttachmentSave($inlineAttachmentList, $email); + + return $email; + } + + private function copyAttributesToDuplicate(Email $email, Email $duplicate): void + { + $duplicate->set([ + 'from' => $email->get('from'), + 'to' => $email->get('to'), + 'cc' => $email->get('cc'), + 'bcc' => $email->get('bcc'), + 'replyTo' => $email->get('replyTo'), + 'name' => $email->get(Field::NAME), + 'dateSent' => $email->get('dateSent'), + 'body' => $email->get('body'), + 'bodyPlain' => $email->get('bodyPlain'), + 'parentType' => $email->get('parentType'), + 'parentId' => $email->get('parentId'), + 'isHtml' => $email->get('isHtml'), + 'messageId' => $email->get('messageId'), + 'fromString' => $email->get('fromString'), + 'replyToString' => $email->get('replyToString'), + ]); + } + + private function processEmailWithParent(Email $email): void + { + $parentType = $email->get(Field::PARENT . 'Type'); + $parentId = $email->get(Field::PARENT . 'Id'); + + if (!$parentId || !$parentType) { + return; + } + + $emailKeepParentTeamsEntityList = $this->config->get('emailKeepParentTeamsEntityList') ?? []; + + if ( + !in_array($parentType, $emailKeepParentTeamsEntityList) || + !$this->entityManager->hasRepository($parentType) + ) { + return; + } + + $parent = $email->getParent(); + + if (!$parent) { + return; + } + + if (!$parent instanceof CoreEntity) { + return; + } + + foreach ($parent->getLinkMultipleIdList(Field::TEAMS) as $parentTeamId) { + $email->addTeamId($parentTeamId); + } + } + + private function findDuplicate(Email $email, Message $message): ?Email + { + return $this->duplicateFinder->find($email, $message); + } + + private function processDuplicate(Email $email, Data $data, ?string $groupFolderId): void + { + $assignedUserId = $data->getAssignedUserId(); + + if ($email->getStatus() === Email::STATUS_ARCHIVED) { + $this->getEmailRepository()->loadFromField($email); + $this->getEmailRepository()->loadToField($email); + } + + $fetchedTeamIds = $email->getTeams()->getIdList(); + $fetchedUserIds = $email->getUsers()->getIdList(); + $fetchedAssignedUserIds = $email->getAssignedUsers()->getIdList(); + + $email->setLinkMultipleIdList('users', []); + $email->setLinkMultipleIdList(Field::TEAMS, []); + $email->setLinkMultipleIdList(Field::ASSIGNED_USERS, []); + + $processNoteAcl = false; + + if ($assignedUserId) { + if (!in_array($assignedUserId, $fetchedUserIds)) { + $processNoteAcl = true; + + $email->addUserId($assignedUserId); + } + + if (!in_array($assignedUserId, $fetchedAssignedUserIds)) { + $email->addAssignedUserId($assignedUserId); + } + } + + foreach ($data->getUserIdList() as $uId) { + if (!in_array($uId, $fetchedUserIds)) { + $processNoteAcl = true; + + $email->addUserId($uId); + } + } + + foreach ($data->getFolderData() as $uId => $folderId) { + if (!in_array($uId, $fetchedUserIds)) { + $email->setUserColumnFolderId($uId, $folderId); + + continue; + } + + // Can cause skip-notification bypass. @todo Revise. + $this->entityManager + ->getRelation($email, 'users') + ->updateColumnsById($uId, [Email::USERS_COLUMN_FOLDER_ID => $folderId]); + } + + $email->set('isBeingImported', true); + + $this->getEmailRepository()->applyUsersFilters($email); + + if ($groupFolderId && !$email->getGroupFolder()) { + $this->relateWithGroupFolder($email, $groupFolderId); + + $addedFromFolder = $this->applyGroupFolder( + $email, + $groupFolderId, + $fetchedUserIds, + $fetchedTeamIds + ); + + if ($addedFromFolder) { + $processNoteAcl = true; + } + } + + foreach ($data->getTeamIdList() as $teamId) { + if (!in_array($teamId, $fetchedTeamIds)) { + $processNoteAcl = true; + + $email->addTeamId($teamId); + } + } + + $saverParams = SaverParams::create()->withRawOptions([ + 'skipLinkMultipleRemove' => true, + 'skipLinkMultipleUpdate' => true, + ]); + + $this->linkMultipleSaver->process($email, 'users', $saverParams); + $this->linkMultipleSaver->process($email, Field::ASSIGNED_USERS, $saverParams); + $this->linkMultipleSaver->process($email, Field::TEAMS, $saverParams); + + if ($this->notificationsEnabled()) { + $notificatorParams = AssignmentNotificatorParams::create() + ->withRawOptions([Email::SAVE_OPTION_IS_BEING_IMPORTED => true]); + + $this->notificator->process($email, $notificatorParams); + } + + $email->set('isBeingImported', false); + $email->clear('teamsIds'); + $email->clear('usersIds'); + $email->clear('assignedUsersIds'); + + $email->setAsFetched(); + + if ($email->getParentType() && $processNoteAcl) { + $this->scheduleAclJob($email); + } + } + + private function notificationsEnabled(): bool + { + return in_array( + Email::ENTITY_TYPE, + $this->config->get('assignmentNotificationsEntityList') ?? [] + ); + } + + private function getSubject(Parser $parser, Message $message): string + { + $subject = ''; + + if ($parser->hasHeader($message, 'subject')) { + $subject = $parser->getHeader($message, 'subject'); + } + + if (!empty($subject)) { + $subject = trim($subject); + } + + if ($subject !== '0' && empty($subject)) { + $subject = '(No Subject)'; + } + + if (mb_strlen($subject) > self::SUBJECT_MAX_LENGTH) { + $subject = mb_substr($subject, 0, self::SUBJECT_MAX_LENGTH); + } + + return $subject; + } + + private function setFromStrings(Parser $parser, Message $message, Email $email): void + { + $fromAddressData = $parser->getAddressData($message, 'from'); + + if ($fromAddressData) { + $namePart = ($fromAddressData->name ? ($fromAddressData->name . ' ') : ''); + + $email->set('fromString', "$namePart<$fromAddressData->address>"); + } + + $replyToData = $parser->getAddressData($message, 'reply-To'); + + if ($replyToData) { + $namePart = ($replyToData->name ? ($replyToData->name . ' ') : ''); + + $email->set('replyToString', "$namePart<$replyToData->address>"); + } + } + + private function setAddresses(Parser $parser, Message $message, Email $email): void + { + $from = $parser->getAddressList($message, 'from'); + $to = $parser->getAddressList($message, 'to'); + $cc = $parser->getAddressList($message, 'cc'); + $replyTo = $parser->getAddressList($message, 'reply-To'); + + $email->setFromAddress($from[0] ?? null); + $email->setToAddressList($to); + $email->setCcAddressList($cc); + $email->setReplyToAddressList($replyTo); + + $email->set('addressNameMap', $parser->getAddressNameMap($message)); + } + + /** + * @return bool True if an email is system. + */ + private function processMessageId(Parser $parser, Message $message, Email $email): bool + { + if (!$parser->hasHeader($message, 'message-Id')) { + return false; + } + + $messageId = $parser->getMessageId($message); + + if (!$messageId) { + return false; + } + + $email->setMessageId($messageId); + + if ($parser->hasHeader($message, 'delivered-To')) { + $deliveredTo = $parser->getHeader($message, 'delivered-To') ?? ''; + + $messageIdInternal = "$messageId-$deliveredTo"; + + $messageIdInternalMaxLength = $email->getAttributeParam('messageIdInternal', AttributeParam::LEN); + + if ($messageIdInternalMaxLength) { + $messageIdInternal = mb_substr($messageIdInternal, 0, $messageIdInternalMaxLength); + } + + $email->set('messageIdInternal', $messageIdInternal); + } + + if (stripos($messageId, '@espo-system') !== false) { + return true; + } + + return false; + } + + private function processDate(Parser $parser, Message $message, Email $email): void + { + if (!$parser->hasHeader($message, 'date')) { + return; + } + + $dateString = $parser->getHeader($message, 'date') ?? 'now'; + + try { + $dateSent = DateTimeField::fromDateTime(new DateTime($dateString)); + } catch (Exception) { + return; + } + + $email->setDateSent($dateSent); + } + + private function processDeliveryDate(Parser $parser, Message $message, Email $email): void + { + if (!$parser->hasHeader($message, 'delivery-Date')) { + return; + } + + $dateString = $parser->getHeader($message, 'delivery-Date') ?? 'now'; + + try { + $deliveryDate = DateTimeField::fromDateTime(new DateTime($dateString)); + } catch (Exception) { + return; + } + + $email->setDeliveryDate($deliveryDate); + } + + private function processInReplyTo(Parser $parser, Message $message, Email $email): void + { + if (!$parser->hasHeader($message, 'in-Reply-To')) { + return; + } + + $stringValue = $parser->getHeader($message, 'in-Reply-To'); + + if (!$stringValue) { + return; + } + + $values = explode(' ', $stringValue); + + $inReplyTo = $values[0] ?? null; + + if (!$inReplyTo) { + return; + } + + if ($inReplyTo[0] !== '<') { + $inReplyTo = "<$inReplyTo>"; + } + + $replied = $this->entityManager + ->getRDBRepositoryByClass(Email::class) + ->where(['messageId' => $inReplyTo]) + ->findOne(); + + if (!$replied) { + return; + } + + $email->setReplied($replied); + + foreach ($replied->getTeams()->getIdList() as $teamId) { + $email->addTeamId($teamId); + } + } + + /** + * @param iterable $filterList + * @return bool True if to skip. + */ + private function processFilters(Email $email, iterable $filterList, bool $skipBody = false): bool + { + $matchedFilter = $this->filtersMatcher->findMatch($email, $filterList, $skipBody); + + if (!$matchedFilter) { + return false; + } + + if ($matchedFilter->getAction() === EmailFilter::ACTION_SKIP) { + return true; + } + + if ( + $matchedFilter->getAction() === EmailFilter::ACTION_MOVE_TO_GROUP_FOLDER && + $matchedFilter->getGroupEmailFolderId() + ) { + $this->applyGroupFolder($email, $matchedFilter->getGroupEmailFolderId()); + } + + return false; + } + + + /** + * @param Attachment[] $inlineAttachmentList + */ + private function processAttachmentSave(array $inlineAttachmentList, Email $email): void + { + foreach ($inlineAttachmentList as $attachment) { + $attachment->setTargetField('body'); + $attachment->setRelated(LinkParent::fromEntity($email)); + + $this->entityManager->saveEntity($attachment); + } + } + + private function getParser(Message $message): Parser + { + return $message instanceof MessageWrapper ? + ($message->getParser() ?? $this->parserFactory->create()) : + $this->parserFactory->create(); + } + + private function getEmailRepository(): EmailRepository + { + /** @var EmailRepository */ + return $this->entityManager->getRDBRepositoryByClass(Email::class); + } + + private function relateWithGroupFolder(Email $email, string $groupFolderId): void + { + $this->entityManager + ->getRelation($email, 'groupFolder') + ->relateById($groupFolderId); + } + + /** + * @param string[] $fetchedUserIds + * @param string[] $fetchedTeamIds + */ + private function applyGroupFolder( + Email $email, + string $groupFolderId, + array $fetchedUserIds = [], + array $fetchedTeamIds = [], + ): bool { + + $email->setGroupFolderId($groupFolderId); + + $groupFolder = $this->entityManager + ->getRDBRepositoryByClass(GroupEmailFolder::class) + ->getById($groupFolderId); + + if (!$groupFolder || !$groupFolder->getTeams()->getCount()) { + return false; + } + + $added = false; + + foreach ($groupFolder->getTeams()->getIdList() as $teamId) { + if (!in_array($teamId, $fetchedTeamIds)) { + $added = true; + + $email->addTeamId($teamId); + } + } + + $users = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->select([Attribute::ID]) + ->where([ + 'type' => [User::TYPE_REGULAR, User::TYPE_ADMIN], + 'isActive' => true, + Attribute::ID . '!=' => $fetchedUserIds, + ]) + ->where( + Condition::in( + Expression::column(Attribute::ID), + SelectBuilder::create() + ->from(Team::RELATIONSHIP_TEAM_USER) + ->select('userId') + ->where(['teamId' => $groupFolder->getTeams()->getIdList()]) + ->build() + ) + ) + ->find(); + + foreach ($users as $user) { + $added = true; + + $email->addUserId($user->getId()); + } + + return $added; + } + + private function scheduleAclJob(Email $email): void + { + // Need to update acl fields (users and teams) + // of notes related to the duplicate email. + // To grant access to the user who received the email. + + $dt = new DateTime(); + $dt->modify('+' . self::PROCESS_ACL_DELAY_PERIOD); + + $this->jobSchedulerFactory + ->create() + ->setClassName(ProcessNoteAcl::class) + ->setData( + JobData::create(['notify' => true]) + ->withTargetId($email->getId()) + ->withTargetType(Email::ENTITY_TYPE) + ) + ->setQueue(QueueName::Q1) + ->setTime($dt) + ->schedule(); + } +} diff --git a/application/Espo/Core/Mail/Importer/DefaultParentFinder.php b/application/Espo/Core/Mail/Importer/DefaultParentFinder.php new file mode 100644 index 0000000..f69485d --- /dev/null +++ b/application/Espo/Core/Mail/Importer/DefaultParentFinder.php @@ -0,0 +1,271 @@ +. + * + * 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\Mail\Importer; + +use Espo\Core\Mail\Message; +use Espo\Core\Name\Field; +use Espo\Core\Templates\Entities\Company; +use Espo\Core\Templates\Entities\Person; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Email; +use Espo\Entities\EmailAddress; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\Contact; +use Espo\Modules\Crm\Entities\Lead; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Repositories\EmailAddress as EmailAddressRepository; + +class DefaultParentFinder implements ParentFinder +{ + /** @var string[] */ + private array $entityTypeList; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + private Metadata $metadata + ) { + $this->entityTypeList = $this->entityManager + ->getDefs() + ->getEntity(Email::ENTITY_TYPE) + ->getField(Field::PARENT) + ->getParam('entityList') ?? []; + } + + public function find(Email $email, Message $message): ?Entity + { + return + $this->getByReferences($message) ?? + $this->getFromReplied($email) ?? + $this->getByFromAddress($email) ?? + $this->getByReplyToAddress($email) ?? + $this->getByToAddress($email); + } + + private function isEntityTypeAllowed(string $entityType): bool + { + return in_array($entityType, $this->entityTypeList); + } + + private function getByFromAddress(Email $email): ?Entity + { + $from = $email->getFromAddress(); + + if (!$from) { + return null; + } + + return $this->getByAddress($from); + } + + private function getByReplyToAddress(Email $email): ?Entity + { + $list = $email->getReplyToAddressList(); + + if ($list === []) { + return null; + } + + return $this->getByAddress($list[0]); + } + + private function getByToAddress(Email $email): ?Entity + { + $list = $email->getToAddressList(); + + if ($list === []) { + return null; + } + + return $this->getByAddress($list[0]); + } + + private function getByAddress(string $emailAddress): ?Entity + { + $contact = $this->entityManager + ->getRDBRepositoryByClass(Contact::class) + ->where(['emailAddress' => $emailAddress]) + ->findOne(); + + if ($contact) { + if ( + !$this->config->get('b2cMode') && + $this->isEntityTypeAllowed(Account::ENTITY_TYPE) && + $contact->getAccount() + ) { + return $contact->getAccount(); + } + + if ($this->isEntityTypeAllowed(Contact::ENTITY_TYPE)) { + return $contact; + } + } + + if ($this->isEntityTypeAllowed(Account::ENTITY_TYPE)) { + $account = $this->entityManager + ->getRDBRepositoryByClass(Account::class) + ->where(['emailAddress' => $emailAddress]) + ->findOne(); + + if ($account) { + return $account; + } + } + + if ($this->isEntityTypeAllowed(Lead::ENTITY_TYPE)) { + $lead = $this->entityManager + ->getRDBRepositoryByClass(Lead::class) + ->where(['emailAddress' => $emailAddress]) + ->findOne(); + + if ($lead) { + return $lead; + } + } + + $entityTypeList = array_filter( + $this->entityTypeList, + function ($entityType) { + return + !in_array( + $entityType, + [Account::ENTITY_TYPE, Contact::ENTITY_TYPE, Lead::ENTITY_TYPE] + ) && + in_array( + $this->metadata->get(['scopes', $entityType, 'type']), + [Company::TEMPLATE_TYPE, Person::TEMPLATE_TYPE] + ); + } + ); + + /** @var EmailAddressRepository $emailAddressRepository */ + $emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE); + + foreach ($entityTypeList as $entityType) { + $entity = $emailAddressRepository->getEntityByAddress($emailAddress, $entityType); + + if ($entity) { + return $entity; + } + } + + return null; + } + + private function getFromReplied(Email $email): ?Entity + { + $replied = $email->getReplied(); + + return $replied?->getParent(); + } + + private function getByReferences(Message $message): ?Entity + { + $references = $message->getHeader('References'); + + if (!$references) { + return null; + } + + $delimiter = strpos($references, '>,') ? ',' : ' '; + + foreach (explode($delimiter, $references) as $reference) { + $reference = str_replace(['/', '@'], ' ', trim(trim($reference), '<>')); + + $parent = $this->getByReferencesItem($reference); + + if ($parent) { + return $parent; + } + } + + return null; + } + + private function getByReferencesItem(string $reference): ?Entity + { + $parentType = null; + $parentId = null; + $number = null; + $emailSent = PHP_INT_MAX; + + $n = sscanf($reference, '%s %s %d %d espo', $parentType, $parentId, $emailSent, $number); + + if ($n !== 4) { + $n = sscanf($reference, '%s %s %d %d espo-system', $parentType, $parentId, $emailSent, $number); + } + + if ($n !== 4 || $emailSent >= time()) { + return null; + } + + if (!$parentType || !$parentId) { + return null; + } + + if (!is_string($parentType) || !is_string($parentId)) { + return null; + } + + if (!$this->entityManager->hasRepository($parentType)) { + return null; + } + + $parent = $this->entityManager->getEntityById($parentType, $parentId); + + if ($parent instanceof Lead) { + return $this->getFromLead($parent) ?? $parent; + } + + return $parent; + } + + private function getFromLead(Lead $lead): ?Entity + { + if ($lead->getStatus() !== Lead::STATUS_CONVERTED) { + return null; + } + + if ($lead->getCreatedAccount()) { + return $lead->getCreatedAccount(); + } + + if ( + $this->config->get('b2cMode') && + $lead->getCreatedContact() + ) { + return $lead->getCreatedContact(); + } + + return null; + } +} diff --git a/application/Espo/Core/Mail/Importer/DuplicateFinder.php b/application/Espo/Core/Mail/Importer/DuplicateFinder.php new file mode 100644 index 0000000..9aa1064 --- /dev/null +++ b/application/Espo/Core/Mail/Importer/DuplicateFinder.php @@ -0,0 +1,41 @@ +. + * + * 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\Mail\Importer; + +use Espo\Core\Mail\Message; +use Espo\Entities\Email; + +/** + * Finds an existing duplicate of an email being imported. + */ +interface DuplicateFinder +{ + public function find(Email $email, Message $message): ?Email; +} diff --git a/application/Espo/Core/Mail/Importer/EmailSaver.php b/application/Espo/Core/Mail/Importer/EmailSaver.php new file mode 100644 index 0000000..f29460d --- /dev/null +++ b/application/Espo/Core/Mail/Importer/EmailSaver.php @@ -0,0 +1,85 @@ +. + * + * 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\Mail\Importer; + +use Espo\Entities\Email; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use PDOException; + +/** + * @internal + */ +class EmailSaver +{ + private const int SAVE_RETRY_COUNT = 2; + + public function __construct( + private EntityManager $entityManager, + ) {} + + public function save(Email $email): void + { + for ($i = 0; $i < self::SAVE_RETRY_COUNT; $i ++) { + try { + $this->saveInternal($email); + } catch (PDOException $e) { + $code = (int) ($e->errorInfo[1] ?? '0'); + + // Handles a snapshot isolation conflict. + if ($code === 1020) { + if ($i === self::SAVE_RETRY_COUNT - 1) { + throw $e; + } + + continue; + } + + throw $e; + } + + break; + } + } + + private function saveInternal(Email $email): void + { + $this->entityManager->getTransactionManager()->run(function () use ($email) { + $this->entityManager + ->getRDBRepositoryByClass(Email::class) + ->forUpdate() + ->select(Attribute::ID) + ->where([Attribute::ID => $email->getId()]) + ->findOne(); + + $this->entityManager->saveEntity($email, [Email::SAVE_OPTION_IS_BEING_IMPORTED => true]); + }); + } +} diff --git a/application/Espo/Core/Mail/Importer/ParentFinder.php b/application/Espo/Core/Mail/Importer/ParentFinder.php new file mode 100644 index 0000000..fca66a3 --- /dev/null +++ b/application/Espo/Core/Mail/Importer/ParentFinder.php @@ -0,0 +1,42 @@ +. + * + * 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\Mail\Importer; + +use Espo\Entities\Email; +use Espo\Core\Mail\Message; +use Espo\ORM\Entity; + +/** + * Finds a parent record for an email being imported. + */ +interface ParentFinder +{ + public function find(Email $email, Message $message): ?Entity; +} diff --git a/application/Espo/Core/Mail/Message.php b/application/Espo/Core/Mail/Message.php new file mode 100644 index 0000000..37f6673 --- /dev/null +++ b/application/Espo/Core/Mail/Message.php @@ -0,0 +1,82 @@ +. + * + * 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\Mail; + +use Espo\Core\Mail\Exceptions\ImapError; +use Espo\Core\Mail\Message\Part; + +interface Message +{ + /** + * Whether has a specific header. + */ + public function hasHeader(string $name): bool; + + /** + * Get a specific header. + */ + public function getHeader(string $attribute): ?string; + + /** + * Get a raw header part. + */ + public function getRawHeader(): string; + + /** + * Get a raw content part. + * + * @throws ImapError + */ + public function getRawContent(): string; + + /** + * Get a full raw message. + * + * @throws ImapError + */ + public function getFullRawContent(): string; + + /** + * Get flags. + * + * @return string[] + */ + public function getFlags(): array; + + /** + * Whether contents is fetched. + */ + public function isFetched(): bool; + + /** + * @return Part[] + */ + public function getPartList(): array; +} diff --git a/application/Espo/Core/Mail/Message/MailMimeParser/Part.php b/application/Espo/Core/Mail/Message/MailMimeParser/Part.php new file mode 100644 index 0000000..17a1727 --- /dev/null +++ b/application/Espo/Core/Mail/Message/MailMimeParser/Part.php @@ -0,0 +1,79 @@ +. + * + * 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\Mail\Message\MailMimeParser; + +use Espo\Core\Mail\Message\Part as PartInterface; + +use ZBateson\MailMimeParser\Message\IMessagePart; + +class Part implements PartInterface +{ + private IMessagePart $part; + + public function __construct(IMessagePart $part) + { + $this->part = $part; + } + + public function getContentType(): ?string + { + return $this->part->getContentType(); + } + + public function hasContent(): bool + { + return $this->part->hasContent(); + } + + public function getContent(): ?string + { + return $this->part->getContent(); + } + + public function getContentId(): ?string + { + return $this->part->getContentId(); + } + + public function getCharset(): ?string + { + return $this->part->getCharset(); + } + + public function getContentDisposition(): ?string + { + return $this->part->getContentDisposition(); + } + + public function getFilename(): ?string + { + return $this->part->getFilename(); + } +} diff --git a/application/Espo/Core/Mail/Message/Part.php b/application/Espo/Core/Mail/Message/Part.php new file mode 100644 index 0000000..782dcd5 --- /dev/null +++ b/application/Espo/Core/Mail/Message/Part.php @@ -0,0 +1,47 @@ +. + * + * 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\Mail\Message; + +interface Part +{ + public function getContentType(): ?string; + + public function hasContent(): bool; + + public function getContent(): ?string; + + public function getContentId(): ?string; + + public function getCharset(): ?string; + + public function getContentDisposition(): ?string; + + public function getFilename(): ?string; +} diff --git a/application/Espo/Core/Mail/MessageWrapper.php b/application/Espo/Core/Mail/MessageWrapper.php new file mode 100644 index 0000000..26a1c17 --- /dev/null +++ b/application/Espo/Core/Mail/MessageWrapper.php @@ -0,0 +1,155 @@ +. + * + * 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\Mail; + +use Espo\Core\Mail\Account\Storage; +use Espo\Core\Mail\Exceptions\ImapError; +use Espo\Core\Mail\Message\Part; + +use RuntimeException; + +class MessageWrapper implements Message +{ + private ?string $rawHeader = null; + private ?string $rawContent = null; + + /** @var ?string[] */ + private ?array $flagList = null; + + /** + * @throws ImapError + */ + public function __construct( + private int $id, + private ?Storage $storage = null, + private ?Parser $parser = null, + private ?string $fullRawContent = null, + private bool $peek = false, + ) { + if ($storage) { + $data = $storage->getHeaderAndFlags($id); + + $this->rawHeader = $data['header']; + $this->flagList = $data['flags']; + } + + if ( + !$storage && + $this->fullRawContent + ) { + $rawHeader = null; + $rawBody = null; + + if (str_contains($this->fullRawContent, "\r\n\r\n")) { + [$rawHeader, $rawBody] = explode("\r\n\r\n", $this->fullRawContent, 2); + } else if (str_contains($this->fullRawContent, "\n\n")) { + [$rawHeader, $rawBody] = explode("\n\n", $this->fullRawContent, 2); + } + + $this->rawHeader = $rawHeader; + $this->rawContent = $rawBody; + } + } + + public function getRawHeader(): string + { + return $this->rawHeader ?? ''; + } + + public function getParser(): ?Parser + { + return $this->parser; + } + + public function hasHeader(string $name): bool + { + if (!$this->parser) { + throw new RuntimeException(); + } + + return $this->parser->hasHeader($this, $name); + } + + public function getHeader(string $attribute): ?string + { + if (!$this->parser) { + throw new RuntimeException(); + } + + return $this->parser->getHeader($this, $attribute); + } + + public function getRawContent(): string + { + if (is_null($this->rawContent)) { + if (!$this->storage) { + throw new RuntimeException(); + } + + $this->rawContent = $this->storage->getRawContent($this->id, $this->peek); + } + + return $this->rawContent ?? ''; + } + + public function getFullRawContent(): string + { + if ($this->fullRawContent) { + return $this->fullRawContent; + } + + return $this->getRawHeader() . "\n" . $this->getRawContent(); + } + + /** + * @return string[] + */ + public function getFlags(): array + { + return $this->flagList ?? []; + } + + public function isFetched(): bool + { + return (bool) $this->rawHeader; + } + + /** + * @return Part[] + */ + public function getPartList(): array + { + if (!$this->parser) { + throw new RuntimeException(); + } + + return $this->parser->getPartList($this); + } +} diff --git a/application/Espo/Core/Mail/Parser.php b/application/Espo/Core/Mail/Parser.php new file mode 100644 index 0000000..f1e563b --- /dev/null +++ b/application/Espo/Core/Mail/Parser.php @@ -0,0 +1,67 @@ +. + * + * 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\Mail; + +use Espo\Entities\Email; +use Espo\Entities\Attachment; +use Espo\Core\Mail\Message\Part; + +use stdClass; + +interface Parser +{ + public function hasHeader(Message $message, string $name): bool; + + public function getHeader(Message $message, string $name): ?string; + + public function getMessageId(Message $message): ?string; + + public function getAddressNameMap(Message $message): stdClass; + + /** + * @return ?object{address: string, name: string} + */ + public function getAddressData(Message $message, string $type): ?object; + + /** + * @return string[] + */ + public function getAddressList(Message $message, string $type): array; + + /** + * @return Attachment[] A list of inline attachments. + */ + public function getInlineAttachmentList(Message $message, Email $email): array; + + /** + * @return Part[] + */ + public function getPartList(Message $message): array; +} diff --git a/application/Espo/Core/Mail/ParserFactory.php b/application/Espo/Core/Mail/ParserFactory.php new file mode 100644 index 0000000..0b4d28e --- /dev/null +++ b/application/Espo/Core/Mail/ParserFactory.php @@ -0,0 +1,46 @@ +. + * + * 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\Mail; + +use Espo\Core\InjectableFactory; +use Espo\Core\Mail\Parsers\MailMimeParser; + +class ParserFactory +{ + protected const DEFAULT_PARSER_CLASS_NAME = MailMimeParser::class; + + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function create(): Parser + { + return $this->injectableFactory->create(self::DEFAULT_PARSER_CLASS_NAME); + } +} diff --git a/application/Espo/Core/Mail/Parsers/MailMimeParser.php b/application/Espo/Core/Mail/Parsers/MailMimeParser.php new file mode 100644 index 0000000..2ec7f40 --- /dev/null +++ b/application/Espo/Core/Mail/Parsers/MailMimeParser.php @@ -0,0 +1,485 @@ +. + * + * 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\Mail\Parsers; + +use Espo\Entities\Email; +use Espo\Entities\Attachment; +use Espo\ORM\EntityManager; +use Espo\Core\Mail\Message; +use Espo\Core\Mail\Parser; +use Espo\Core\Mail\Message\Part; +use Espo\Core\Mail\Message\MailMimeParser\Part as WrapperPart; + +use ZBateson\MailMimeParser\Header\AddressHeader; +use ZBateson\MailMimeParser\Header\HeaderConsts; +use ZBateson\MailMimeParser\IMessage; +use ZBateson\MailMimeParser\MailMimeParser as WrappeeParser; +use ZBateson\MailMimeParser\Message\MessagePart; +use ZBateson\MailMimeParser\Message\MimePart; + +use stdClass; + +/** + * An adapter for MailMimeParser library. + */ +class MailMimeParser implements Parser +{ + /** @var array */ + private array $extMimeTypeMap = [ + 'jpg' => 'image/jpg', + 'jpeg' => 'image/jpeg', + '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; + + private const FIELD_BODY = 'body'; + private const FIELD_ATTACHMENTS = 'attachments'; + + private const DISPOSITION_INLINE = 'inline'; + + public const TYPE_MESSAGE_RFC822 = 'message/rfc822'; + public const TYPE_OCTET_STREAM = 'application/octet-stream'; + + /** @var array */ + private array $messageHash = []; + + public function __construct(private EntityManager $entityManager) + {} + + private function getParser(): WrappeeParser + { + if (!$this->parser) { + $this->parser = new WrappeeParser(); + } + + return $this->parser; + } + + private function loadContent(Message $message): void + { + $raw = $message->getFullRawContent(); + + $key = spl_object_hash($message); + + $this->messageHash[$key] = $this->getParser()->parse($raw, false); + } + + /** + * @return IMessage + */ + private function getMessage(Message $message) + { + $key = spl_object_hash($message); + + if (!array_key_exists($key, $this->messageHash)) { + $raw = $message->getRawHeader(); + + if (!$raw) { + $raw = $message->getFullRawContent(); + } + + $this->messageHash[$key] = $this->getParser()->parse($raw, false); + } + + return $this->messageHash[$key]; + } + + public function hasHeader(Message $message, string $name): bool + { + return $this->getMessage($message)->getHeaderValue($name) !== null; + } + + public function getHeader(Message $message, string $name): ?string + { + if (!$this->hasHeader($message, $name)) { + return null; + } + + return $this->getMessage($message)->getHeaderValue($name); + } + + public function getMessageId(Message $message): ?string + { + $messageId = $this->getHeader($message, 'Message-ID'); + + if (!$messageId) { + return null; + } + + if ($messageId[0] !== '<') { + $messageId = '<' . $messageId . '>'; + } + + return $messageId; + } + + public function getAddressNameMap(Message $message): stdClass + { + $map = (object) []; + + foreach (['from', 'to', 'cc', 'reply-To'] as $type) { + $header = $this->getMessage($message)->getHeader($type); + + if (!$header || !method_exists($header, 'getAddresses')) { + continue; + } + + /** @var AddressHeader $header */ + + $list = $header->getAddresses(); + + foreach ($list as $item) { + $address = $item->getEmail(); + $name = $item->getName(); + + if ($name && $address) { + $map->$address = $name; + } + } + } + + return $map; + } + + public function getAddressData(Message $message, string $type): ?object + { + $header = $this->getMessage($message)->getHeader($type); + + /** @var ?AddressHeader $header */ + + if ($header && method_exists($header, 'getAddresses')) { + foreach ($header->getAddresses() as $item) { + return (object) [ + 'address' => $item->getEmail(), + 'name' => $item->getName(), + ]; + } + } + + return null; + } + + /** + * @return string[] + */ + public function getAddressList(Message $message, string $type): array + { + $addressList = []; + + $header = $this->getMessage($message)->getHeader($type); + + /** @var ?AddressHeader $header */ + + if ($header && method_exists($header, 'getAddresses')) { + $list = $header->getAddresses(); + + foreach ($list as $address) { + $addressList[] = $address->getEmail(); + } + } + + return $addressList; + } + + /** + * @return Part[] + */ + public function getPartList(Message $message): array + { + $wrappeeList = $this->getMessage($message)->getChildParts(); + + $partList = []; + + foreach ($wrappeeList as $wrappee) { + $partList[] = new WrapperPart($wrappee); + } + + return $partList; + } + + /** + * @return Attachment[] + */ + public function getInlineAttachmentList(Message $message, Email $email): array + { + $inlineAttachmentList = []; + + $this->loadContent($message); + + $bodyPlain = ''; + $bodyHtml = ''; + + $htmlPartCount = $this->getMessage($message)->getHtmlPartCount(); + $textPartCount = $this->getMessage($message)->getTextPartCount(); + + if (!$htmlPartCount) { + $bodyHtml = $this->getMessage($message)->getHtmlContent(); + } + + if (!$textPartCount) { + $bodyPlain = $this->getMessage($message)->getTextContent(); + } + + for ($i = 0; $i < $htmlPartCount; $i++) { + if ($i) { + $bodyHtml .= "
"; + } + + $inlinePart = $this->getMessage($message)->getHtmlPart($i); + + $bodyHtml .= $inlinePart?->getContent() ?? ''; + } + + for ($i = 0; $i < $textPartCount; $i++) { + if ($i) { + $bodyPlain .= "\n"; + } + + $inlinePart = $this->getMessage($message)->getTextPart($i); + + $bodyPlain .= $inlinePart?->getContent() ?? ''; + } + + if ($bodyHtml) { + $email->setIsHtml(); + $email->setBody($bodyHtml); + + if ($bodyPlain) { + $email->setBodyPlain($bodyPlain); + } + } else { + $email->setIsHtml(false); + $email->setBody($bodyPlain); + $email->setBodyPlain($bodyPlain); + } + + if (!$email->getBody() && $email->hasBodyPlain()) { + $email->setBody($email->getBodyPlain()); + } + + $attachmentPartList = $this->getMessage($message)->getAllAttachmentParts(); + + $inlineAttachmentMap = []; + + foreach ($attachmentPartList as $i => $attachmentPart) { + if (!$attachmentPart instanceof MimePart) { + continue; + } + + $attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getNew(); + + $filename = $this->extractFileName($attachmentPart, $i); + $contentType = $this->detectAttachmentContentType($attachmentPart, $filename); + $disposition = $attachmentPart->getHeaderValue(HeaderConsts::CONTENT_DISPOSITION); + + if ($contentType) { + $contentType = strtolower($contentType); + } + + $attachment->setName($filename); + $attachment->setType($contentType); + + $content = ''; + + $binaryContentStream = $attachmentPart->getBinaryContentStream(); + + if ($binaryContentStream) { + $content = $binaryContentStream->getContents(); + } + + $contentId = $attachmentPart->getHeaderValue('Content-ID'); + + if ($contentId) { + $contentId = trim($contentId, '<>'); + } + + if ($disposition === self::DISPOSITION_INLINE) { + $attachment->setRole(Attachment::ROLE_INLINE_ATTACHMENT); + $attachment->setTargetField(self::FIELD_BODY); + } else { + $attachment->setRole(Attachment::ROLE_ATTACHMENT); + $attachment->setTargetField(self::FIELD_ATTACHMENTS); + } + + $attachment->setContents($content); + + $this->entityManager->saveEntity($attachment); + + if ($attachment->getRole() === Attachment::ROLE_ATTACHMENT) { + $email->addLinkMultipleId(self::FIELD_ATTACHMENTS, $attachment->getId()); + + if ($contentId) { + $inlineAttachmentMap[$contentId] = $attachment; + } + + continue; + } + + // Inline disposition. + + if ($contentId) { + $inlineAttachmentMap[$contentId] = $attachment; + + $inlineAttachmentList[] = $attachment; + + continue; + } + + // No ID found, fallback to attachment. + $attachment + ->setRole(Attachment::ROLE_ATTACHMENT) + ->setTargetField(self::FIELD_ATTACHMENTS); + + $this->entityManager->saveEntity($attachment); + + $email->addLinkMultipleId(self::FIELD_ATTACHMENTS, $attachment->getId()); + } + + $body = $email->getBody(); + + if ($body) { + foreach ($inlineAttachmentMap as $cid => $attachment) { + if (str_contains($body, 'cid:' . $cid)) { + $body = str_replace( + 'cid:' . $cid, + '?entryPoint=attachment&id=' . $attachment->getId(), + $body + ); + + continue; + } + + // Fallback to attachment. + if ($attachment->getRole() === Attachment::ROLE_INLINE_ATTACHMENT) { + $attachment + ->setRole(Attachment::ROLE_ATTACHMENT) + ->setTargetField(self::FIELD_ATTACHMENTS); + + $this->entityManager->saveEntity($attachment); + + $email->addLinkMultipleId(self::FIELD_ATTACHMENTS, $attachment->getId()); + } + } + + $email->setBody($body); + } + + /** @var ?MessagePart $textCalendarPart */ + $textCalendarPart = + $this->getMessage($message)->getAllPartsByMimeType('text/calendar')[0] ?? + $this->getMessage($message)->getAllPartsByMimeType('application/ics')[0] ?? + null; + + if ($textCalendarPart && $textCalendarPart->hasContent()) { + $email->set('icsContents', $textCalendarPart->getContent()); + } + + return $inlineAttachmentList; + } + + private function detectAttachmentContentType(MimePart $part, ?string $filename): ?string + { + $contentType = $part->getHeaderValue(HeaderConsts::CONTENT_TYPE); + + if ($contentType && strtolower($contentType) !== self::TYPE_OCTET_STREAM) { + return $contentType; + } + + if (!$filename) { + return null; + } + + $ext = $this->getAttachmentFilenameExtension($filename); + + if (!$ext) { + return null; + } + + return $this->extMimeTypeMap[$ext] ?? null; + } + + private function getAttachmentFilenameExtension(string $filename): ?string + { + if (!$filename) { + return null; + } + + $ext = pathinfo($filename, PATHINFO_EXTENSION); + + if ($ext === '') { + return null; + } + + return strtolower($ext); + } + + private function extractFileName(MimePart $attachmentPart, int $i): string + { + $filename = $attachmentPart->getHeaderParameter(HeaderConsts::CONTENT_DISPOSITION, 'filename'); + + if ($filename === null) { + $filename = $attachmentPart->getHeaderParameter(HeaderConsts::CONTENT_TYPE, 'name'); + } + + if ($filename === null && $attachmentPart->getContentType() === self::TYPE_MESSAGE_RFC822) { + $filename = 'message-' . ($i + 1) . '.eml'; + } + + if ($filename === null) { + $filename = 'unnamed'; + } + + return $filename; + } +} diff --git a/application/Espo/Core/Mail/Sender.php b/application/Espo/Core/Mail/Sender.php new file mode 100644 index 0000000..26a6d9e --- /dev/null +++ b/application/Espo/Core/Mail/Sender.php @@ -0,0 +1,594 @@ +. + * + * 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\Mail; + +use Espo\Core\FileStorage\Manager as FileStorageManager; +use Espo\Core\Mail\Exceptions\NoSmtp; +use Espo\Core\Mail\Sender\AttachmentContainer; +use Espo\Core\Mail\Sender\MessageContainer; +use Espo\Core\Mail\Sender\TransportPreparatorFactory; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\ORM\EntityCollection; +use Espo\Core\Field\DateTime; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Log; +use Espo\Core\Mail\Account\SendingAccountProvider; +use Espo\Core\Mail\Exceptions\SendingError; +use Espo\Entities\Attachment; +use Espo\Entities\Email; +use Espo\ORM\EntityManager; + +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mailer\Transport\TransportInterface; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email as Message; +use Symfony\Component\Mime\Part\DataPart; + +use Exception; +use LogicException; +use InvalidArgumentException; + +/** + * Sends emails. Builds parameters for sending. Should not be used directly. + */ +class Sender +{ + private ?TransportInterface $transport = null; + private bool $isGlobal = false; + /** @var array */ + private array $params = []; + /** @var array */ + private array $overrideParams = []; + private ?string $envelopeFromAddress = null; + /** @var ?iterable */ + private $attachmentList = null; + /** @var array{string, string}[] */ + private array $headers = []; + private ?MessageContainer $messageContainer = null; + + private const ATTACHMENT_ATTR_CONTENTS = 'contents'; + + public function __construct( + private Config $config, + private EntityManager $entityManager, + private Log $log, + private SendingAccountProvider $accountProvider, + private FileStorageManager $fileStorageManager, + private ConfigDataProvider $configDataProvider, + private TransportPreparatorFactory $transportPreparatorFactory, + ) { + $this->useGlobal(); + } + + private function resetParams(): void + { + $this->params = []; + $this->envelopeFromAddress = null; + $this->attachmentList = null; + $this->overrideParams = []; + $this->headers = []; + $this->messageContainer = null; + } + + /** + * With parameters. + * + * @param SenderParams|array $params + */ + public function withParams($params): self + { + if ($params instanceof SenderParams) { + $params = $params->toArray(); + } else if (!is_array($params)) { + throw new InvalidArgumentException(); + } + + $paramList = [ + 'fromAddress', + 'fromName', + 'replyToAddress', + 'replyToName', + ]; + + foreach (array_keys($params) as $key) { + if (!in_array($key, $paramList)) { + unset($params[$key]); + } + } + + $this->overrideParams = array_merge($this->overrideParams, $params); + + return $this; + } + + /** + * With specific SMTP parameters. + * + * @param SmtpParams|array $params + */ + public function withSmtpParams($params): self + { + if ($params instanceof SmtpParams) { + $params = $params->toArray(); + } else if (!is_array($params)) { + throw new InvalidArgumentException(); + } + return $this->useSmtp($params); + } + + /** + * With specific attachments. + * + * @param iterable $attachmentList + */ + public function withAttachments(iterable $attachmentList): self + { + $this->attachmentList = $attachmentList; + + return $this; + } + + /** + * With an envelope from address. + * + * @since 9.1.0 + */ + public function withEnvelopeFromAddress(string $fromAddress): void + { + $this->envelopeFromAddress = $fromAddress; + } + + /** + * @since 9.2.0 + * @internal + */ + public function withMessageContainer(MessageContainer $messageContainer): self + { + $this->messageContainer = $messageContainer; + + return $this; + } + + /** + * Add a header. + * + * @param string $name A header name. + * @param string $value A header value. + * @since 9.1.0 + */ + public function withAddedHeader(string $name, string $value): self + { + $this->headers[] = [$name, $value]; + + return $this; + } + + /** + * @param array $params + */ + private function useSmtp(array $params = []): self + { + $this->isGlobal = false; + + $this->applySmtp($params); + + return $this; + } + + private function useGlobal(): void + { + $this->params = []; + $this->isGlobal = true; + } + + /** + * @param array $params + */ + private function applySmtp(array $params = []): void + { + $this->params = $params; + + $smtpParams = SmtpParams::fromArray($params); + + $preparator = $this->transportPreparatorFactory->create($smtpParams); + + $this->transport = $preparator->prepare($smtpParams); + } + + /** + * @throws NoSmtp + */ + private function applyGlobal(): void + { + $systemAccount = $this->accountProvider->getSystem(); + + if (!$systemAccount) { + throw new NoSmtp("No system SMTP settings."); + } + + $smtpParams = $systemAccount->getSmtpParams(); + + if (!$smtpParams) { + throw new NoSmtp("No system SMTP settings."); + } + + $this->applySmtp($smtpParams->toArray()); + } + + /** + * Send an email. + * + * @throws SendingError + */ + public function send(Email $email): void + { + if ($this->isGlobal) { + $this->applyGlobal(); + } + + $message = new Message(); + + $params = array_merge($this->params, $this->overrideParams); + + $this->applyHeaders($email, $message); + $this->applyFrom($email, $message, $params); + $this->addRecipientAddresses($email, $message); + $this->applyReplyTo($email, $message, $params); + $this->applySubject($email, $message); + $this->applyBody($email, $message); + $this->applyMessageId($email, $message); + + if (!$this->transport) { + throw new LogicException(); + } + + $envelope = $this->prepareEnvelope($message); + + if ($this->messageContainer) { + $this->messageContainer->message = new Sender\Message($message); + } + + try { + $this->transport->send($message, $envelope); + } catch (Exception|TransportExceptionInterface $e) { + $this->resetParams(); + $this->useGlobal(); + + $this->handleException($e); + } + + $email + ->setStatus(Email::STATUS_SENT) + ->setDateSent(DateTime::createNow()) + ->setSendAt(null); + + $this->resetParams(); + $this->useGlobal(); + } + + /** + * @return DataPart[] + */ + private function getAttachmentParts(Email $email): array + { + /** @var EntityCollection $collection */ + $collection = $this->entityManager + ->getCollectionFactory() + ->create(Attachment::ENTITY_TYPE); + + if (!$email->isNew()) { + foreach ($email->getAttachments() as $attachment) { + $collection[] = $attachment; + } + } + + /** @var AttachmentContainer[] $containers */ + $containers = []; + + if ($this->attachmentList !== null) { + foreach ($this->attachmentList as $attachment) { + if ($attachment instanceof Attachment) { + $collection[] = $attachment; + } else { + $containers[] = $attachment; + } + } + } + + $list = []; + + foreach ($collection as $attachment) { + $contents = $this->getAttachmentContents($attachment); + + $part = new DataPart( + body: $contents, + filename: $attachment->getName() ?? '', + contentType: $attachment->getType(), + ); + + $list[] = $part; + } + + $this->prepareContainerParts($containers, $list); + + return $list; + } + + /** + * @return DataPart[] + */ + private function getInlineAttachmentParts(Email $email): array + { + $list = []; + + foreach ($email->getInlineAttachmentList() as $attachment) { + $contents = $this->getAttachmentContents($attachment); + + $part = (new DataPart($contents, null, $attachment->getType())) + ->asInline() + ->setContentId($attachment->getId() . '@espo'); + + $list[] = $part; + } + + return $list; + } + + /** + * @throws SendingError + */ + private function handleException(Exception|TransportExceptionInterface $e): never + { + if ($e instanceof TransportExceptionInterface) { + $message = "unknownError"; + + if ( + stripos($e->getMessage(), 'password') !== false || + stripos($e->getMessage(), 'credentials') !== false || + stripos($e->getMessage(), '5.7.8') !== false || + stripos($e->getMessage(), '5.7.3') !== false + ) { + $message = 'invalidCredentials'; + } + + $this->log->error("Email sending error: " . $e->getMessage(), ['exception' => $e]); + + throw new SendingError($message); + } + + throw new SendingError($e->getMessage()); + } + + private function addRecipientAddresses(Email $email, Message $message): void + { + $value = $email->get('to'); + + if ($value) { + foreach (explode(';', $value) as $address) { + $message->addTo(trim($address)); + } + } + + $value = $email->get('cc'); + + if ($value) { + foreach (explode(';', $value) as $address) { + $message->addCC(trim($address)); + } + } + + $value = $email->get('bcc'); + + if ($value) { + foreach (explode(';', $value) as $address) { + $message->addBCC(trim($address)); + } + } + + $value = $email->get('replyTo'); + + if ($value) { + foreach (explode(';', $value) as $address) { + $message->addReplyTo(trim($address)); + } + } + } + + /** + * @param array $params + * @throws NoSmtp + */ + private function applyFrom(Email $email, Message $message, array $params): void + { + $fromName = $params['fromName'] ?? $this->config->get('outboundEmailFromName'); + + $fromAddress = $email->get('from'); + + if ($fromAddress) { + $fromAddress = trim($fromAddress); + } else { + if ( + empty($params['fromAddress']) && + !$this->configDataProvider->getSystemOutboundAddress() + ) { + throw new NoSmtp('outboundEmailFromAddress is not specified in config.'); + } + + $fromAddress = $params['fromAddress'] ?? $this->configDataProvider->getSystemOutboundAddress(); + + $email->setFromAddress($fromAddress); + } + + $message->addFrom(new Address($fromAddress, $fromName ?? '')); + + $fromString = '<' . $fromAddress . '>'; + + if ($fromName) { + $fromString = $fromName . ' ' . $fromString; + } + + $email->set('fromString', $fromString); + + $message->sender($fromAddress); + } + + /** + * @param array $params + */ + private function applyReplyTo(Email $email, Message $message, array $params): void + { + $address = $params['replyToAddress'] ?? null; + $name = $params['replyToName'] ?? null; + + if (!$address) { + return; + } + + $message->replyTo(new Address($address, $name ?? '')); + + $email->setReplyToAddressList([$address]); + } + + private function applyMessageId(Email $email, Message $message): void + { + $messageId = $email->getMessageId(); + + if ( + !$messageId || + strlen($messageId) < 4 || + str_starts_with($messageId, 'dummy:') + ) { + $messageId = EmailSender::generateMessageId($email); + + $email->setMessageId('<' . $messageId . '>'); + + if ($email->hasId()) { + $this->entityManager->saveEntity($email, [SaveOption::SILENT => true]); + } + } else { + $messageId = substr($messageId, 1, strlen($messageId) - 2); + } + + $message->getHeaders()->addIdHeader('Message-ID', $messageId); + } + + private function applyBody(Email $email, Message $message): void + { + $message->text($email->getBodyPlainForSending()); + + if ($email->isHtml()) { + $message->html($email->getBodyForSending()); + } + + foreach ($this->getAttachmentParts($email) as $part) { + $message->addPart($part); + } + + foreach ($this->getInlineAttachmentParts($email) as $part) { + $message->addPart($part); + } + } + + private function applySubject(Email $email, Message $message): void + { + $message->subject($email->getSubject() ?? ''); + } + + private function applyHeaders(Email $email, Message $message): void + { + foreach ($this->headers as $item) { + $message->getHeaders()->addTextHeader($item[0], $item[1]); + } + + if ($email->isAutoReply() && !$message->getHeaders()->has('Auto-Submitted')) { + $message->getHeaders()->addTextHeader('Auto-Submitted', 'auto-replied'); + } + } + + private function prepareEnvelope(Message $message): ?Envelope + { + if (!$this->envelopeFromAddress) { + return null; + } + + $recipients = [ + ...$message->getTo(), + ...$message->getCc(), + ...$message->getBcc(), + ]; + + return new Envelope(new Address($this->envelopeFromAddress), $recipients); + } + + /** + * @todo Use stream. + * + */ + private function getAttachmentContents(Attachment $attachment): string + { + return $attachment->has(self::ATTACHMENT_ATTR_CONTENTS) ? + $attachment->get(self::ATTACHMENT_ATTR_CONTENTS) : + $this->fileStorageManager->getContents($attachment); + } + + /** + * @param AttachmentContainer[] $containers + * @param DataPart[] $list + */ + private function prepareContainerParts(array $containers, array &$list): void + { + foreach ($containers as $container) { + $attachment = $container->attachment; + + $contents = $this->getAttachmentContents($attachment); + + $part = new DataPart( + body: $contents, + filename: $attachment->getName() ?? '', + contentType: $attachment->getType(), + ); + + if ($container->inline) { + $part->asInline(); + } + + if ($container->contentTypeParams && $attachment->getType()) { + $part->getHeaders()->addParameterizedHeader( + name: 'Content-Type', + value: $attachment->getType(), + params: $container->contentTypeParams, + ); + } + + $list[] = $part; + } + } +} diff --git a/application/Espo/Core/Mail/Sender/AttachmentContainer.php b/application/Espo/Core/Mail/Sender/AttachmentContainer.php new file mode 100644 index 0000000..ea251bd --- /dev/null +++ b/application/Espo/Core/Mail/Sender/AttachmentContainer.php @@ -0,0 +1,51 @@ +. + * + * 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\Mail\Sender; + +use Espo\Entities\Attachment; + +/** + * @since 10.0.0 + * @internal + */ +readonly class AttachmentContainer +{ + /** + * Important: Use named parameters. Parameter order backward compatibility is not guaranteed. + * + * @param Attachment $attachment + * @param array $contentTypeParams + */ + public function __construct( + public Attachment $attachment, + public bool $inline = false, + public array $contentTypeParams = [], + ) {} +} diff --git a/application/Espo/Core/Mail/Sender/DefaultTransportPreparator.php b/application/Espo/Core/Mail/Sender/DefaultTransportPreparator.php new file mode 100644 index 0000000..e9787cf --- /dev/null +++ b/application/Espo/Core/Mail/Sender/DefaultTransportPreparator.php @@ -0,0 +1,119 @@ +. + * + * 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\Mail\Sender; + +use Espo\Core\Mail\SmtpParams; +use Espo\Core\Utils\Config; +use RuntimeException; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\Smtp\Auth\CramMd5Authenticator; +use Symfony\Component\Mailer\Transport\Smtp\Auth\LoginAuthenticator; +use Symfony\Component\Mailer\Transport\Smtp\Auth\PlainAuthenticator; +use Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransportFactory; +use Symfony\Component\Mailer\Transport\TransportInterface; + +class DefaultTransportPreparator implements TransportPreparator +{ + public function __construct( + private Config $config, + ) {} + + public function prepare(SmtpParams $smtpParams): TransportInterface + { + $localHostName = $this->config->get('smtpLocalHostName', gethostname()); + + // 'SSL' is treated as implicit SSL/TLS. 'TLS' is treated as STARTTLS. + // STARTTLS is the most common method. + $scheme = $smtpParams->getSecurity() === SmtpParams::SECURITY_SSL_TLS ? 'smtps' : 'smtp'; + + if ($smtpParams->getSecurity() === SmtpParams::SECURITY_START_TLS && !defined('OPENSSL_VERSION_NUMBER')) { + throw new RuntimeException("OpenSSL is not available."); + } + + $transport = (new EsmtpTransportFactory()) + ->create( + new Dsn( + scheme: $scheme, + host: $smtpParams->getServer(), + port: $smtpParams->getPort(), + ) + ); + + if (!$smtpParams->getSecurity() && $transport instanceof EsmtpTransport) { + $transport->setAutoTls(false); + } + + if (!$transport instanceof EsmtpTransport) { + throw new RuntimeException(); + } + + $transport->setLocalDomain($localHostName); + + $authMechanism = null; + + // @todo For xoauth, set authMechanism, username, password in handlers. + $connectionOptions = $smtpParams->getConnectionOptions() ?? []; + $authString = $connectionOptions['authString'] ?? null; + + if ($authString) { + $decodedAuthString = base64_decode($authString); + + /** @noinspection RegExpRedundantEscape */ + if (preg_match("/user=(.*?)\\\1auth=Bearer (.*?)\\\1\\\1/", $decodedAuthString, $matches) !== false) { + $username = $matches[1]; + $token = $matches[2]; + + $transport->setUsername($username); + $transport->setPassword($token); + } + + $authMechanism = SmtpParams::AUTH_MECHANISM_XOAUTH; + } else if ($smtpParams->useAuth()) { + $authMechanism = $smtpParams->getAuthMechanism() ?: SmtpParams::AUTH_MECHANISM_LOGIN; + + $transport->setUsername($smtpParams->getUsername() ?? ''); + $transport->setPassword($smtpParams->getPassword() ?? ''); + } + + if ($authMechanism === SmtpParams::AUTH_MECHANISM_LOGIN) { + $transport->setAuthenticators([new LoginAuthenticator()]); + } else if ($authMechanism === SmtpParams::AUTH_MECHANISM_CRAMMD5) { + $transport->setAuthenticators([new CramMd5Authenticator()]); + } else if ($authMechanism === SmtpParams::AUTH_MECHANISM_PLAIN) { + $transport->setAuthenticators([new PlainAuthenticator()]); + } else if ($authMechanism === SmtpParams::AUTH_MECHANISM_XOAUTH) { + $transport->setAuthenticators([new XOAuth2Authenticator()]); + } + + return $transport; + } +} diff --git a/application/Espo/Core/Mail/Sender/Message.php b/application/Espo/Core/Mail/Sender/Message.php new file mode 100644 index 0000000..719626f --- /dev/null +++ b/application/Espo/Core/Mail/Sender/Message.php @@ -0,0 +1,48 @@ +. + * + * 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\Mail\Sender; + +use Symfony\Component\Mime\Email; + +/** + * @since 9.2.0 + * @internal + */ +class Message +{ + public function __construct( + private Email $email, + ) {} + + public function toString(): string + { + return $this->email->toString(); + } +} diff --git a/application/Espo/Core/Mail/Sender/MessageContainer.php b/application/Espo/Core/Mail/Sender/MessageContainer.php new file mode 100644 index 0000000..89553d6 --- /dev/null +++ b/application/Espo/Core/Mail/Sender/MessageContainer.php @@ -0,0 +1,41 @@ +. + * + * 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\Mail\Sender; + +/** + * @since 9.2.0 + * @internal + */ +class MessageContainer +{ + public function __construct( + public ?Message $message = null, + ) {} +} diff --git a/application/Espo/Core/Mail/Sender/TransportPreparator.php b/application/Espo/Core/Mail/Sender/TransportPreparator.php new file mode 100644 index 0000000..67cef2d --- /dev/null +++ b/application/Espo/Core/Mail/Sender/TransportPreparator.php @@ -0,0 +1,41 @@ +. + * + * 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\Mail\Sender; + +use Espo\Core\Mail\SmtpParams; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * @since 9.1.0 + */ +interface TransportPreparator +{ + public function prepare(SmtpParams $smtpParams): TransportInterface; +} diff --git a/application/Espo/Core/Mail/Sender/TransportPreparatorFactory.php b/application/Espo/Core/Mail/Sender/TransportPreparatorFactory.php new file mode 100644 index 0000000..f64f47a --- /dev/null +++ b/application/Espo/Core/Mail/Sender/TransportPreparatorFactory.php @@ -0,0 +1,47 @@ +. + * + * 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\Mail\Sender; + +use Espo\Core\InjectableFactory; +use Espo\Core\Mail\SmtpParams; + +class TransportPreparatorFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function create(SmtpParams $smtpParams): TransportPreparator + { + $className = $smtpParams->getTransportPreparatorClassName() ?? DefaultTransportPreparator::class; + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/Mail/SenderParams.php b/application/Espo/Core/Mail/SenderParams.php new file mode 100644 index 0000000..c77dd7f --- /dev/null +++ b/application/Espo/Core/Mail/SenderParams.php @@ -0,0 +1,140 @@ +. + * + * 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\Mail; + +/** + * Sender parameters. + * + * Immutable. + */ +class SenderParams +{ + private ?string $fromAddress = null; + private ?string $fromName = null; + private ?string $replyToAddress = null; + private ?string $replyToName = null; + + /** @var string[] */ + private $paramList = [ + 'fromAddress', + 'fromName', + 'replyToAddress', + 'replyToName', + ]; + + public static function create(): self + { + return new self(); + } + + /** + * @return array + */ + public function toArray(): array + { + $params = []; + + foreach ($this->paramList as $name) { + if ($this->$name !== null) { + $params[$name] = $this->$name; + } + } + + return $params; + } + + /** + * @param array $params + */ + public static function fromArray(array $params): self + { + $obj = new self(); + + foreach ($obj->paramList as $name) { + if (array_key_exists($name, $params)) { + $obj->$name = $params[$name]; + } + } + + return $obj; + } + + public function getFromAddress(): ?string + { + return $this->fromAddress; + } + + public function getFromName(): ?string + { + return $this->fromName; + } + + public function getReplyToAddress(): ?string + { + return $this->replyToAddress; + } + + public function getReplyToName(): ?string + { + return $this->replyToName; + } + + public function withFromAddress(?string $fromAddress): self + { + $obj = clone $this; + $obj->fromAddress = $fromAddress; + + return $obj; + } + + public function withFromName(?string $fromName): self + { + $obj = clone $this; + $obj->fromName = $fromName; + + return $obj; + } + + public function withReplyToAddress(?string $replyToAddress): self + { + $obj = clone $this; + $obj->replyToAddress = $replyToAddress; + + return $obj; + } + + public function withReplyToName(?string $replyToName): self + { + $obj = clone $this; + $obj->replyToName = $replyToName; + + return $obj; + } +} diff --git a/application/Espo/Core/Mail/Smtp/Handler.php b/application/Espo/Core/Mail/Smtp/Handler.php new file mode 100644 index 0000000..8e45f9e --- /dev/null +++ b/application/Espo/Core/Mail/Smtp/Handler.php @@ -0,0 +1,37 @@ +. + * + * 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\Mail\Smtp; + +use Espo\Core\Mail\SmtpParams; + +interface Handler +{ + public function handle(SmtpParams $params, ?string $id): SmtpParams; +} diff --git a/application/Espo/Core/Mail/Smtp/HandlerProcessor.php b/application/Espo/Core/Mail/Smtp/HandlerProcessor.php new file mode 100644 index 0000000..f669e74 --- /dev/null +++ b/application/Espo/Core/Mail/Smtp/HandlerProcessor.php @@ -0,0 +1,61 @@ +. + * + * 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\Mail\Smtp; + +use Espo\Core\InjectableFactory; +use Espo\Core\Mail\SmtpParams; + +class HandlerProcessor +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + /** + * @param class-string $className + */ + public function handle(string $className, SmtpParams $params, ?string $id): SmtpParams + { + $handler = $this->injectableFactory->create($className); + + if ($handler instanceof Handler) { + return $handler->handle($params, $id); + } + + if (method_exists($handler, 'applyParams')) { + $raw = $params->toArray(); + + $handler->applyParams($id, $raw); + + return SmtpParams::fromArray($raw); + } + + return $params; + } +} diff --git a/application/Espo/Core/Mail/SmtpParams.php b/application/Espo/Core/Mail/SmtpParams.php new file mode 100644 index 0000000..988b4cc --- /dev/null +++ b/application/Espo/Core/Mail/SmtpParams.php @@ -0,0 +1,279 @@ +. + * + * 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\Mail; + +use Espo\Core\Mail\Sender\TransportPreparator; +use RuntimeException; +use SensitiveParameter; + +/** + * SMTP parameters. + * + * Immutable. + */ +class SmtpParams +{ + private ?string $fromAddress = null; + private ?string $fromName = null; + /** @var ?array */ + private ?array $connectionOptions = null; + private bool $auth = false; + private ?string $authMechanism = null; + private ?string $username = null; + private ?string $password = null; + private ?string $security = null; + /** @var ?class-string */ + private ?string $transportPreparatorClassName = null; + + public const AUTH_MECHANISM_LOGIN = 'login'; + public const AUTH_MECHANISM_CRAMMD5 = 'crammd5'; + public const AUTH_MECHANISM_PLAIN = 'plain'; + public const AUTH_MECHANISM_XOAUTH = 'xoauth'; + + public const string SECURITY_SSL_TLS = 'SSL'; + public const string SECURITY_START_TLS = 'TLS'; + + /** @var string[] */ + private array $paramList = [ + 'server', + 'port', + 'fromAddress', + 'fromName', + 'connectionOptions', + 'auth', + 'authMechanism', + 'username', + 'password', + 'security', + 'transportPreparatorClassName', + ]; + + public function __construct( + private string $server, + private int $port + ) {} + + public static function create(string $server, int $port): self + { + return new self($server, $port); + } + + /** + * @return array + */ + public function toArray(): array + { + $params = []; + + foreach ($this->paramList as $name) { + if ($this->$name !== null) { + $params[$name] = $this->$name; + } + } + + return $params; + } + + /** + * @param array $params + */ + public static function fromArray(array $params): self + { + $server = $params['server'] ?? null; + $port = $params['port'] ?? null; + $auth = $params['auth'] ?? false; + + if ($server === null) { + throw new RuntimeException("Empty server."); + } + + if ($port === null) { + throw new RuntimeException("Empty port."); + } + + $obj = new self($server, $port); + + $obj->auth = $auth; + + foreach ($obj->paramList as $name) { + if ($obj->$name !== null) { + continue; + } + + if (array_key_exists($name, $params)) { + $obj->$name = $params[$name]; + } + } + + return $obj; + } + + public function getServer(): string + { + return $this->server; + } + + public function getPort(): int + { + return $this->port; + } + + public function getFromAddress(): ?string + { + return $this->fromAddress; + } + + public function getFromName(): ?string + { + return $this->fromName; + } + + /** + * @return ?array + */ + public function getConnectionOptions(): ?array + { + return $this->connectionOptions; + } + + public function useAuth(): bool + { + return $this->auth; + } + + public function getAuthMechanism(): ?string + { + return $this->authMechanism; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getSecurity(): ?string + { + return $this->security; + } + + public function withFromAddress(?string $fromAddress): self + { + $obj = clone $this; + $obj->fromAddress = $fromAddress; + + return $obj; + } + + public function withFromName(?string $fromName): self + { + $obj = clone $this; + $obj->fromName = $fromName; + + return $obj; + } + + /** + * @param ?array $connectionOptions + */ + public function withConnectionOptions(?array $connectionOptions): self + { + $obj = clone $this; + $obj->connectionOptions = $connectionOptions; + + return $obj; + } + + public function withAuth(bool $auth = true): self + { + $obj = clone $this; + $obj->auth = $auth; + + return $obj; + } + + public function withAuthMechanism(?string $authMechanism): self + { + $obj = clone $this; + $obj->authMechanism = $authMechanism; + + return $obj; + } + + public function withUsername(?string $username): self + { + $obj = clone $this; + $obj->username = $username; + + return $obj; + } + + public function withPassword(#[SensitiveParameter] ?string $password): self + { + $obj = clone $this; + $obj->password = $password; + + return $obj; + } + + public function withSecurity(?string $security): self + { + $obj = clone $this; + $obj->security = $security; + + return $obj; + } + + /** + * @param ?class-string $transportPreparatorClassName + * @since 9.1.0. + * @noinspection PhpUnused + */ + public function withTransportPreparatorClassName(?string $transportPreparatorClassName): self + { + $obj = clone $this; + $obj->transportPreparatorClassName = $transportPreparatorClassName; + + return $obj; + } + + /** + * @return ?class-string + * @since 9.1.0. + */ + public function getTransportPreparatorClassName(): ?string + { + return $this->transportPreparatorClassName; + } +} diff --git a/application/Espo/Core/MassAction/Actions/MassConvertCurrency.php b/application/Espo/Core/MassAction/Actions/MassConvertCurrency.php new file mode 100644 index 0000000..94fedef --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassConvertCurrency.php @@ -0,0 +1,176 @@ +. + * + * 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\Table; +use Espo\Core\Acl; +use Espo\Core\Currency\ConfigDataProvider as CurrencyConfigDataProvider; +use Espo\Core\Currency\Rates as CurrencyRates; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +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\Core\ORM\Entity as CoreEntity; +use Espo\Core\ORM\EntityManager; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\Tools\Currency\Conversion\EntityConverterFactory; +use RuntimeException; +use Throwable; + +/** + * @noinspection PhpUnused + */ +class MassConvertCurrency implements MassAction +{ + public function __construct( + private QueryBuilder $queryBuilder, + private Acl $acl, + private EntityManager $entityManager, + private Metadata $metadata, + private CurrencyConfigDataProvider $configDataProvider, + private EntityConverterFactory $converterFactory, + private User $user, + private Log $log, + ) {} + + public function process(Params $params, Data $data): Result + { + $entityType = $params->getEntityType(); + + if (!$this->acl->checkScope($entityType, Table::ACTION_EDIT)) { + throw new Forbidden("No edit access for '{$entityType}'."); + } + + if ($this->acl->getPermissionLevel(Acl\Permission::MASS_UPDATE) !== Table::LEVEL_YES) { + throw new Forbidden("No mass-update permission."); + } + + $this->checkFieldAccess($entityType); + + $dataRaw = $data->getRaw(); + + if (empty($dataRaw->targetCurrency)) { + throw new BadRequest("No target currency."); + } + + if (isset($dataRaw->rates) && !is_object($dataRaw->rates)) { + throw new BadRequest(); + } + + $baseCurrency = $this->configDataProvider->getBaseCurrency(); + $targetCurrency = $dataRaw->targetCurrency; + + $rates = $this->getRatesFromData($data) ?? + $this->configDataProvider->getCurrencyRates(); + + if ($targetCurrency !== $baseCurrency && !$rates->hasRate($targetCurrency)) { + throw new BadRequest("Target currency rate is not specified."); + } + + $query = $this->queryBuilder->build($params); + + $collection = $this->entityManager + ->getRDBRepository($entityType) + ->clone($query) + ->sth() + ->find(); + + $ids = []; + $count = 0; + + $converter = $this->converterFactory->create($entityType); + + foreach ($collection as $entity) { + if (!$this->acl->checkEntity($entity, Table::ACTION_EDIT)) { + continue; + } + + if (!$entity instanceof CoreEntity) { + throw new RuntimeException("Only Core-Entity allowed."); + } + + try { + $converter->convert($entity, $targetCurrency, $rates); + } catch (Forbidden|BadRequest|Conflict $e) { + $this->log->info("Could not convert currency for {id}.", [ + 'id' => $entity->getId(), + 'exception' => $e, + ]); + } + + $this->entityManager->saveEntity($entity, [SaveOption::MODIFIED_BY_ID => $this->user->getId()]); + + $ids[] = $entity->getId(); + $count++; + } + + return new Result($count, $ids); + } + + private function getRatesFromData(Data $data): ?CurrencyRates + { + if ($data->get('rates') === null) { + return null; + } + + $baseCurrency = $this->configDataProvider->getBaseCurrency(); + $ratesArray = get_object_vars($data->get('rates')); + + $ratesArray[$baseCurrency] = 1.0; + + return CurrencyRates::fromAssoc($ratesArray, $baseCurrency); + } + + /** + * @throws Forbidden + */ + private function checkFieldAccess(string $entityType): void + { + /** @var string[] $requiredFieldList */ + $requiredFieldList = $this->metadata->get(['scopes', $entityType, 'currencyConversionAccessRequiredFieldList']); + + if ($requiredFieldList === null) { + return; + } + + foreach ($requiredFieldList as $field) { + if (!$this->acl->checkField($entityType, $field, Table::ACTION_EDIT)) { + throw new Forbidden("No edit access to field `$field`."); + } + } + } +} diff --git a/application/Espo/Core/MassAction/Actions/MassDelete.php b/application/Espo/Core/MassAction/Actions/MassDelete.php new file mode 100644 index 0000000..c47beb2 --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassDelete.php @@ -0,0 +1,120 @@ +. + * + * 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\ORM\Repository\Option\RemoveOption; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\Record\ActionHistory\Action; +use Espo\Core\Utils\Log; +use Espo\Entities\User; +use Espo\Core\Acl; +use Espo\Core\Exceptions\Forbidden; +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\Core\ORM\EntityManager; +use Espo\Core\Record\ServiceFactory; +use Exception; + +class MassDelete implements MassAction +{ + public function __construct( + private QueryBuilder $queryBuilder, + private Acl $acl, + private ServiceFactory $serviceFactory, + private EntityManager $entityManager, + private User $user, + private Log $log, + ) {} + + public function process(Params $params, Data $data): Result + { + $entityType = $params->getEntityType(); + + if (!$this->acl->check($entityType, Acl\Table::ACTION_DELETE)) { + throw new Forbidden("No delete access for '$entityType'."); + } + + if ( + !$params->hasIds() && + $this->acl->getPermissionLevel(Acl\Permission::MASS_UPDATE) !== Acl\Table::LEVEL_YES + ) { + throw new Forbidden("No mass-update permission."); + } + + $service = $this->serviceFactory->createForUser($entityType, $this->user); + + $repository = $this->entityManager->getRDBRepository($entityType); + + $query = $this->queryBuilder->build($params); + + $collection = $repository + ->clone($query) + ->sth() + ->find(); + + $ids = []; + + $count = 0; + + foreach ($collection as $entity) { + if (!$this->acl->checkEntityDelete($entity)) { + continue; + } + + try { + $repository->remove($entity, [ + RemoveOption::MASS_REMOVE => true, + RemoveOption::MODIFIED_BY_ID => $this->user->getId(), + SaveOption::MASS_UPDATE => true, // Legacy. + ]); + } catch (Exception $e) { + $this->log->info("Mass delete exception. Record: {id}.", [ + 'exception' => $e, + 'id' => $entity->getId(), + ]); + + continue; + } + + $id = $entity->getId(); + + $ids[] = $id; + + $count++; + + $service->processActionHistoryRecord(Action::DELETE, $entity); + } + + return new Result($count, $ids); + } +} diff --git a/application/Espo/Core/MassAction/Actions/MassFollow.php b/application/Espo/Core/MassAction/Actions/MassFollow.php new file mode 100644 index 0000000..c5f7470 --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassFollow.php @@ -0,0 +1,116 @@ +. + * + * 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\Tools\Stream\Service as StreamService; +use Espo\Core\Acl; +use Espo\Core\Exceptions\Forbidden; +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\Core\ORM\EntityManager; +use Espo\Entities\User; + +class MassFollow implements MassAction +{ + private QueryBuilder $queryBuilder; + private Acl $acl; + private StreamService $streamService; + private EntityManager $entityManager; + private User $user; + + public function __construct( + QueryBuilder $queryBuilder, + Acl $acl, + StreamService $streamService, + EntityManager $entityManager, + User $user + ) { + $this->queryBuilder = $queryBuilder; + $this->acl = $acl; + $this->streamService = $streamService; + $this->entityManager = $entityManager; + $this->user = $user; + } + + public function process(Params $params, Data $data): Result + { + $entityType = $params->getEntityType(); + + $passedUserId = $data->get('userId'); + + if ($passedUserId && !$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $userId = $passedUserId ?? $this->user->getId(); + + if (!$this->acl->check($entityType, Acl\Table::ACTION_STREAM)) { + throw new Forbidden("No stream access for '{$entityType}'."); + } + + $query = $this->queryBuilder->build($params); + + $collection = $this->entityManager + ->getRDBRepository($entityType) + ->clone($query) + ->sth() + ->find(); + + $ids = []; + + $count = 0; + + foreach ($collection as $entity) { + if ( + !$this->acl->checkEntityStream($entity) || + !$this->acl->checkEntityRead($entity) + ) { + continue; + } + + $followResult = $this->streamService->followEntity($entity, $userId); + + if (!$followResult) { + continue; + } + + /** @var string $id */ + $id = $entity->getId(); + + $ids[] = $id; + $count++; + } + + return new Result($count, $ids); + } +} diff --git a/application/Espo/Core/MassAction/Actions/MassLock.php b/application/Espo/Core/MassAction/Actions/MassLock.php new file mode 100644 index 0000000..70743d7 --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassLock.php @@ -0,0 +1,81 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Core/MassAction/Actions/MassRecalculateFormula.php b/application/Espo/Core/MassAction/Actions/MassRecalculateFormula.php new file mode 100644 index 0000000..3b50ffd --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassRecalculateFormula.php @@ -0,0 +1,85 @@ +. + * + * 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\Forbidden; +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\Core\ORM\EntityManager; +use Espo\Core\Utils\SystemUser; + +class MassRecalculateFormula implements MassAction +{ + public function __construct( + private QueryBuilder $queryBuilder, + private EntityManager $entityManager, + private SystemUser $systemUser, + private Acl $acl, + ) {} + + public function process(Params $params, Data $data): Result + { + if (!$this->acl->checkAdmin()) { + throw new Forbidden(); + } + + $entityType = $params->getEntityType(); + + $query = $this->queryBuilder->build($params); + + $collection = $this->entityManager + ->getRDBRepository($entityType) + ->clone($query) + ->sth() + ->find(); + + $ids = []; + + $count = 0; + + foreach ($collection as $entity) { + $this->entityManager->saveEntity($entity, [ + 'modifiedById' => $this->systemUser->getId(), + ]); + + /** @var string $id */ + $id = $entity->getId(); + + $ids[] = $id; + $count++; + } + + return new Result($count, $ids); + } +} diff --git a/application/Espo/Core/MassAction/Actions/MassUnfollow.php b/application/Espo/Core/MassAction/Actions/MassUnfollow.php new file mode 100644 index 0000000..a1778f2 --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassUnfollow.php @@ -0,0 +1,86 @@ +. + * + * 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\Tools\Stream\Service as StreamService; +use Espo\Core\Exceptions\Forbidden; +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\Core\ORM\EntityManager; +use Espo\Entities\User; + +class MassUnfollow implements MassAction +{ + public function __construct( + private QueryBuilder $queryBuilder, + private StreamService $streamService, + private EntityManager $entityManager, + private User $user + ) {} + + public function process(Params $params, Data $data): Result + { + $entityType = $params->getEntityType(); + + $passedUserId = $data->get('userId'); + + if ($passedUserId && !$this->user->isEffectiveAdmin()) { + throw new Forbidden(); + } + + $userId = $passedUserId ?? $this->user->getId(); + + $query = $this->queryBuilder->build($params); + + $collection = $this->entityManager + ->getRDBRepository($entityType) + ->clone($query) + ->sth() + ->find(); + + $ids = []; + + $count = 0; + + foreach ($collection as $entity) { + $this->streamService->unfollowEntity($entity, $userId); + + $id = $entity->getId(); + + $ids[] = $id; + $count++; + } + + return new Result($count, $ids); + } +} diff --git a/application/Espo/Core/MassAction/Actions/MassUnlock.php b/application/Espo/Core/MassAction/Actions/MassUnlock.php new file mode 100644 index 0000000..d8fea43 --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassUnlock.php @@ -0,0 +1,81 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Core/MassAction/Actions/MassUpdate.php b/application/Espo/Core/MassAction/Actions/MassUpdate.php new file mode 100644 index 0000000..c8e7871 --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassUpdate.php @@ -0,0 +1,50 @@ +. + * + * 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\Tools\MassUpdate\Processor; +use Espo\Tools\MassUpdate\Data as MassUpdateData; +use Espo\Core\MassAction\Params; +use Espo\Core\MassAction\Result; +use Espo\Core\MassAction\Data; +use Espo\Core\MassAction\MassAction; + +class MassUpdate implements MassAction +{ + public function __construct(private Processor $processor) + {} + + public function process(Params $params, Data $data): Result + { + $massUpdateData = MassUpdateData::fromMassActionData($data); + + return $this->processor->process($params, $massUpdateData); + } +} diff --git a/application/Espo/Core/MassAction/Api/GetStatus.php b/application/Espo/Core/MassAction/Api/GetStatus.php new file mode 100644 index 0000000..279957a --- /dev/null +++ b/application/Espo/Core/MassAction/Api/GetStatus.php @@ -0,0 +1,56 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Action; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Api\ResponseComposer; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\MassAction\Service; + +class GetStatus implements Action +{ + public function __construct(private Service $service) + {} + + public function process(Request $request): Response + { + $id = $request->getRouteParam('id'); + + if (!$id) { + throw new BadRequest(); + } + + $result = $this->service->getStatusData($id); + + return ResponseComposer::json($result); + } +} diff --git a/application/Espo/Core/MassAction/Api/PostProcess.php b/application/Espo/Core/MassAction/Api/PostProcess.php new file mode 100644 index 0000000..a271153 --- /dev/null +++ b/application/Espo/Core/MassAction/Api/PostProcess.php @@ -0,0 +1,157 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Action; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Api\ResponseComposer; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\MassAction\Params; +use Espo\Core\MassAction\Service; +use Espo\Core\MassAction\ServiceParams; +use Espo\Core\MassAction\ServiceResult; +use Espo\Core\Utils\Json; +use RuntimeException; +use stdClass; + +/** + * Processes mass actions. + */ +class PostProcess implements Action +{ + public function __construct(private Service $service) + {} + + public function process(Request $request): Response + { + $body = $request->getParsedBody(); + + $entityType = $body->entityType ?? null; + $action = $body->action ?? null; + $params = $body->params ?? null; + $data = $body->data ?? (object) []; + $isIdle = $body->idle ?? false; + + if ( + !is_string($entityType) || + !is_string($action) || + !$params instanceof stdClass || + !$entityType || + !$action + ) { + throw new BadRequest(); + } + + $rawParams = $this->prepareMassActionParams($params); + + try { + $massActionParams = Params::fromRaw($rawParams, $entityType); + } catch (RuntimeException $e) { + throw new BadRequest($e->getMessage()); + } + + $serviceParams = ServiceParams::create($massActionParams) + ->withIsIdle($isIdle); + + $result = $this->service->process( + $entityType, + $action, + $serviceParams, + $data + ); + + $result = $this->convertResult($result); + + return ResponseComposer::json($result); + } + + /** + * @return array + * @throws BadRequest + */ + private function prepareMassActionParams(stdClass $data): array + { + $where = $data->where ?? null; + $searchParams = $data->searchParams ?? $data->selectData ?? null; + $ids = $data->ids ?? null; + + if (!is_null($where) || !is_null($searchParams)) { + $params = []; + + if (!is_null($where)) { + $params['where'] = json_decode(Json::encode($where), true); + } + + if (!is_null($searchParams)) { + $params['searchParams'] = json_decode(Json::encode($searchParams), true); + } + + return $params; + } + + if (is_null($ids)) { + throw new BadRequest("Bad search params for mass action."); + } + + return ['ids' => $ids]; + } + + /** + * @throws Error + */ + private function convertResult(ServiceResult $serviceResult): stdClass + { + if (!$serviceResult->hasResult()) { + return (object) [ + 'id' => $serviceResult->getId(), + ]; + } + + $result = $serviceResult->getResult(); + + if (!$result) { + throw new Error(); + } + + $data = (object) []; + + if ($result->hasCount()) { + $data->count = $result->getCount(); + } + + if ($result->hasIds()) { + $data->ids = $result->getIds(); + } + + return $data; + } +} diff --git a/application/Espo/Core/MassAction/Api/PostSubscribe.php b/application/Espo/Core/MassAction/Api/PostSubscribe.php new file mode 100644 index 0000000..35e3677 --- /dev/null +++ b/application/Espo/Core/MassAction/Api/PostSubscribe.php @@ -0,0 +1,56 @@ +. + * + * 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\Api; + +use Espo\Core\Api\Action; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Api\ResponseComposer; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\MassAction\Service; + +class PostSubscribe implements Action +{ + public function __construct(private Service $service) + {} + + public function process(Request $request): Response + { + $id = $request->getRouteParam('id'); + + if (!$id) { + throw new BadRequest(); + } + + $this->service->subscribeToNotificationOnSuccess($id); + + return ResponseComposer::json(true); + } +} diff --git a/application/Espo/Core/MassAction/Data.php b/application/Espo/Core/MassAction/Data.php new file mode 100644 index 0000000..30f4373 --- /dev/null +++ b/application/Espo/Core/MassAction/Data.php @@ -0,0 +1,93 @@ +. + * + * 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; + +use Espo\Core\Utils\ObjectUtil; + +use stdClass; + +class Data +{ + private stdClass $data; + + private function __construct() + { + $this->data = (object) []; + } + + public function getRaw(): stdClass + { + return ObjectUtil::clone($this->data); + } + + /** + * Get an item value. + * + * @return mixed + */ + public function get(string $name) + { + return $this->getRaw()->$name ?? null; + } + + /** + * Has an item. + */ + public function has(string $name): bool + { + return property_exists($this->data, $name); + } + + public static function fromRaw(stdClass $data): self + { + $obj = new self(); + $obj->data = $data; + + return $obj; + } + + /** + * Clone with an item value. + * + * @param mixed $value + */ + public function with(string $name, $value): self + { + $obj = clone $this; + $obj->data->$name = $value; + + return $obj; + } + + public function __clone() + { + $this->data = ObjectUtil::clone($this->data); + } +} diff --git a/application/Espo/Core/MassAction/Jobs/Process.php b/application/Espo/Core/MassAction/Jobs/Process.php new file mode 100644 index 0000000..b808caa --- /dev/null +++ b/application/Espo/Core/MassAction/Jobs/Process.php @@ -0,0 +1,150 @@ +. + * + * 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\Jobs; + +use Espo\Core\Job\Job; +use Espo\Core\Job\Job\Data as JobData; +use Espo\Core\MassAction\MassActionFactory; +use Espo\Core\MassAction\Result; +use Espo\Core\Utils\Language; +use Espo\ORM\EntityManager; +use Espo\Entities\MassAction as MassActionEntity; +use Espo\Entities\Notification; +use Espo\Entities\User; + +use RuntimeException; +use Throwable; + +class Process implements Job +{ + public const string PARAM_USER_SCOPES = 'userScopes'; + + public function __construct( + private EntityManager $entityManager, + private MassActionFactory $factory, + private Language $language + ) {} + + public function run(JobData $data): void + { + $id = $data->getTargetId(); + + if ($id === null) { + throw new RuntimeException("ID not passed to the mass action job."); + } + + $entity = $this->entityManager->getRDBRepositoryByClass(MassActionEntity::class)->getById($id); + + if (!$entity) { + throw new RuntimeException("MassAction '$id' not found."); + } + + $user = $this->getUser($entity, $data); + + $params = $entity->getParams(); + + try { + $massAction = $this->factory->createForUser($entity->getAction(), $params->getEntityType(), $user); + + $this->setRunning($entity); + + $result = $massAction->process( + $params, + $entity->getData() + ); + } catch (Throwable $e) { + $this->setFailed($entity); + + throw new RuntimeException("Mass action job error: " . $e->getMessage()); + } + + $this->setSuccess($entity, $result); + + $this->entityManager->refreshEntity($entity); + + if ($entity->notifyOnFinish()) { + $this->notifyFinish($entity); + } + } + + private function notifyFinish(MassActionEntity $entity): void + { + $notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew(); + + $message = $this->language->translateLabel('massActionProcessed', 'messages'); + + $notification + ->setType(Notification::TYPE_MESSAGE) + ->setMessage($message) + ->setUserId($entity->getCreatedBy()->getId()); + + $this->entityManager->saveEntity($notification); + } + + private function setFailed(MassActionEntity $entity): void + { + $entity->setStatus(MassActionEntity::STATUS_FAILED); + + $this->entityManager->saveEntity($entity); + } + + private function setRunning(MassActionEntity $entity): void + { + $entity->setStatus(MassActionEntity::STATUS_RUNNING); + + $this->entityManager->saveEntity($entity); + } + + private function setSuccess(MassActionEntity $entity, Result $result): void + { + $entity + ->setStatus(MassActionEntity::STATUS_SUCCESS) + ->setProcessedCount($result->getCount()); + + $this->entityManager->saveEntity($entity); + } + + private function getUser(MassActionEntity $entity, JobData $data): User + { + $user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($entity->getCreatedBy()->getId()); + + if (!$user) { + throw new RuntimeException("MassAction '{$entity->getId()}', user not found."); + } + + $userScopes = $data->get(self::PARAM_USER_SCOPES); + + if (is_array($userScopes)) { + $user->setScopes($userScopes); + } + + return $user; + } +} diff --git a/application/Espo/Core/MassAction/MassAction.php b/application/Espo/Core/MassAction/MassAction.php new file mode 100644 index 0000000..f49966f --- /dev/null +++ b/application/Espo/Core/MassAction/MassAction.php @@ -0,0 +1,44 @@ +. + * + * 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; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Error; +use Espo\Core\Exceptions\Forbidden; + +interface MassAction +{ + /** + * @throws Forbidden + * @throws BadRequest + * @throws Error + */ + public function process(Params $params, Data $data): Result; +} diff --git a/application/Espo/Core/MassAction/MassActionFactory.php b/application/Espo/Core/MassAction/MassActionFactory.php new file mode 100644 index 0000000..ccafd03 --- /dev/null +++ b/application/Espo/Core/MassAction/MassActionFactory.php @@ -0,0 +1,174 @@ +. + * + * 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; + +use Espo\Entities\User; + +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Utils\Metadata; +use Espo\Core\InjectableFactory; +use Espo\Core\AclManager; +use Espo\Core\Acl; +use Espo\Core\Binding\BindingContainerBuilder; + +class MassActionFactory +{ + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory, + private AclManager $aclManager + ) {} + + /** + * @throws Forbidden + * @throws NotFound + */ + public function create(string $action, string $entityType): MassAction + { + $className = $this->getClassName($action, $entityType); + + if (!$className) { + throw new NotFound("Mass action '{$action}' not found."); + } + + if ($this->isDisabled($action, $entityType)) { + throw new Forbidden("Mass action '{$action}' is disabled for '{$entityType}'."); + } + + return $this->injectableFactory->create($className); + } + + public function createForUser(string $action, string $entityType, User $user): MassAction + { + $className = $this->getClassName($action, $entityType); + + if (!$className) { + throw new NotFound("Mass action '{$action}' not found."); + } + + $bindingContainer = BindingContainerBuilder::create() + ->bindInstance(User::class, $user) + ->bindInstance(Acl::class, $this->aclManager->createUserAcl($user)) + ->build(); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + /** + * @param array $with + * @throws NotFound + * @todo Remove. + * @deprecated As of v7.1. Use create. + */ + public function createWith(string $action, string $entityType, array $with): MassAction + { + $className = $this->getClassName($action, $entityType); + + if (!$className) { + throw new NotFound("Mass action '{$action}' not found."); + } + + return $this->injectableFactory->createWith($className, $with); + } + + /** + * @return ?class-string + */ + private function getClassName(string $action, string $entityType): ?string + { + /** @var ?class-string $className */ + $className = $this->getEntityTypeClassName($action, $entityType); + + if ($className) { + return $className; + } + + /** @var ?class-string */ + return $this->metadata->get( + ['app', 'massActions', $action, 'implementationClassName'] + ); + } + + /** + * @return ?class-string + */ + private function getEntityTypeClassName(string $action, string $entityType): ?string + { + /** @var ?class-string */ + return $this->metadata->get( + ['recordDefs', $entityType, 'massActions', $action, 'implementationClassName'] + ); + } + + private function isDisabled(string $action, string $entityType): bool + { + $actionsDisabled = $this->metadata + ->get(['recordDefs', $entityType, 'actionsDisabled']) ?? false; + + if ($actionsDisabled) { + return true; + } + + $massActionsDisabled = $this->metadata + ->get(['recordDefs', $entityType, 'massActionsDisabled']) ?? false; + + if ($massActionsDisabled) { + return true; + } + + if ($this->needsToBeAllowed($entityType)) { + if (!$this->isAllowed($action, $entityType)) { + return true; + } + } + + return $this->metadata + ->get(['recordDefs', $entityType, 'massActions', $action, 'disabled']) ?? false; + } + + private function needsToBeAllowed(string $entityType): bool + { + $isObject = $this->metadata->get(['scopes', $entityType, 'object']) ?? false; + + if (!$isObject) { + return true; + } + + return $this->metadata + ->get(['recordDefs', $entityType, 'notAllowedActionsDisabled']) ?? false; + } + + private function isAllowed(string $action, string $entityType): bool + { + return $this->metadata + ->get(['recordDefs', $entityType, 'massActions', $action, 'allowed']) ?? false; + } +} diff --git a/application/Espo/Core/MassAction/Params.php b/application/Espo/Core/MassAction/Params.php new file mode 100644 index 0000000..54514a5 --- /dev/null +++ b/application/Espo/Core/MassAction/Params.php @@ -0,0 +1,228 @@ +. + * + * 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; + +use Espo\Core\Select\SearchParams; + +use RuntimeException; + +/** + * Immutable. + */ +class Params +{ + private string $entityType; + /** @var ?string[] */ + private ?array $ids = null; + private ?SearchParams $searchParams = null; + + private function __construct() {} + + public function getEntityType(): string + { + return $this->entityType; + } + + /** + * @return string[] + * @throws RuntimeException + */ + public function getIds(): array + { + if (!$this->ids) { + throw new RuntimeException("No IDs."); + } + + return $this->ids; + } + + public function getSearchParams(): SearchParams + { + if (!$this->searchParams) { + throw new RuntimeException("No search params."); + } + + return $this->searchParams; + } + + public function hasIds(): bool + { + return !is_null($this->ids); + } + + /** + * @param string[] $ids + */ + public static function createWithIds(string $entityType, array $ids): self + { + return self::fromRaw([ + 'entityType' => $entityType, + 'ids' => $ids, + ]); + } + + public static function createWithSearchParams(string $entityType, SearchParams $searchParams): self + { + $obj = new self(); + + $obj->entityType = $entityType; + $obj->searchParams = $searchParams; + + return $obj; + } + + /** + * Create from raw params. + * + * @param array{ + * entityType?: string, + * where?: array>, + * ids?: ?string[], + * searchParams?: ?array, + * } $params + * @throws RuntimeException + */ + public static function fromRaw(array $params, ?string $entityType = null): self + { + /** @var array $params */ + + $obj = new self(); + + $passedEntityType = $entityType ?? $params['entityType'] ?? null; + + if (!$passedEntityType) { + throw new RuntimeException("No 'entityType'."); + } + + $obj->entityType = $passedEntityType; + + $where = $params['where'] ?? null; + $ids = $params['ids'] ?? null; + + $searchParams = $params['searchParams'] ?? $params['selectData'] ?? null; + + if ($where !== null && !is_array($where)) { + throw new RuntimeException("Bad 'where'."); + } + + if ($searchParams !== null && !is_array($searchParams)) { + throw new RuntimeException("Bad 'searchParams'."); + } + + if ($where !== null && $searchParams !== null) { + $searchParams['where'] = $where; + } + + if ($where !== null && $searchParams === null) { + $searchParams = [ + 'where' => $where, + ]; + } + + if ($searchParams !== null) { + if ($ids !== null) { + throw new RuntimeException("Can't combine 'ids' and search params."); + } + } else if ($ids !== null) { + if (!is_array($ids)) { + throw new RuntimeException("Bad 'ids'."); + } + + $obj->ids = $ids; + } else { + throw new RuntimeException("Bad mass action params."); + } + + if ($searchParams !== null) { + $actualSearchParams = $searchParams; + + unset($actualSearchParams['select']); + + $obj->searchParams = SearchParams::fromRaw($actualSearchParams); + } + + return $obj; + } + + /** + * @return void + */ + public function __clone() + { + if ($this->searchParams) { + $this->searchParams = clone $this->searchParams; + } + } + + /** + * @return array{ + * entityType: string, + * ids: ?string[], + * searchParams: string, + * } + */ + public function __serialize(): array + { + return [ + 'entityType' => $this->entityType, + 'ids' => $this->ids, + 'searchParams' => serialize($this->searchParams), + ]; + } + + /** + * @param array{ + * entityType: string, + * ids: ?string[], + * searchParams: string, + * } $data + */ + public function __unserialize(array $data): void + { + $this->entityType = $data['entityType']; + $this->ids = $data['ids']; + $this->searchParams = unserialize($data['searchParams'], [ + 'allowed_classes' => [SearchParams::class], + ]); + } + + /** + * @internal + */ + public static function fromSerializedRaw(string $raw): self + { + /** @var Params $params */ + $params = unserialize(base64_decode($raw), [ + 'allowed_classes' => [Params::class], + ]); + + return $params; + } +} diff --git a/application/Espo/Core/MassAction/QueryBuilder.php b/application/Espo/Core/MassAction/QueryBuilder.php new file mode 100644 index 0000000..565ec2c --- /dev/null +++ b/application/Espo/Core/MassAction/QueryBuilder.php @@ -0,0 +1,68 @@ +. + * + * 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; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\ORM\Query\Select; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\Entities\User; + +class QueryBuilder +{ + public function __construct(private SelectBuilderFactory $selectBuilderFactory, private User $user) + {} + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function build(Params $params): Select + { + $builder = $this->selectBuilderFactory + ->create() + ->from($params->getEntityType()) + ->forUser($this->user) + ->withStrictAccessControl(); + + if ($params->hasIds()) { + return $builder + ->buildQueryBuilder() + ->where([ + 'id' => $params->getIds(), + ]) + ->build(); + } + + return $builder + ->withSearchParams($params->getSearchParams()) + ->build(); + } +} diff --git a/application/Espo/Core/MassAction/Result.php b/application/Espo/Core/MassAction/Result.php new file mode 100644 index 0000000..5807e7a --- /dev/null +++ b/application/Espo/Core/MassAction/Result.php @@ -0,0 +1,104 @@ +. + * + * 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; + +use RuntimeException; + +/** + * Immutable. + */ +class Result +{ + private ?int $count = null; + /** @var ?string[] */ + private $ids = null; + + /** + * @param ?string[] $ids + */ + public function __construct(?int $count, ?array $ids = null) + { + $this->count = $count; + $this->ids = $ids; + } + + public function hasIds(): bool + { + return $this->ids !== null; + } + + public function hasCount(): bool + { + return $this->count !== null; + } + + /** + * @return string[] + */ + public function getIds(): array + { + if (!$this->hasIds()) { + throw new RuntimeException("No IDs."); + } + + /** @var string[] */ + return $this->ids; + } + + public function getCount(): int + { + if (!$this->hasCount()) { + throw new RuntimeException("No count."); + } + + /** @var int */ + return $this->count; + } + + public function withNoIds(): self + { + return new self($this->count); + } + + /** + * @deprecated + * @param array{ + * count?: ?int, + * ids?: ?string[], + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['count'] ?? null, + $data['ids'] ?? null + ); + } +} diff --git a/application/Espo/Core/MassAction/Service.php b/application/Espo/Core/MassAction/Service.php new file mode 100644 index 0000000..9af0d95 --- /dev/null +++ b/application/Espo/Core/MassAction/Service.php @@ -0,0 +1,167 @@ +. + * + * 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; + +use Espo\Core\Exceptions\BadRequest; +use Espo\ORM\EntityManager; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\ForbiddenSilent; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Acl; +use Espo\Core\MassAction\Jobs\Process; +use Espo\Core\Job\JobSchedulerFactory; +use Espo\Core\Job\Job\Data as JobData; +use Espo\Entities\User; +use Espo\Entities\MassAction as MassActionEntity; + +use stdClass; + +class Service +{ + public function __construct( + private MassActionFactory $factory, + private Acl $acl, + private JobSchedulerFactory $jobSchedulerFactory, + private EntityManager $entityManager, + private User $user + ) {} + + /** + * Perform a mass action. + * + * @throws Forbidden + * @throws NotFound + * @throws BadRequest + */ + public function process( + string $entityType, + string $action, + ServiceParams $serviceParams, + stdClass $data + ): ServiceResult { + + if (!$this->acl->checkScope($entityType)) { + throw new ForbiddenSilent(); + } + + $params = $serviceParams->getParams(); + + if ($serviceParams->isIdle()) { + if ($this->user->isPortal()) { + throw new Forbidden("Idle mass actions are not allowed for portal users."); + } + + return $this->schedule($entityType, $action, $params, $data); + } + + $massAction = $this->factory->create($action, $entityType); + + $result = $massAction->process( + $params, + Data::fromRaw($data) + ); + + if ($params->hasIds()) { + return ServiceResult::createWithResult($result); + } + + return ServiceResult::createWithResult( + $result->withNoIds() + ); + } + + /** + * @throws Forbidden + * @throws NotFound + */ + public function getStatusData(string $id): stdClass + { + /** @var ?MassActionEntity $entity */ + $entity = $this->entityManager->getEntityById(MassActionEntity::ENTITY_TYPE, $id); + + if (!$entity) { + throw new NotFound(); + } + + if ($entity->getCreatedBy()->getId() !== $this->user->getId()) { + throw new Forbidden(); + } + + return (object) [ + 'status' => $entity->getStatus(), + 'processedCount' => $entity->getProcessedCount(), + ]; + } + + /** + * @throws Forbidden + * @throws NotFound + */ + public function subscribeToNotificationOnSuccess(string $id): void + { + /** @var ?MassActionEntity $entity */ + $entity = $this->entityManager->getEntityById(MassActionEntity::ENTITY_TYPE, $id); + + if (!$entity) { + throw new NotFound(); + } + + if ($entity->getCreatedBy()->getId() !== $this->user->getId()) { + throw new Forbidden(); + } + + $entity->setNotifyOnFinish(); + + $this->entityManager->saveEntity($entity); + } + + private function schedule(string $entityType, string $action, Params $params, stdClass $data): ServiceResult + { + $entity = $this->entityManager->createEntity(MassActionEntity::ENTITY_TYPE, [ + 'entityType' => $entityType, + 'action' => $action, + // Additional encoding to handle null-character issue in PostgreSQL. + 'params' => base64_encode(serialize($params)), + 'data' => $data, + ]); + + $this->jobSchedulerFactory + ->create() + ->setClassName(Process::class) + ->setData( + JobData::create([Process::PARAM_USER_SCOPES => $this->user->getScopes()]) + ->withTargetId($entity->getId()) + ->withTargetType($entity->getEntityType()) + ) + ->schedule(); + + return ServiceResult::createWithId($entity->getId()); + } +} diff --git a/application/Espo/Core/MassAction/ServiceParams.php b/application/Espo/Core/MassAction/ServiceParams.php new file mode 100644 index 0000000..ebc15ee --- /dev/null +++ b/application/Espo/Core/MassAction/ServiceParams.php @@ -0,0 +1,64 @@ +. + * + * 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; + +/** + * Immutable. + */ +class ServiceParams +{ + private bool $isIdle = false; + + private function __construct(private Params $params) + {} + + public static function create(Params $params): self + { + return new self($params); + } + + public function getParams(): Params + { + return $this->params; + } + + public function isIdle(): bool + { + return $this->isIdle; + } + + public function withIsIdle(bool $isIdle = true): self + { + $obj = clone $this; + $obj->isIdle = $isIdle; + + return $obj; + } +} diff --git a/application/Espo/Core/MassAction/ServiceResult.php b/application/Espo/Core/MassAction/ServiceResult.php new file mode 100644 index 0000000..13d2bd9 --- /dev/null +++ b/application/Espo/Core/MassAction/ServiceResult.php @@ -0,0 +1,72 @@ +. + * + * 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; + +/** + * Immutable. + */ +class ServiceResult +{ + private ?Result $result = null; + private ?string $id = null; + + private function __construct() {} + + public function hasResult(): bool + { + return $this->result !== null; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getResult(): ?Result + { + return $this->result; + } + + public static function createWithId(string $id): self + { + $obj = new self; + $obj->id = $id; + + return $obj; + } + + public static function createWithResult(Result $result): self + { + $obj = new self; + $obj->result = $result; + + return $obj; + } +} diff --git a/application/Espo/Core/Name/Field.php b/application/Espo/Core/Name/Field.php new file mode 100644 index 0000000..c073600 --- /dev/null +++ b/application/Espo/Core/Name/Field.php @@ -0,0 +1,68 @@ +. + * + * 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\Name; + +class Field +{ + public const ID = 'id'; + public const NAME = 'name'; + public const CREATED_BY = 'createdBy'; + public const CREATED_AT = 'createdAt'; + public const MODIFIED_BY = 'modifiedBy'; + public const MODIFIED_AT = 'modifiedAt'; + public const STREAM_UPDATED_AT = 'streamUpdatedAt'; + public const ASSIGNED_USER = 'assignedUser'; + public const ASSIGNED_USERS = 'assignedUsers'; + public const COLLABORATORS = 'collaborators'; + public const TEAMS = 'teams'; + public const PARENT = 'parent'; + public const IS_FOLLOWED = 'isFollowed'; + public const FOLLOWERS = 'followers'; + public const IS_STARRED = 'isStarred'; + public const EMAIL_ADDRESS = 'emailAddress'; + public const PHONE_NUMBER = 'phoneNumber'; + public const VERSION_NUMBER = 'versionNumber'; + public const string IS_LOCKED = 'isLocked'; + + /** + * @since 10.0.0 + */ + public const string ACCOUNT = 'account'; + + /** + * @since 10.0.0 + */ + public const string PIPELINE = 'pipeline'; + + /** + * @since 10.0.0 + */ + public const string PIPELINE_STAGE = 'pipelineStage'; +} diff --git a/application/Espo/Core/Name/Link.php b/application/Espo/Core/Name/Link.php new file mode 100644 index 0000000..39c3b76 --- /dev/null +++ b/application/Espo/Core/Name/Link.php @@ -0,0 +1,39 @@ +. + * + * 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\Name; + +/** + * @since 9.2.0 + */ +class Link +{ + public const EMAIL_ADDRESSES = 'emailAddresses'; + public const PHONE_NUMBERS = 'phoneNumbers'; +} diff --git a/application/Espo/Core/Notification/AssignmentNotificator.php b/application/Espo/Core/Notification/AssignmentNotificator.php new file mode 100644 index 0000000..cac9340 --- /dev/null +++ b/application/Espo/Core/Notification/AssignmentNotificator.php @@ -0,0 +1,46 @@ +. + * + * 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\Notification; + +use Espo\ORM\Entity; +use Espo\Core\Notification\AssignmentNotificator\Params; + +/** + * Processes assignment notifications. Called after entity is saved. + * + * @template TEntity of Entity + */ +interface AssignmentNotificator +{ + /** + * @param TEntity $entity + */ + public function process(Entity $entity, Params $params): void; +} diff --git a/application/Espo/Core/Notification/AssignmentNotificator/Params.php b/application/Espo/Core/Notification/AssignmentNotificator/Params.php new file mode 100644 index 0000000..24b1ef6 --- /dev/null +++ b/application/Espo/Core/Notification/AssignmentNotificator/Params.php @@ -0,0 +1,119 @@ +. + * + * 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\Notification\AssignmentNotificator; + +/** + * Immutable. + */ +class Params +{ + /** @var array */ + private $options = []; + + private ?string $actionId = null; + + /** + * Whether an option is set. + */ + public function hasOption(string $option): bool + { + return array_key_exists($option, $this->options); + } + + /** + * Get an option. + * + * @return mixed + */ + public function getOption(string $option) + { + return $this->options[$option] ?? null; + } + + /** + * @return array + */ + public function getRawOptions(): array + { + return $this->options; + } + + /** + * @param array $options + */ + public function withRawOptions(array $options): self + { + $obj = clone $this; + + $obj->options = $options; + + return $obj; + } + + /** + * Clone with an option. + * + * @since 9.0.0 + */ + public function withOption(string $option, mixed $value): self + { + $obj = clone $this; + $obj->options[$option] = $value; + + return $obj; + } + + /** + * Create an instance. + */ + public static function create(): self + { + return new self(); + } + + /** + * @since 9.2.0 + */ + public function withActionId(?string $actionId): self + { + $obj = clone $this; + $obj->actionId = $actionId; + + return $obj; + } + + /** + * @since 9.2.0 + */ + public function getActionId(): ?string + { + return $this->actionId; + } +} diff --git a/application/Espo/Core/Notification/AssignmentNotificatorFactory.php b/application/Espo/Core/Notification/AssignmentNotificatorFactory.php new file mode 100644 index 0000000..3ff51b3 --- /dev/null +++ b/application/Espo/Core/Notification/AssignmentNotificatorFactory.php @@ -0,0 +1,96 @@ +. + * + * 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\Notification; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\ClassFinder; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; +use Espo\ORM\Repository\Util as RepositoryUtil; + +class AssignmentNotificatorFactory +{ + /** @var class-string> */ + protected string $defaultClassName = DefaultAssignmentNotificator::class; + + public function __construct( + private InjectableFactory $injectableFactory, + private ClassFinder $classFinder, + private Metadata $metadata + ) {} + + /** + * @template T of Entity + * @param class-string $className An entity class name. + * @return AssignmentNotificator + */ + public function createByClass(string $className): AssignmentNotificator + { + $entityType = RepositoryUtil::getEntityTypeByClass($className); + + /** @var AssignmentNotificator */ + return $this->create($entityType); + } + + /** + * @todo Change return type to AssignmentNotificator. + * + * @return AssignmentNotificator + */ + public function create(string $entityType): object // AssignmentNotificator + { + $className = $this->getClassName($entityType); + + return $this->injectableFactory->create($className); + } + + /** + * @return class-string> + */ + private function getClassName(string $entityType): string + { + /** @var ?class-string> $className1 */ + $className1 = $this->metadata->get(['notificationDefs', $entityType, 'assignmentNotificatorClassName']); + + if ($className1) { + return $className1; + } + + /* For backward compatibility. */ + /** @var ?class-string> $className2 */ + $className2 = $this->classFinder->find('Notificators', $entityType); + + if ($className2) { + return $className2; + } + + return $this->defaultClassName; + } +} diff --git a/application/Espo/Core/Notification/DefaultAssignmentNotificator.php b/application/Espo/Core/Notification/DefaultAssignmentNotificator.php new file mode 100644 index 0000000..9dc46ee --- /dev/null +++ b/application/Espo/Core/Notification/DefaultAssignmentNotificator.php @@ -0,0 +1,134 @@ +. + * + * 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\Notification; + +use Espo\Core\Field\LinkParent; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Entities\User; +use Espo\Entities\Notification; +use Espo\Core\Notification\AssignmentNotificator\Params; + +/** + * @implements AssignmentNotificator + */ +class DefaultAssignmentNotificator implements AssignmentNotificator +{ + public const OPTION_FORCE_ASSIGNED_USER = 'forceAssignedUser'; + + private const FIELD_ASSIGNED_USERS = Field::ASSIGNED_USERS; + private const ATTR_ASSIGNED_USER_ID = 'assignedUserId'; + + public function __construct( + protected User $user, + protected EntityManager $entityManager, + protected UserEnabledChecker $userChecker + ) {} + + public function process(Entity $entity, Params $params): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + if ( + $entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) && + !$params->getOption(self::OPTION_FORCE_ASSIGNED_USER) + ) { + $userIds = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS); + /** @var string[] $fetchedIds */ + $fetchedIds = $entity->getFetched(self::FIELD_ASSIGNED_USERS . 'Ids') ?? []; + + foreach ($userIds as $userId) { + if (in_array($userId, $fetchedIds)) { + continue; + } + + $this->processForUser($entity, $userId); + } + + return; + } + + if (!$entity->get(self::ATTR_ASSIGNED_USER_ID)) { + return; + } + + if (!$entity->isAttributeChanged(self::ATTR_ASSIGNED_USER_ID)) { + return; + } + + $assignedUserId = $entity->get(self::ATTR_ASSIGNED_USER_ID); + + $this->processForUser($entity, $assignedUserId); + } + + protected function processForUser(Entity $entity, string $assignedUserId): void + { + if (!$this->userChecker->checkAssignment($entity->getEntityType(), $assignedUserId)) { + return; + } + + if ($entity->hasAttribute('createdById') && $entity->hasAttribute('modifiedById')) { + $isSelfAssignment = $entity->isNew() ? + $assignedUserId === $entity->get('createdById') : + $assignedUserId === $entity->get('modifiedById'); + + if ($isSelfAssignment) { + return; + } + } + + $isSelfAssignment = $assignedUserId === $this->user->getId(); + + if ($isSelfAssignment) { + return; + } + + $notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew(); + + $notification + ->setType(Notification::TYPE_ASSIGN) + ->setUserId($assignedUserId) + ->setData([ + 'entityType' => $entity->getEntityType(), + 'entityId' => $entity->getId(), + 'relatedName' => $entity->get(Field::NAME), + 'isNew' => $entity->isNew(), + 'userId' => $this->user->getId(), + 'userName' => $this->user->getName(), + ]) + ->setRelated(LinkParent::fromEntity($entity)); + + $this->entityManager->saveEntity($notification); + } +} diff --git a/application/Espo/Core/Notification/EmailNotificationHandler.php b/application/Espo/Core/Notification/EmailNotificationHandler.php new file mode 100644 index 0000000..cb3f0ec --- /dev/null +++ b/application/Espo/Core/Notification/EmailNotificationHandler.php @@ -0,0 +1,46 @@ +. + * + * 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\Notification; + +use Espo\Core\Mail\SenderParams; +use Espo\ORM\Entity; +use Espo\Entities\User; +use Espo\Entities\Email; + +/** + * Handles a notification emails (supposed for adding CC, BCC addresses). + * Provides sender parameters for notification emails (e.g. setting Reply-To address). + */ +interface EmailNotificationHandler +{ + public function prepareEmail(Email $email, Entity $entity, User $user): void; + + public function getSenderParams(Entity $entity, User $user): ?SenderParams; +} diff --git a/application/Espo/Core/Notification/UserEnabledChecker.php b/application/Espo/Core/Notification/UserEnabledChecker.php new file mode 100644 index 0000000..7ed1ea1 --- /dev/null +++ b/application/Espo/Core/Notification/UserEnabledChecker.php @@ -0,0 +1,76 @@ +. + * + * 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\Notification; + +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\Config; +use Espo\Entities\Preferences; + +class UserEnabledChecker +{ + /** @var array */ + private $assignmentCache = []; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + ) {} + + public function checkAssignment(string $entityType, string $userId): bool + { + if (!in_array($entityType, $this->config->get('assignmentNotificationsEntityList', []))) { + return false; + } + + $key = $entityType . '_' . $userId; + + if (!array_key_exists($key, $this->assignmentCache)) { + $preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId); + + $isEnabled = false; + + $ignoreList = []; + + if ($preferences) { + $isEnabled = true; + + $ignoreList = $preferences->get('assignmentNotificationsIgnoreEntityTypeList') ?? []; + } + + if ($preferences && in_array($entityType, $ignoreList)) { + $isEnabled = false; + } + + $this->assignmentCache[$key] = $isEnabled; + } + + return $this->assignmentCache[$key]; + } +} diff --git a/application/Espo/Core/ORM/AttributeExtractorFactory.php b/application/Espo/Core/ORM/AttributeExtractorFactory.php new file mode 100644 index 0000000..fed23be --- /dev/null +++ b/application/Espo/Core/ORM/AttributeExtractorFactory.php @@ -0,0 +1,89 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +use Espo\ORM\Metadata as OrmMetadata; +use Espo\ORM\Value\AttributeExtractor; +use Espo\ORM\Value\AttributeExtractorFactory as AttributeExtractorFactoryInterface; + +use RuntimeException; + +/** + * @template T of object + * @implements AttributeExtractorFactoryInterface + */ +class AttributeExtractorFactory implements AttributeExtractorFactoryInterface +{ + public function __construct( + private Metadata $metadata, + private OrmMetadata $ormMetadata, + private InjectableFactory $injectableFactory + ) {} + + /** + * @return AttributeExtractor + */ + public function create(string $entityType, string $field): AttributeExtractor + { + $className = $this->getClassName($entityType, $field); + + if (!$className) { + throw new RuntimeException("Could not get AttributeExtractor for '{$entityType}.{$field}'."); + } + + return $this->injectableFactory->createWith($className, ['entityType' => $entityType]); + } + + /** + * @return ?class-string> + */ + private function getClassName(string $entityType, string $field): ?string + { + $fieldDefs = $this->ormMetadata + ->getDefs() + ->getEntity($entityType) + ->getField($field); + + $className = $fieldDefs->getParam('attributeExtractorClassName'); + + if ($className) { + /** @var class-string> */ + return $className; + } + + $type = $fieldDefs->getType(); + + /** @var ?class-string> */ + return $this->metadata->get(['fields', $type, 'attributeExtractorClassName']); + } +} diff --git a/application/Espo/Core/ORM/ClassNameProvider.php b/application/Espo/Core/ORM/ClassNameProvider.php new file mode 100644 index 0000000..24e29fa --- /dev/null +++ b/application/Espo/Core/ORM/ClassNameProvider.php @@ -0,0 +1,161 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\ORM\Entity as BaseEntity; +use Espo\Core\Repositories\Database as DatabaseRepository; +use Espo\Core\Utils\ClassFinder; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity as Entity; +use Espo\ORM\EventDispatcher; +use Espo\ORM\Repository\Repository as Repository; + +class ClassNameProvider +{ + /** @var class-string */ + private const DEFAULT_ENTITY_CLASS_NAME = BaseEntity::class; + /** @var class-string> */ + private const DEFAULT_REPOSITORY_CLASS_NAME = DatabaseRepository::class; + + /** @var array> */ + private array $entityCache = []; + + /** @var array>> */ + private array $repositoryCache = []; + + public function __construct( + private Metadata $metadata, + private ClassFinder $classFinder, + EventDispatcher $eventDispatcher, + ) { + $eventDispatcher->subscribeToMetadataUpdate(function () { + $this->entityCache = []; + $this->repositoryCache = []; + + $this->classFinder->resetRuntimeCache(); + }); + } + + /** + * @param string $entityType + * @return class-string + */ + public function getEntityClassName(string $entityType): string + { + if (!array_key_exists($entityType, $this->entityCache)) { + $this->entityCache[$entityType] = $this->findEntityClassName($entityType); + } + + return $this->entityCache[$entityType]; + } + + /** + * @param string $entityType + * @return class-string> + */ + public function getRepositoryClassName(string $entityType): string + { + if (!array_key_exists($entityType, $this->entityCache)) { + $this->repositoryCache[$entityType] = $this->findRepositoryClassName($entityType); + } + + return $this->repositoryCache[$entityType]; + } + + /** + * @param string $entityType + * @return class-string + */ + private function findEntityClassName(string $entityType): string + { + /** @var ?class-string $className */ + $className = $this->metadata->get("entityDefs.$entityType.entityClassName"); + + if ($className) { + return $className; + } + + /** @var ?class-string $className */ + $className = $this->classFinder->find('Entities', $entityType); + + if ($className) { + return $className; + } + + /** @var ?string $template */ + $template = $this->metadata->get(['scopes', $entityType, 'type']); + + if ($template) { + /** @var ?class-string $className */ + $className = $this->metadata->get(['app', 'entityTemplates', $template, 'entityClassName']); + } + + if ($className) { + return $className; + } + + return self::DEFAULT_ENTITY_CLASS_NAME; + } + + /** + * @param string $entityType + * @return class-string> + */ + private function findRepositoryClassName(string $entityType): string + { + /** @var ?class-string> $className */ + $className = $this->metadata->get("entityDefs.$entityType.repositoryClassName"); + + if ($className) { + return $className; + } + + /** @var ?class-string> $className */ + $className = $this->classFinder->find('Repositories', $entityType); + + if ($className) { + return $className; + } + + /** @var ?string $template */ + $template = $this->metadata->get(['scopes', $entityType, 'type']); + + if ($template) { + /** @var ?class-string> $className */ + $className = $this->metadata->get(['app', 'entityTemplates', $template, 'repositoryClassName']); + } + + if ($className) { + return $className; + } + + return self::DEFAULT_REPOSITORY_CLASS_NAME; + } +} diff --git a/application/Espo/Core/ORM/ConfigDataProvider.php b/application/Espo/Core/ORM/ConfigDataProvider.php new file mode 100644 index 0000000..2a0cf7a --- /dev/null +++ b/application/Espo/Core/ORM/ConfigDataProvider.php @@ -0,0 +1,48 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\Utils\Config; + +class ConfigDataProvider +{ + public function __construct(private Config $config) + {} + + public function logSql(): bool + { + return (bool) $this->config->get('logger.sql'); + } + + public function logSqlFailed(): bool + { + return (bool) $this->config->get('logger.sqlFailed'); + } +} diff --git a/application/Espo/Core/ORM/DatabaseParamsFactory.php b/application/Espo/Core/ORM/DatabaseParamsFactory.php new file mode 100644 index 0000000..a85b3e4 --- /dev/null +++ b/application/Espo/Core/ORM/DatabaseParamsFactory.php @@ -0,0 +1,95 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\Utils\Config; +use Espo\ORM\DatabaseParams; + +use RuntimeException; + +class DatabaseParamsFactory +{ + private const DEFAULT_PLATFORM = 'Mysql'; + + public function __construct(private Config $config) {} + + public function create(): DatabaseParams + { + $config = $this->config; + + if (!$config->get('database')) { + throw new RuntimeException('No database params in config.'); + } + + $databaseParams = DatabaseParams::create() + ->withHost($config->get('database.host')) + ->withPort($config->get('database.port') ? (int) $config->get('database.port') : null) + ->withName($config->get('database.dbname')) + ->withUsername($config->get('database.user')) + ->withPassword($config->get('database.password')) + ->withCharset($config->get('database.charset')) + ->withPlatform($config->get('database.platform')) + ->withSslCa($config->get('database.sslCA')) + ->withSslCert($config->get('database.sslCert')) + ->withSslKey($config->get('database.sslKey')) + ->withSslCaPath($config->get('database.sslCAPath')) + ->withSslCipher($config->get('database.sslCipher')) + ->withSslVerifyDisabled($config->get('database.sslVerifyDisabled') ?? false); + + if (!$databaseParams->getPlatform()) { + $databaseParams = $databaseParams->withPlatform(self::DEFAULT_PLATFORM); + } + + return $databaseParams; + } + + /** + * @param array $params + */ + public function createWithMergedAssoc(array $params): DatabaseParams + { + $configParams = $this->create(); + + return DatabaseParams::create() + ->withHost($params['host'] ?? $configParams->getHost()) + ->withPort(isset($params['port']) ? (int) $params['port'] : $configParams->getPort()) + ->withName($params['dbname'] ?? $configParams->getName()) + ->withUsername($params['user'] ?? $configParams->getUsername()) + ->withPassword($params['password'] ?? $configParams->getPassword()) + ->withCharset($params['charset'] ?? $configParams->getCharset()) + ->withPlatform($params['platform'] ?? $configParams->getPlatform()) + ->withSslCa($params['sslCA'] ?? $configParams->getSslCa()) + ->withSslCert($params['sslCert'] ?? $configParams->getSslCert()) + ->withSslKey($params['sslKey'] ?? $configParams->getSslKey()) + ->withSslCaPath($params['sslCAPath'] ?? $configParams->getSslCaPath()) + ->withSslCipher($params['sslCipher'] ?? $configParams->getSslCipher()) + ->withSslVerifyDisabled($params['sslVerifyDisabled'] ?? $configParams->isSslVerifyDisabled()); + } +} diff --git a/application/Espo/Core/ORM/Defs/AttributeParam.php b/application/Espo/Core/ORM/Defs/AttributeParam.php new file mode 100644 index 0000000..d134bba --- /dev/null +++ b/application/Espo/Core/ORM/Defs/AttributeParam.php @@ -0,0 +1,43 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\Defs; + +class AttributeParam +{ + /** @internal */ + public const IS_LINK_MULTIPLE_NAME_MAP = 'isLinkMultipleNameMap'; + /** @internal */ + public const IS_LINK_MULTIPLE_ID_LIST = 'isLinkMultipleIdList'; + /** @internal */ + public const NOT_EXPORTABLE = 'notExportable'; + /** @internal */ + public const IS_LINK_STUB = 'isLinkStub'; + +} diff --git a/application/Espo/Core/ORM/Entity.php b/application/Espo/Core/ORM/Entity.php new file mode 100644 index 0000000..baa3280 --- /dev/null +++ b/application/Espo/Core/ORM/Entity.php @@ -0,0 +1,728 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\Field\Link; +use Espo\Core\Field\LinkParent; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Defs\AttributeParam; +use Espo\ORM\BaseEntity; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Entity as OrmEntity; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Order; +use Espo\ORM\Type\AttributeType; +use Espo\ORM\Type\RelationType; + +use LogicException; +use stdClass; + +/** + * An entity. + */ +class Entity extends BaseEntity +{ + /** + * Has a link-multiple field. + */ + public function hasLinkMultipleField(string $field): bool + { + return + $this->hasRelation($field) && + $this->getAttributeParam($field . 'Ids', AttributeParam::IS_LINK_MULTIPLE_ID_LIST); + } + + /** + * Has a link field. + */ + public function hasLinkField(string $field): bool + { + return $this->hasAttribute($field . 'Id') && $this->hasRelation($field); + } + + /** + * Has a link-parent field. + */ + public function hasLinkParentField(string $field): bool + { + return $this->getAttributeType($field . 'Type') === AttributeType::FOREIGN_TYPE && + $this->hasAttribute($field . 'Id'); + } + + /** + * Load a parent-name field. + */ + public function loadParentNameField(string $field): void + { + if (!$this->hasLinkParentField($field)) { + throw new LogicException("Called `loadParentNameField` on non-link-parent field `$field`."); + } + + $idAttribute = $field . 'Id'; + $nameAttribute = $field . 'Name'; + + $parentId = $this->get($idAttribute); + $parentType = $this->get($field . 'Type'); + + if (!$this->entityManager) { + throw new LogicException("No entity-manager."); + } + + $toSetFetched = !$this->isNew() && !$this->isAttributeChanged($idAttribute); + + if (!$parentId || !$parentType) { + $this->set($nameAttribute, null); + + if ($toSetFetched) { + $this->setFetched($nameAttribute, null); + } + + return; + } + + if (!$this->entityManager->hasRepository($parentType)) { + return; + } + + $repository = $this->entityManager->getRDBRepository($parentType); + + $select = [Attribute::ID, Field::NAME]; + + $foreignEntity = $repository + ->select($select) + ->where([Attribute::ID => $parentId]) + ->findOne(); + + $entityName = $foreignEntity ? $foreignEntity->get(Field::NAME) : null; + + $this->set($nameAttribute, $entityName); + + if ($toSetFetched) { + $this->setFetched($nameAttribute, $entityName); + } + } + + /** + * @param string $link + * @return ?array{ + * orderBy: string|array|null, + * order: ?string, + * } + */ + protected function getRelationOrderParams(string $link): ?array + { + $field = $link; + + $idsAttribute = $field . 'Ids'; + + $foreignEntityType = $this->getRelationParam($field, RelationParam::ENTITY); + + if ($this->getAttributeParam($idsAttribute, 'orderBy')) { + $defs = [ + 'orderBy' => $this->getAttributeParam($idsAttribute, 'orderBy'), + 'order' => Order::ASC, + ]; + + if ($this->getAttributeParam($idsAttribute, 'orderDirection')) { + $defs['order'] = $this->getAttributeParam($idsAttribute, 'orderDirection'); + } + + return $defs; + } + + if ($this->getRelationParam($link, 'orderBy')) { + $defs = [ + 'orderBy' => $this->getRelationParam($link, 'orderBy'), + 'order' => Order::ASC, + ]; + + if ($this->getRelationParam($link, 'order')) { + $defs['order'] = strtoupper($this->getRelationParam($link, 'order')); + } + + return $defs; + } + + if (!$foreignEntityType || !$this->entityManager) { + return null; + } + + $ormDefs = $this->entityManager->getMetadata()->getDefs(); + + if (!$ormDefs->hasEntity($foreignEntityType)) { + return null; + } + + $entityDefs = $ormDefs->getEntity($foreignEntityType); + + $collectionDefs = $entityDefs->getParam('collection') ?? []; + + $orderBy = $collectionDefs['orderBy'] ?? null; + $order = $collectionDefs['order'] ?? 'ASC'; + + if (!$orderBy) { + return null; + } + + if (!$entityDefs->hasAttribute($orderBy)) { + return null; + } + + return [ + 'orderBy' => $orderBy, + 'order' => $order, + ]; + } + + /** + * Load a link-multiple field. Should be used wisely. Consider using `getLinkMultipleIdList` instead. + * + * @param ?array $columns Deprecated as of v9.0. + * @todo Add a method to load and set only fetched values? + * @internal + */ + public function loadLinkMultipleField(string $field, ?array $columns = null): void + { + if (!$this->hasLinkMultipleField($field)) { + throw new LogicException("Called `loadLinkMultipleField` on non-link-multiple field `$field`."); + } + + if (!$this->entityManager) { + throw new LogicException("No entity-manager."); + } + + $select = [Attribute::ID, Field::NAME]; + + $hasType = $this->hasAttribute($field . 'Types'); + + if ($hasType) { + $select[] = 'type'; + } + + $columns ??= $this->getLinkMultipleColumnsFromDefs($field); + + if ($columns) { + foreach ($columns as $it) { + $select[] = $it; + } + } + + $selectBuilder = $this->entityManager + ->getRDBRepository($this->getEntityType()) + ->getRelation($this, $field) + ->select($select); + + $orderBy = null; + $order = null; + + $orderParams = $this->getRelationOrderParams($field); + + if ($orderParams) { + $orderBy = $orderParams['orderBy'] ?? null; + /** @var string|bool|null $order */ + $order = $orderParams['order'] ?? null; + } + + if ($orderBy) { + if (is_string($orderBy) && !in_array($orderBy, $select)) { + $selectBuilder->select($orderBy); + } + + if (is_string($order)) { + $order = strtoupper($order); + + if ($order !== Order::ASC && $order !== Order::DESC) { + $order = Order::ASC; + } + } + + $selectBuilder->order($orderBy, $order); + } + + $collection = $selectBuilder->find(); + + $ids = []; + $names = (object) []; + $types = (object) []; + $columnsData = (object) []; + + foreach ($collection as $e) { + $id = $e->getId(); + + $ids[] = $id; + + $names->$id = $e->get(Field::NAME); + + if ($hasType) { + $types->$id = $e->get('type'); + } + + if (!$columns) { + continue; + } + + $columnsData->$id = (object) []; + + foreach ($columns as $column => $foreignAttribute) { + $columnsData->$id->$column = $e->get($foreignAttribute); + } + } + + $idsAttribute = $field . 'Ids'; + $namesAttribute = $field . 'Names'; + $typesAttribute = $field . 'Types'; + $columnsAttribute = $field . 'Columns'; + + $toSetFetched = !$this->isNew() && !$this->hasFetched($idsAttribute); + + $this->setInContainerNotWritten($idsAttribute, $ids); + $this->setInContainerNotWritten($namesAttribute, $names); + + if ($toSetFetched) { + $this->setFetched($idsAttribute, $ids); + $this->setFetched($namesAttribute, $names); + } + + if ($hasType) { + $this->set($typesAttribute, $types); + + if ($toSetFetched) { + $this->setFetched($typesAttribute, $types); + } + } + + if ($columns) { + $this->setInContainerNotWritten($columnsAttribute, $columnsData); + + if ($toSetFetched) { + $this->setFetched($columnsAttribute, $columnsData); + } + } + } + + /** + * Load a link field. If a value is already set, it will set only a fetched value. + */ + public function loadLinkField(string $field): void + { + if (!$this->hasLinkField($field)) { + throw new LogicException("Called `loadLinkField` on non-link field '$field'."); + } + + if ( + $this->getRelationType($field) !== RelationType::HAS_ONE && + $this->getRelationType($field) !== RelationType::BELONGS_TO + ) { + throw new LogicException("Can't load link '$field'."); + } + + if (!$this->entityManager) { + throw new LogicException("No entity-manager."); + } + + $select = [Attribute::ID, Field::NAME]; + + $entity = $this->entityManager + ->getRelation($this, $field) + ->select($select) + ->findOne(); + + $entityId = null; + $entityName = null; + + if ($entity) { + $entityId = $entity->getId(); + $entityName = $entity->get(Field::NAME); + } + + $idAttribute = $field . 'Id'; + $nameAttribute = $field . 'Name'; + + if (!$this->isNew() && !$this->hasFetched($idAttribute)) { + $this->setFetched($idAttribute, $entityId); + $this->setFetched($nameAttribute, $entityName); + } + + if ($this->has($idAttribute)) { + return; + } + + $this->setInContainerNotWritten($idAttribute, $entityId); + $this->setInContainerNotWritten($nameAttribute, $entityName); + } + + /** + * Get a link-multiple name. + */ + public function getLinkMultipleName(string $field, string $id): ?string + { + $namesAttribute = $field . 'Names'; + + if (!$this->hasAttribute($namesAttribute)) { + throw new LogicException("Called `getLinkMultipleName` on non-link-multiple field `$field."); + } + + if (!$this->has($namesAttribute) && !$this->isNew()) { + $this->loadLinkMultipleField($field); + } + + $object = $this->get($namesAttribute) ?? (object) []; + + if (!$object instanceof stdClass) { + throw new LogicException("Non-object value in `$namesAttribute`."); + } + + return $object->$id ?? null; + } + + /** + * Set a link-multiple name. + * + * @return static As of v9.2. + */ + public function setLinkMultipleName(string $field, string $id, ?string $value): static + { + $namesAttribute = $field . 'Names'; + + if (!$this->hasAttribute($namesAttribute)) { + throw new LogicException("Called `setLinkMultipleName` on non-link-multiple field `$field."); + } + + if (!$this->has($namesAttribute)) { + return $this; + } + + $object = $this->get($namesAttribute) ?? (object) []; + + if (!$object instanceof stdClass) { + throw new LogicException("Non-object value in `$namesAttribute`."); + } + + $object->$id = $value; + + $this->set($namesAttribute, $object); + + return $this; + } + + /** + * Get a link-multiple column value. + */ + public function getLinkMultipleColumn(string $field, string $column, string $id): mixed + { + $columnsAttribute = $field . 'Columns'; + + if (!$this->hasAttribute($columnsAttribute)) { + throw new LogicException("Called `getLinkMultipleColumn` on not supported field `$field."); + } + + if (!$this->has($columnsAttribute) && !$this->isNew()) { + $this->loadLinkMultipleField($field); + } + + $object = $this->get($columnsAttribute) ?? (object) []; + + if (!$object instanceof stdClass) { + throw new LogicException("Non-object value in `$columnsAttribute`."); + } + + return $object->$id->$column ?? null; + } + + /** + * Set a link-multiple column value. + * + * @return static As of v9.2. + */ + public function setLinkMultipleColumn(string $field, string $column, string $id, mixed $value): static + { + $columnsAttribute = $field . 'Columns'; + + if (!$this->hasAttribute($columnsAttribute)) { + throw new LogicException("Called `setLinkMultipleColumn` on non-link-multiple field `$field."); + } + + if (!$this->has($columnsAttribute) && !$this->isNew()) { + $this->loadLinkMultipleField($field); + } + + $object = $this->get($columnsAttribute) ?? (object) []; + + if (!$object instanceof stdClass) { + throw new LogicException("Non-object value in `$columnsAttribute`."); + } + + $object->$id ??= (object) []; + $object->$id->$column = $value; + + $this->set($columnsAttribute, $object); + + return $this; + } + + /** + * Set link-multiple IDs. + * + * @param string[] $idList + * @return static As of v9.2. + */ + public function setLinkMultipleIdList(string $field, array $idList): static + { + $idsAttribute = $field . 'Ids'; + + if (!$this->hasAttribute($idsAttribute)) { + throw new LogicException("Called `setLinkMultipleIdList` on non-link-multiple field `$field."); + } + + $this->set($idsAttribute, $idList); + + return $this; + } + + /** + * Add an ID to a link-multiple field. + * + * @return static As of v9.2. + */ + public function addLinkMultipleId(string $field, string $id): static + { + $idsAttribute = $field . 'Ids'; + + if (!$this->hasAttribute($idsAttribute)) { + throw new LogicException("Called `addLinkMultipleId` on non-link-multiple field `$field."); + } + + if (!$this->has($idsAttribute)) { + if (!$this->isNew()) { + $this->loadLinkMultipleField($field); + } else { + $this->set($idsAttribute, []); + } + } + + if (!$this->has($idsAttribute)) { + return $this; + } + + $idList = $this->get($idsAttribute); + + if ($idList === null) { + throw new LogicException("Null value set in `$idsAttribute`."); + } + + if (!is_array($idList)) { + throw new LogicException("Non-array value set in `$idsAttribute`."); + } + + if (in_array($id, $idList)) { + return $this; + } + + $idList[] = $id; + + $this->set($idsAttribute, $idList); + + return $this; + } + + /** + * Remove an ID from link-multiple field. + * + * @return static As of v9.2. + */ + public function removeLinkMultipleId(string $field, string $id): static + { + if (!$this->hasLinkMultipleId($field, $id)) { + return $this; + } + + $list = $this->getLinkMultipleIdList($field); + + $index = array_search($id, $list); + + if ($index !== false) { + unset($list[$index]); + + $list = array_values($list); + } + + $this->setLinkMultipleIdList($field, $list); + + return $this; + } + + /** + * Get link-multiple field IDs. + * + * @return string[] + */ + public function getLinkMultipleIdList(string $field): array + { + $idsAttribute = $field . 'Ids'; + + if (!$this->hasAttribute($idsAttribute)) { + throw new LogicException("Called `getLinkMultipleIdList` for non-link-multiple field `$field."); + } + + if (!$this->has($idsAttribute) && !$this->isNew()) { + $this->loadLinkMultipleField($field); + } + + /** @var string[] */ + return $this->get($idsAttribute) ?? []; + } + + /** + * Get previous link-multiple field IDs. + * + * @return string[] + * @since 9.1.0 + */ + public function getFetchedLinkMultipleIdList(string $field): array + { + $idsAttribute = $field . 'Ids'; + + if (!$this->hasAttribute($idsAttribute)) { + throw new LogicException("Called `getFetchedLinkMultipleIdList` for non-link-multiple field `$field."); + } + + if (!$this->isNew()) { + if (!$this->has($idsAttribute)) { + $this->loadLinkMultipleField($field); + } else if (!$this->hasFetched($field)) { + // Set but not loaded. + + $attributes = [ + $field . 'Ids', + $field . 'Names', + $field . 'Types', + $field . 'Columns', + ]; + + $map = array_reduce($attributes, function ($p, $item) { + if (!$this->has($item)) { + return $p; + } + + $p[$item] = $this->get($item); + + return $p; + }, []); + + $this->loadLinkMultipleField($field); + + // Restore set values. + $this->setMultiple($map); + } + } + + if (!$this->has($idsAttribute) && !$this->isNew()) { + $this->loadLinkMultipleField($field); + } + + /** @var string[] */ + return $this->getFetched($idsAttribute) ?? []; + } + + /** + * Has an ID in a link-multiple field. + */ + public function hasLinkMultipleId(string $field, string $id): bool + { + $idsAttribute = $field . 'Ids'; + + if (!$this->hasAttribute($idsAttribute)) { + throw new LogicException("Called `hasLinkMultipleId` for non-link-multiple field `$field."); + } + + if (!$this->has($idsAttribute) && !$this->isNew()) { + $this->loadLinkMultipleField($field); + } + + if (!$this->has($idsAttribute)) { + return false; + } + + /** @var string[] $idList */ + $idList = $this->get($idsAttribute) ?? []; + + return in_array($id, $idList); + } + + /** + * @return string[]|null + */ + private function getLinkMultipleColumnsFromDefs(string $field): ?array + { + if (!$this->entityManager) { + return null; + } + + $entityDefs = $this->entityManager->getDefs()->getEntity($this->entityType); + + /** @var ?array $columns */ + $columns = $entityDefs->tryGetField($field)?->getParam('columns'); + + if (!$columns) { + return $columns; + } + + $foreignEntityType = $entityDefs->tryGetRelation($field)?->tryGetForeignEntityType(); + + if ($foreignEntityType) { + $foreignEntityDefs = $this->entityManager->getDefs()->getEntity($foreignEntityType); + + foreach ($columns as $column => $attribute) { + if (!$foreignEntityDefs->hasAttribute($attribute)) { + // For backward compatibility. If foreign attributes defined in the field do not exist. + unset($columns[$column]); + } + } + } + + return $columns; + } + + /** + * @since 9.0.0 + */ + protected function setRelatedLinkOrEntity(string $relation, Link|LinkParent|OrmEntity|null $related): static + { + if ($related instanceof Entity || $related === null) { + $this->relations->set($relation, $related); + + return $this; + } + + $this->setValueObject($relation, $related); + + return $this; + } +} diff --git a/application/Espo/Core/ORM/EntityFactory.php b/application/Espo/Core/ORM/EntityFactory.php new file mode 100644 index 0000000..f49d839 --- /dev/null +++ b/application/Espo/Core/ORM/EntityFactory.php @@ -0,0 +1,188 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\ORM\DataLoader\Loader; +use Espo\ORM\DataLoader\RDBLoader; +use Espo\ORM\Entity; +use Espo\ORM\EntityFactory as EntityFactoryInterface; +use Espo\ORM\EntityManager; +use Espo\ORM\Relation\RDBRelations; +use Espo\ORM\Relation\Relations; +use Espo\ORM\Relation\RelationsMap; +use Espo\ORM\Value\ValueAccessorFactory; + +use RuntimeException; + +class EntityFactory implements EntityFactoryInterface +{ + private ?EntityManager $entityManager = null; + private ?ValueAccessorFactory $valueAccessorFactory = null; + + public function __construct( + private ClassNameProvider $classNameProvider, + private Helper $helper, + private InjectableFactory $injectableFactory, + private RelationsMap $relationsMap, + ) {} + + public function setEntityManager(EntityManager $entityManager): void + { + if ($this->entityManager) { + throw new RuntimeException("EntityManager can be set only once."); + } + + $this->entityManager = $entityManager; + } + + public function setValueAccessorFactory(ValueAccessorFactory $valueAccessorFactory): void + { + if ($this->valueAccessorFactory) { + throw new RuntimeException("ValueAccessorFactory can be set only once."); + } + + $this->valueAccessorFactory = $valueAccessorFactory; + } + + public function create(string $entityType): Entity + { + return $this->createInternal($entityType); + } + + /** + * @param ?array $attributeDefs + */ + private function createInternal(string $entityType, ?array $attributeDefs = null): Entity + { + $className = $this->getClassName($entityType); + + if (!$this->entityManager) { + throw new RuntimeException("No entityManager."); + } + + $defs = $this->entityManager->getMetadata()->get($entityType); + + if (is_null($defs)) { + throw new RuntimeException("Entity '$entityType' is not defined in metadata."); + } + + if ($attributeDefs) { + $defs['attributes'] = array_merge($defs['attributes'] ?? [], $attributeDefs); + } + + $relations = $this->injectableFactory->createWithBinding( + RDBRelations::class, + BindingContainerBuilder::create() + ->bindInstance(EntityManager::class, $this->entityManager) + ->build() + ); + + $loader = $this->injectableFactory->createWithBinding( + RDBLoader::class, + BindingContainerBuilder::create() + ->bindInstance(EntityManager::class, $this->entityManager) + ->build() + ); + + $bindingContainer = $this->getBindingContainer( + $className, + $entityType, + $defs, + $relations, + $loader, + ); + + $entity = $this->injectableFactory->createWithBinding($className, $bindingContainer); + + if ($relations instanceof RDBRelations) { + $relations->setEntity($entity); + } + + $this->relationsMap->set($entity, $relations); + + return $entity; + } + + /** + * @param array $attributeDefs + * @internal + * @noinspection PhpUnused + */ + public function createWithAdditionalAttributes(string $entityType, array $attributeDefs): Entity + { + return $this->createInternal($entityType, $attributeDefs); + } + + /** + * @return class-string + */ + private function getClassName(string $entityType): string + { + /** @var class-string */ + return $this->classNameProvider->getEntityClassName($entityType); + } + + /** + * @param class-string $className + * @param array $defs + */ + private function getBindingContainer( + string $className, + string $entityType, + array $defs, + Relations $relations, + Loader $loader, + ): BindingContainer { + + if (!$this->entityManager || !$this->valueAccessorFactory) { + throw new RuntimeException(); + } + + $data = new BindingData(); + $binder = new Binder($data); + + $binder + ->for($className) + ->bindValue('$entityType', $entityType) + ->bindValue('$defs', $defs) + ->bindInstance(EntityManager::class, $this->entityManager) + ->bindInstance(ValueAccessorFactory::class, $this->valueAccessorFactory) + ->bindInstance(Helper::class, $this->helper) + ->bindInstance(Relations::class, $relations) + ->bindInstance(Loader::class, $loader); + + return new BindingContainer($data); + } +} diff --git a/application/Espo/Core/ORM/EntityManager.php b/application/Espo/Core/ORM/EntityManager.php new file mode 100644 index 0000000..93e95b3 --- /dev/null +++ b/application/Espo/Core/ORM/EntityManager.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\ORM\EntityManager as BaseEntityManager; + +class EntityManager extends BaseEntityManager +{} diff --git a/application/Espo/Core/ORM/EntityManagerFactory.php b/application/Espo/Core/ORM/EntityManagerFactory.php new file mode 100644 index 0000000..4d8656e --- /dev/null +++ b/application/Espo/Core/ORM/EntityManagerFactory.php @@ -0,0 +1,170 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\ORM\PDO\PDOFactoryFactory; +use Espo\Core\ORM\QueryComposer\QueryComposerFactory; +use Espo\Core\InjectableFactory; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\ORM\QueryComposer\Part\FunctionConverterFactory; + +use Espo\Core\Utils\Log; +use Espo\ORM\Executor\DefaultSqlExecutor; +use Espo\ORM\Metadata; +use Espo\ORM\EventDispatcher; +use Espo\ORM\DatabaseParams; +use Espo\ORM\PDO\PDOFactory; +use Espo\ORM\QueryComposer\QueryComposerFactory as QueryComposerFactoryInterface; +use Espo\ORM\Relation\RelationsMap; +use Espo\ORM\Repository\RepositoryFactory as RepositoryFactoryInterface; +use Espo\ORM\EntityFactory as EntityFactoryInterface; +use Espo\ORM\Executor\SqlExecutor; +use Espo\ORM\Value\ValueFactoryFactory as ValueFactoryFactoryInterface; +use Espo\ORM\Value\AttributeExtractorFactory as AttributeExtractorFactoryInterface; +use Espo\ORM\PDO\PDOProvider; +use Espo\ORM\QueryComposer\Part\FunctionConverterFactory as FunctionConverterFactoryInterface; + +use RuntimeException; + +class EntityManagerFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private MetadataDataProvider $metadataDataProvider, + private EventDispatcher $eventDispatcher, + private PDOFactoryFactory $pdoFactoryFactory, + private DatabaseParamsFactory $databaseParamsFactory, + private ConfigDataProvider $configDataProvider, + private Log $log, + ) {} + + public function create(): EntityManager + { + $relationsMap = new RelationsMap(); + + $entityFactory = $this->injectableFactory->createWithBinding( + EntityFactory::class, + BindingContainerBuilder::create() + ->bindInstance(EventDispatcher::class, $this->eventDispatcher) + ->bindInstance(RelationsMap::class, $relationsMap) + ->build() + ); + + $repositoryFactory = $this->injectableFactory->createWithBinding( + RepositoryFactory::class, + BindingContainerBuilder::create() + ->bindInstance(EntityFactoryInterface::class, $entityFactory) + ->bindInstance(EventDispatcher::class, $this->eventDispatcher) + ->bindInstance(RelationsMap::class, $relationsMap) + ->build() + ); + + $databaseParams = $this->createDatabaseParams(); + + $metadata = new Metadata($this->metadataDataProvider, $this->eventDispatcher); + + $valueFactoryFactory = $this->injectableFactory->createWithBinding( + ValueFactoryFactory::class, + BindingContainerBuilder::create() + ->bindInstance(Metadata::class, $metadata) + ->build() + ); + + $attributeExtractorFactory = $this->injectableFactory->createWithBinding( + AttributeExtractorFactory::class, + BindingContainerBuilder::create() + ->bindInstance(Metadata::class, $metadata) + ->build() + ); + + $functionConverterFactory = $this->injectableFactory->createWithBinding( + FunctionConverterFactory::class, + BindingContainerBuilder::create() + ->bindInstance(DatabaseParams::class, $databaseParams) + ->build() + ); + + $pdoFactory = $this->pdoFactoryFactory->create($databaseParams->getPlatform() ?? ''); + + $pdoProvider = $this->injectableFactory->createResolved( + PDOProvider::class, + BindingContainerBuilder::create() + ->bindInstance(DatabaseParams::class, $databaseParams) + ->bindInstance(PDOFactory::class, $pdoFactory) + ->build() + ); + + $queryComposerFactory = $this->injectableFactory->createWithBinding( + QueryComposerFactory::class, + BindingContainerBuilder::create() + ->bindInstance(PDOProvider::class, $pdoProvider) + ->bindInstance(Metadata::class, $metadata) + ->bindInstance(EventDispatcher::class, $this->eventDispatcher) + ->bindInstance(EntityFactoryInterface::class, $entityFactory) + ->bindInstance(FunctionConverterFactoryInterface::class, $functionConverterFactory) + ->build() + ); + + $sqlExecutor = new DefaultSqlExecutor( + $pdoProvider, + $this->log, + $this->configDataProvider->logSql(), + $this->configDataProvider->logSqlFailed() + ); + + $binding = BindingContainerBuilder::create() + ->bindInstance(DatabaseParams::class, $databaseParams) + ->bindInstance(Metadata::class, $metadata) + ->bindInstance(QueryComposerFactoryInterface::class, $queryComposerFactory) + ->bindInstance(RepositoryFactoryInterface::class, $repositoryFactory) + ->bindInstance(EntityFactoryInterface::class, $entityFactory) + ->bindInstance(ValueFactoryFactoryInterface::class, $valueFactoryFactory) + ->bindInstance(AttributeExtractorFactoryInterface::class, $attributeExtractorFactory) + ->bindInstance(EventDispatcher::class, $this->eventDispatcher) + ->bindInstance(PDOProvider::class, $pdoProvider) + ->bindInstance(FunctionConverterFactoryInterface::class, $functionConverterFactory) + ->bindInstance(SqlExecutor::class, $sqlExecutor) + ->bindInstance(RelationsMap::class, $relationsMap) + ->build(); + + return $this->injectableFactory->createWithBinding(EntityManager::class, $binding); + } + + private function createDatabaseParams(): DatabaseParams + { + $databaseParams = $this->databaseParamsFactory->create(); + + if (!$databaseParams->getName()) { + throw new RuntimeException('No database name specified in config.'); + } + + return $databaseParams; + } +} diff --git a/application/Espo/Core/ORM/EntityManagerProxy.php b/application/Espo/Core/ORM/EntityManagerProxy.php new file mode 100644 index 0000000..ad40dd0 --- /dev/null +++ b/application/Espo/Core/ORM/EntityManagerProxy.php @@ -0,0 +1,143 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\ORM\Entity; +use Espo\ORM\Executor\QueryExecutor; +use Espo\ORM\Metadata; +use Espo\ORM\Repository\RDBRepository; +use Espo\ORM\Repository\Repository; +use Espo\ORM\Executor\SqlExecutor; +use Espo\Core\Container; + +class EntityManagerProxy +{ + private ?EntityManager $entityManager = null; + + public function __construct(private Container $container) + {} + + private function getEntityManager(): EntityManager + { + if (!$this->entityManager) { + $this->entityManager = $this->container->getByClass(EntityManager::class); + } + + return $this->entityManager; + } + + public function getNewEntity(string $entityType): Entity + { + return $this->getEntityManager()->getNewEntity($entityType); + } + + public function getEntityById(string $entityType, string $id): ?Entity + { + return $this->getEntityManager()->getEntityById($entityType, $id); + } + + /** + * @deprecated As of v9.0. + * @todo Remove in v11.0. + */ + public function getEntity(string $entityType, ?string $id = null): ?Entity + { + /** @noinspection PhpDeprecationInspection */ + return $this->getEntityManager()->getEntity($entityType, $id); + } + + /** + * @param array $options + */ + public function saveEntity(Entity $entity, array $options = []): void + { + $this->getEntityManager()->saveEntity($entity, $options); + } + + /** + * @return Repository + */ + public function getRepository(string $entityType): Repository + { + return $this->getEntityManager()->getRepository($entityType); + } + + /** + * @return RDBRepository + */ + public function getRDBRepository(string $entityType): RDBRepository + { + return $this->getEntityManager()->getRDBRepository($entityType); + } + + public function getMetadata(): Metadata + { + return $this->getEntityManager()->getMetadata(); + } + + public function getSqlExecutor(): SqlExecutor + { + return $this->getEntityManager()->getSqlExecutor(); + } + + /** + * Get an RDB repository by an entity class name. + * + * @template T of Entity + * @param class-string $className An entity class name. + * @return RDBRepository + */ + public function getRDBRepositoryByClass(string $className): RDBRepository + { + return $this->getEntityManager()->getRDBRepositoryByClass($className); + } + + /** + * Get a repository by an entity class name. + * + * @template T of Entity + * @param class-string $className An entity class name. + * @return Repository + */ + public function getRepositoryByClass(string $className): Repository + { + return $this->getEntityManager()->getRepositoryByClass($className); + } + + /** + * Get the query executor. + * + * @since 10.1.0 + */ + public function getQueryExecutor(): QueryExecutor + { + return $this->getEntityManager()->getQueryExecutor(); + } +} diff --git a/application/Espo/Core/ORM/Helper.php b/application/Espo/Core/ORM/Helper.php new file mode 100644 index 0000000..73da449 --- /dev/null +++ b/application/Espo/Core/ORM/Helper.php @@ -0,0 +1,160 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\Utils\Config; +use Espo\ORM\Entity; + +class Helper +{ + private const FORMAT_LAST_FIRST = 'lastFirst'; + private const FORMAT_LAST_FIRST_MIDDLE = 'lastFirstMiddle'; + private const FORMAT_FIRST_MIDDLE_LAST = 'firstMiddleLast'; + + public function __construct(private Config $config) + {} + + /** + * @internal + */ + public function hasAllPersonNameAttributes(Entity $entity, string $field): bool + { + $format = $this->config->get('personNameFormat'); + + $firstName = 'first' . ucfirst($field); + $lastName = 'last' . ucfirst($field); + $middleName = 'middle' . ucfirst($field); + + $attributes = [ + $firstName, + $lastName, + ]; + + if ( + $format === self::FORMAT_LAST_FIRST_MIDDLE || + $format === self::FORMAT_FIRST_MIDDLE_LAST + ) { + $attributes[] = $middleName; + } + + foreach ($attributes as $attribute) { + if (!$entity->has($attribute)) { + return false; + } + } + + return true; + } + + /** + * @internal + */ + public function formatPersonName(Entity $entity, string $field): ?string + { + $format = $this->config->get('personNameFormat'); + + $first = $entity->get('first' . ucfirst($field)); + $last = $entity->get('last' . ucfirst($field)); + $middle = $entity->get('middle' . ucfirst($field)); + + switch ($format) { + case self::FORMAT_LAST_FIRST: + if ($first === null && $last === null) { + return null; + } + + if ($first === null) { + return $last; + } + + if ($last === null) { + return $first; + } + + return $last . ' ' . $first; + + case self::FORMAT_LAST_FIRST_MIDDLE: + if ($first === null && $last === null && $middle === null) { + return null; + } + + $arr = []; + + if ($last !== null) { + $arr[] = $last; + } + + if ($first !== null) { + $arr[] = $first; + } + + if ($middle !== null) { + $arr[] = $middle; + } + + return implode(' ', $arr); + + case self::FORMAT_FIRST_MIDDLE_LAST: + if (!$first && !$last && !$middle) { + return null; + } + + $arr = []; + + if ($first !== null) { + $arr[] = $first; + } + + if ($middle !== null) { + $arr[] = $middle; + } + + if ($last !== null) { + $arr[] = $last; + } + + return implode(' ', $arr); + } + + if ($first === null && $last === null) { + return null; + } + + if ($first === null) { + return $last; + } + + if ($last === null) { + return $first; + } + + return $first . ' ' . $last; + } +} diff --git a/application/Espo/Core/ORM/MetadataDataProvider.php b/application/Espo/Core/ORM/MetadataDataProvider.php new file mode 100644 index 0000000..b03925a --- /dev/null +++ b/application/Espo/Core/ORM/MetadataDataProvider.php @@ -0,0 +1,53 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Metadata\OrmMetadataData; +use Espo\ORM\MetadataDataProvider as MetadataDataProviderInterface; + +class MetadataDataProvider implements MetadataDataProviderInterface +{ + public function __construct( + private OrmMetadataData $ormMetadataData, + private Metadata $metadata + ) {} + + public function get(): array + { + $data = $this->ormMetadataData->getData(); + + foreach (array_keys($data) as $entityType) { + $data[$entityType]['fields'] = $this->metadata->get(['entityDefs', $entityType, 'fields']) ?? []; + } + + return $data; + } +} diff --git a/application/Espo/Core/ORM/PDO/PDOFactoryFactory.php b/application/Espo/Core/ORM/PDO/PDOFactoryFactory.php new file mode 100644 index 0000000..b65a49e --- /dev/null +++ b/application/Espo/Core/ORM/PDO/PDOFactoryFactory.php @@ -0,0 +1,57 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\PDO; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\ORM\PDO\PDOFactory; +use RuntimeException; + +class PDOFactoryFactory +{ + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + public function create(string $platform): PDOFactory + { + /** @var ?class-string $className */ + $className = + $this->metadata->get(['app', 'orm', 'platforms', $platform, 'pdoFactoryClassName']) ?? + $this->metadata->get(['app', 'orm', 'pdoFactoryClassNameMap', $platform]); + + if (!$className) { + throw new RuntimeException("Could not create PDOFactory."); + } + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/ORM/QueryComposer/Part/FunctionConverterFactory.php b/application/Espo/Core/ORM/QueryComposer/Part/FunctionConverterFactory.php new file mode 100644 index 0000000..cc0f94b --- /dev/null +++ b/application/Espo/Core/ORM/QueryComposer/Part/FunctionConverterFactory.php @@ -0,0 +1,89 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\QueryComposer\Part; + +use Espo\ORM\QueryComposer\Part\FunctionConverterFactory as FunctionConverterFactoryInterface; +use Espo\ORM\QueryComposer\Part\FunctionConverter; +use Espo\ORM\DatabaseParams; +use Espo\Core\Utils\Metadata; +use Espo\Core\InjectableFactory; + +use LogicException; + +class FunctionConverterFactory implements FunctionConverterFactoryInterface +{ + /** @var array */ + private $hash = []; + + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory, + private DatabaseParams $databaseParams + ) {} + + public function create(string $name): FunctionConverter + { + $className = $this->getClassName($name); + + if ($className === null) { + throw new LogicException(); + } + + return $this->injectableFactory->create($className); + } + + public function isCreatable(string $name): bool + { + if ($this->getClassName($name) === null) { + return false; + } + + return true; + } + + /** + * @return ?class-string + */ + private function getClassName(string $name): ?string + { + if (!array_key_exists($name, $this->hash)) { + /** @var string $platform */ + $platform = $this->databaseParams->getPlatform(); + + $this->hash[$name] = + $this->metadata->get(['app', 'orm', 'platforms', $platform, 'functionConverterClassNameMap', $name]) ?? + $this->metadata->get(['app', 'orm', 'functionConverterClassNameMap_' . $platform, $name]); + + } + + /** @var ?class-string */ + return $this->hash[$name]; + } +} diff --git a/application/Espo/Core/ORM/QueryComposer/Part/FunctionConverters/Abs.php b/application/Espo/Core/ORM/QueryComposer/Part/FunctionConverters/Abs.php new file mode 100644 index 0000000..139da5c --- /dev/null +++ b/application/Espo/Core/ORM/QueryComposer/Part/FunctionConverters/Abs.php @@ -0,0 +1,40 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\QueryComposer\Part\FunctionConverters; + +use Espo\ORM\QueryComposer\Part\FunctionConverter; + +class Abs implements FunctionConverter +{ + public function convert(string ...$argumentList): string + { + return 'ABS(' . implode(', ', $argumentList) . ')'; + } +} diff --git a/application/Espo/Core/ORM/QueryComposer/Part/FunctionConverters/CurrencyRate.php b/application/Espo/Core/ORM/QueryComposer/Part/FunctionConverters/CurrencyRate.php new file mode 100644 index 0000000..fd93919 --- /dev/null +++ b/application/Espo/Core/ORM/QueryComposer/Part/FunctionConverters/CurrencyRate.php @@ -0,0 +1,99 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\QueryComposer\Part\FunctionConverters; + +use Espo\Core\Currency\ConfigDataProvider; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\QueryComposer\Part\FunctionConverter; +use Espo\ORM\QueryComposer\Util; +use RuntimeException; + +/** + * @noinspection PhpUnused + */ +class CurrencyRate implements FunctionConverter +{ + private const int PRECISION = 5; + + public function __construct( + private ConfigDataProvider $config, + ) {} + + public function convert(string ...$argumentList): string + { + $arg = $argumentList[0] ?? null; + + if (!is_string($arg) || !Util::isArgumentString($arg)) { + throw new RuntimeException("CURRENCY_RATE function accepts only literal string argument."); + } + + $code = substr($arg, 1, -1); + + if (!in_array($code, $this->config->getCurrencyList())) { + return Expression::value(0)->getValue(); + } + + $baseCurrency = $this->config->getBaseCurrency(); + $defaultCurrency = $this->config->getDefaultCurrency(); + + $rates = $this->config->getCurrencyRates()->toAssoc(); + + if ($defaultCurrency !== $baseCurrency) { + $rates = $this->exchangeRates($baseCurrency, $defaultCurrency, $rates); + } + + $rate = $rates[$code] ?? 1.0; + + return Expression::value($rate)->getValue(); + } + + /** + * @param array $currencyRates + * @return array + */ + private function exchangeRates(string $baseCurrency, string $defaultCurrency, array $currencyRates): array + { + // Not supposed to happen. If the default currency is not listed in the currency list. + $defaultNormalRate = $currencyRates[$defaultCurrency] ?? 1.0; + + $defaultCurrencyRate = round(1 / $defaultNormalRate, self::PRECISION); + + $exchangedRates = []; + $exchangedRates[$baseCurrency] = $defaultCurrencyRate; + + unset($currencyRates[$baseCurrency], $currencyRates[$defaultCurrency]); + + foreach ($currencyRates as $code => $rate) { + $exchangedRates[$code] = round($rate * $defaultCurrencyRate, self::PRECISION); + } + + return $exchangedRates; + } +} diff --git a/application/Espo/Core/ORM/QueryComposer/QueryComposerFactory.php b/application/Espo/Core/ORM/QueryComposer/QueryComposerFactory.php new file mode 100644 index 0000000..dca79aa --- /dev/null +++ b/application/Espo/Core/ORM/QueryComposer/QueryComposerFactory.php @@ -0,0 +1,83 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\QueryComposer; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\InjectableFactory; +use Espo\ORM\EventDispatcher; +use Espo\ORM\QueryComposer\Part\FunctionConverterFactory; +use Espo\Core\Utils\Metadata; +use Espo\ORM\PDO\PDOProvider; +use Espo\ORM\QueryComposer\QueryComposer; +use Espo\ORM\Metadata as OrmMetadata; +use Espo\ORM\EntityFactory; + +use PDO; +use RuntimeException; + +class QueryComposerFactory implements \Espo\ORM\QueryComposer\QueryComposerFactory +{ + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory, + private PDOProvider $pdoProvider, + private OrmMetadata $ormMetadata, + private EntityFactory $entityFactory, + private FunctionConverterFactory $functionConverterFactory, + private EventDispatcher $eventDispatcher + ) {} + + public function create(string $platform): QueryComposer + { + /** @var ?class-string $className */ + $className = + $this->metadata->get(['app', 'orm', 'platforms', $platform, 'queryComposerClassName']) ?? + $this->metadata->get(['app', 'orm', 'queryComposerClassNameMap', $platform]); + + if (!$className) { + /** @var class-string $className */ + $className = "Espo\\ORM\\QueryComposer\\{$platform}QueryComposer"; + } + + if (!class_exists($className)) { + throw new RuntimeException("Query composer for '{$platform}' platform does not exist."); + } + + $bindingContainer = BindingContainerBuilder::create() + ->bindInstance(PDO::class, $this->pdoProvider->get()) + ->bindInstance(OrmMetadata::class, $this->ormMetadata) + ->bindInstance(EntityFactory::class, $this->entityFactory) + ->bindInstance(FunctionConverterFactory::class, $this->functionConverterFactory) + ->bindInstance(EventDispatcher::class, $this->eventDispatcher) + ->build(); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } +} diff --git a/application/Espo/Core/ORM/Repository/HookMediator.php b/application/Espo/Core/ORM/Repository/HookMediator.php new file mode 100644 index 0000000..70d03be --- /dev/null +++ b/application/Espo/Core/ORM/Repository/HookMediator.php @@ -0,0 +1,121 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\Repository; + +use Espo\ORM\Entity; +use Espo\ORM\Query\Select; +use Espo\ORM\Repository\EmptyHookMediator; +use Espo\Core\HookManager; +use Espo\Core\ORM\Repository\Option\SaveOption; + +class HookMediator extends EmptyHookMediator +{ + public function __construct(protected HookManager $hookManager) + {} + + /** + * @param ?array $columnData + * @param array $options + */ + public function afterRelate( + Entity $entity, + string $relationName, + Entity $foreignEntity, + ?array $columnData, + array $options + ): void { + + if (!empty($options[SaveOption::SKIP_HOOKS])) { + return; + } + + $hookData = [ + 'relationName' => $relationName, + 'relationData' => $columnData, + 'foreignEntity' => $foreignEntity, + 'foreignId' => $foreignEntity->getId(), + ]; + + $this->hookManager->process( + $entity->getEntityType(), + 'afterRelate', + $entity, + $options, + $hookData + ); + } + + /** + * @param array $options + */ + public function afterUnrelate(Entity $entity, string $relationName, Entity $foreignEntity, array $options): void + { + if (!empty($options[Option\SaveOption::SKIP_HOOKS])) { + return; + } + + $hookData = [ + 'relationName' => $relationName, + 'foreignEntity' => $foreignEntity, + 'foreignId' => $foreignEntity->getId(), + ]; + + $this->hookManager->process( + $entity->getEntityType(), + 'afterUnrelate', + $entity, + $options, + $hookData + ); + } + + /** + * @param array $options + */ + public function afterMassRelate(Entity $entity, string $relationName, Select $query, array $options): void + { + if (!empty($options[SaveOption::SKIP_HOOKS])) { + return; + } + + $hookData = [ + 'relationName' => $relationName, + 'query' => $query, + ]; + + $this->hookManager->process( + $entity->getEntityType(), + 'afterMassRelate', + $entity, + $options, + $hookData + ); + } +} diff --git a/application/Espo/Core/ORM/Repository/Option/RemoveOption.php b/application/Espo/Core/ORM/Repository/Option/RemoveOption.php new file mode 100644 index 0000000..2c3bdf3 --- /dev/null +++ b/application/Espo/Core/ORM/Repository/Option/RemoveOption.php @@ -0,0 +1,59 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\Repository\Option; + +/** + * Save options. + * + * @since 9.2.0 + */ +class RemoveOption +{ + /** + * Silent. Boolean. + * Skip stream notes, notifications, webhooks. + */ + public const SILENT = 'silent'; + + /** + * Called from a Record service. + */ + public const API = 'api'; + + /** + * When saved in Mass-Remove. + */ + public const MASS_REMOVE = 'massRemove'; + + /** + * Override modified-by. String. + */ + public const MODIFIED_BY_ID = 'modifiedById'; +} diff --git a/application/Espo/Core/ORM/Repository/Option/SaveOption.php b/application/Espo/Core/ORM/Repository/Option/SaveOption.php new file mode 100644 index 0000000..c527f89 --- /dev/null +++ b/application/Espo/Core/ORM/Repository/Option/SaveOption.php @@ -0,0 +1,117 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\Repository\Option; + +use Espo\ORM\Repository\Option\SaveOption as BaseSaveOption; + +/** + * Save options. + */ +class SaveOption +{ + /** + * Silent. Boolean. + * Skip stream notes, notifications, webhooks. + */ + public const SILENT = 'silent'; + /** + * Import. Boolean. + */ + public const IMPORT = 'import'; + /** + * Called from a Record service. + * @since 8.0.1 + */ + public const API = 'api'; + /** + * Skip all additional processing. Boolean. + */ + public const SKIP_ALL = BaseSaveOption::SKIP_ALL; + /** + * Keep new. Boolean. + */ + public const KEEP_NEW = BaseSaveOption::KEEP_NEW; + /** + * Keep dirty. Boolean. + */ + public const KEEP_DIRTY = BaseSaveOption::KEEP_DIRTY; + /** + * Keep an entity relations map. Boolean. + * @since 9.0.0 + */ + public const KEEP_RELATIONS = BaseSaveOption::KEEP_RELATIONS; + /** + * Skip hooks. Boolean. + */ + public const SKIP_HOOKS = 'skipHooks'; + /** + * Skip setting created-by. Boolean. + */ + public const SKIP_CREATED_BY = 'skipCreatedBy'; + /** + * Skip setting modified-by. Boolean. + */ + public const SKIP_MODIFIED_BY = 'skipModifiedBy'; + /** + * Override created-by. String. + */ + public const CREATED_BY_ID = 'createdById'; + /** + * Override modified-by. String. + */ + public const MODIFIED_BY_ID = 'modifiedById'; + /** + * A duplicate source ID. A record that is being duplicated. + * @since 8.4.0 + */ + public const DUPLICATE_SOURCE_ID = 'duplicateSourceId'; + + /** + * When saved in Mass-Update. + * @since 8.4.0 + */ + public const MASS_UPDATE = 'massUpdate'; + /** + * Skip stream notes. Boolean. + * @since 9.0.0 + */ + public const NO_STREAM = 'noStream'; + /** + * Skip notification. Boolean. + * @since 9.0.0 + */ + public const NO_NOTIFICATIONS = 'noNotifications'; + + /** + * Skip audit log records. + * @since 9.1.0 + */ + public const SKIP_AUDITED = 'skipAudited'; +} diff --git a/application/Espo/Core/ORM/RepositoryFactory.php b/application/Espo/Core/ORM/RepositoryFactory.php new file mode 100644 index 0000000..2e66abe --- /dev/null +++ b/application/Espo/Core/ORM/RepositoryFactory.php @@ -0,0 +1,78 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Binding\ContextualBinder; +use Espo\Core\InjectableFactory; +use Espo\ORM\Entity as Entity; +use Espo\ORM\EntityFactory as EntityFactoryInterface; +use Espo\ORM\Relation\RelationsMap; +use Espo\ORM\Repository\Repository; +use Espo\ORM\Repository\RepositoryFactory as RepositoryFactoryInterface; + +class RepositoryFactory implements RepositoryFactoryInterface +{ + public function __construct( + private EntityFactoryInterface $entityFactory, + private InjectableFactory $injectableFactory, + private ClassNameProvider $classNameProvider, + private RelationsMap $relationsMap, + ) {} + + public function create(string $entityType): Repository + { + $className = $this->getClassName($entityType); + + return $this->injectableFactory->createWithBinding( + $className, + BindingContainerBuilder::create() + ->bindInstance(EntityFactoryInterface::class, $this->entityFactory) + ->bindInstance(EntityFactory::class, $this->entityFactory) + ->bindInstance(RelationsMap::class, $this->relationsMap) + ->inContext( + $className, + function (ContextualBinder $binder) use ($entityType) { + $binder->bindValue('$entityType', $entityType); + } + ) + ->build() + ); + } + + /** + * @return class-string> + */ + private function getClassName(string $entityType): string + { + /** @var class-string> */ + return $this->classNameProvider->getRepositoryClassName($entityType); + } +} diff --git a/application/Espo/Core/ORM/Type/FieldType.php b/application/Espo/Core/ORM/Type/FieldType.php new file mode 100644 index 0000000..c99e819 --- /dev/null +++ b/application/Espo/Core/ORM/Type/FieldType.php @@ -0,0 +1,82 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM\Type; + +class FieldType +{ + public const string VARCHAR = 'varchar'; + public const string BOOL = 'bool'; + public const string TEXT = 'text'; + public const string INT = 'int'; + public const string FLOAT = 'float'; + public const string DATE = 'date'; + public const string DATETIME = 'datetime'; + public const string DATETIME_OPTIONAL = 'datetimeOptional'; + public const string ENUM = 'enum'; + public const string MULTI_ENUM = 'multiEnum'; + public const string ARRAY = 'array'; + public const string CHECKLIST = 'checklist'; + public const string CURRENCY = 'currency'; + public const string CURRENCY_CONVERTED = 'currencyConverted'; + public const string PERSON_NAME = 'personName'; + public const string ADDRESS = 'address'; + public const string EMAIL = 'email'; + public const string PHONE = 'phone'; + public const string AUTOINCREMENT = 'autoincrement'; + public const string URL = 'url'; + public const string NUMBER = 'number'; + public const string LINK = 'link'; + public const string LINK_ONE = 'linkOne'; + public const string LINK_PARENT = 'linkParent'; + public const string FILE = 'file'; + public const string IMAGE = 'image'; + public const string LINK_MULTIPLE = 'linkMultiple'; + public const string ATTACHMENT_MULTIPLE = 'attachmentMultiple'; + public const string FOREIGN = 'foreign'; + public const string WYSIWYG = 'wysiwyg'; + public const string JSON_ARRAY = 'jsonArray'; + public const string JSON_OBJECT = 'jsonObject'; + public const string PASSWORD = 'password'; + + /** + * @since 9.3.0 + */ + public const string DECIMAL = 'decimal'; + + /** + * @since 9.3.0 + */ + public const string URL_MULTIPLE = 'urlMultiple'; + + /** + * @since 9.3.0 + */ + public const string BARCODE = 'barcode'; +} diff --git a/application/Espo/Core/ORM/ValueFactoryFactory.php b/application/Espo/Core/ORM/ValueFactoryFactory.php new file mode 100644 index 0000000..f59854e --- /dev/null +++ b/application/Espo/Core/ORM/ValueFactoryFactory.php @@ -0,0 +1,86 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\ORM; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Metadata as OrmMetadata; +use Espo\ORM\Value\ValueFactory; +use Espo\ORM\Value\ValueFactoryFactory as ValueFactoryFactoryInteface; + +use RuntimeException; + +class ValueFactoryFactory implements ValueFactoryFactoryInteface +{ + public function __construct( + private Metadata $metadata, + private OrmMetadata $ormMetadata, + private InjectableFactory $injectableFactory + ) {} + + public function isCreatable(string $entityType, string $field): bool + { + return $this->getClassName($entityType, $field) !== null; + } + + public function create(string $entityType, string $field): ValueFactory + { + $className = $this->getClassName($entityType, $field); + + if (!$className) { + throw new RuntimeException("Could not get ValueFactory for '{$entityType}.{$field}'."); + } + + return $this->injectableFactory->create($className); + } + + /** + * @return ?class-string + */ + private function getClassName(string $entityType, string $field): ?string + { + $fieldDefs = $this->ormMetadata + ->getDefs() + ->getEntity($entityType) + ->getField($field); + + /** @var ?class-string $className */ + $className = $fieldDefs->getParam('valueFactoryClassName'); + + if ($className) { + return $className; + } + + $type = $fieldDefs->getType(); + + /** @var ?class-string */ + return $this->metadata->get(['fields', $type, 'valueFactoryClassName']); + } +} diff --git a/application/Espo/Core/PhoneNumber/InternationalValidator.php b/application/Espo/Core/PhoneNumber/InternationalValidator.php new file mode 100644 index 0000000..7004505 --- /dev/null +++ b/application/Espo/Core/PhoneNumber/InternationalValidator.php @@ -0,0 +1,91 @@ +. + * + * 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\PhoneNumber; + +use Brick\PhoneNumber\PhoneNumber; +use Brick\PhoneNumber\PhoneNumberParseException; +use Espo\Core\Utils\Metadata; + +/** + * @since 9.0.3 + */ +class InternationalValidator +{ + public function __construct( + private Metadata $metadata, + ) {} + + public function isValid(string $number, bool $allowExtension = false): bool + { + if ($number === '') { + return false; + } + + $pattern = $this->metadata->get(['app', 'regExpPatterns', 'phoneNumberLoose', 'pattern']); + + if (!$pattern) { + return true; + } + + $preparedPattern = '/^' . $pattern . '$/'; + + if (!preg_match($preparedPattern, $number)) { + return false; + } + + $ext = null; + + if ($allowExtension) { + [$number, $ext] = Util::splitExtension($number); + } + + if ($ext) { + if (!preg_match('/[0-9]+/', $ext)) { + return false; + } + + if (strlen($ext) > 6) { + return false; + } + } + + try { + $numberObj = PhoneNumber::parse($number); + } catch (PhoneNumberParseException) { + return false; + } + + if ((string) $numberObj !== $number) { + return false; + } + + return $numberObj->isPossibleNumber(); + } +} diff --git a/application/Espo/Core/PhoneNumber/Sanitizer.php b/application/Espo/Core/PhoneNumber/Sanitizer.php new file mode 100644 index 0000000..ddcc3c9 --- /dev/null +++ b/application/Espo/Core/PhoneNumber/Sanitizer.php @@ -0,0 +1,85 @@ +. + * + * 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\PhoneNumber; + +use Brick\PhoneNumber\PhoneNumber; +use Brick\PhoneNumber\PhoneNumberParseException; +use Espo\Core\Utils\Config; + +class Sanitizer +{ + public function __construct( + private Config $config + ) {} + + public function sanitize(string $value, ?string $countryCode = null): string + { + $value = trim($value); + + if (str_starts_with($value, '+')) { + if ($this->config->get('phoneNumberInternational')) { + return $this->parsePhoneNumber($value, null); + } + + return $value; + } + + if (!$countryCode) { + return $value; + } + + $code = strtoupper($countryCode); + + return $this->parsePhoneNumber($value, $code); + } + + private function parsePhoneNumber(string $value, ?string $countryCode): string + { + $ext = null; + + if ($this->config->get('phoneNumberExtensions')) { + [$value, $ext] = Util::splitExtension($value); + } + + try { + $number = PhoneNumber::parse($value, $countryCode); + } catch (PhoneNumberParseException) { + return $value; + } + + $output = (string) $number; + + if ($ext) { + $output .= ' ext. ' . $ext; + } + + return $output; + } +} diff --git a/application/Espo/Core/PhoneNumber/Util.php b/application/Espo/Core/PhoneNumber/Util.php new file mode 100644 index 0000000..bba7526 --- /dev/null +++ b/application/Espo/Core/PhoneNumber/Util.php @@ -0,0 +1,64 @@ +. + * + * 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\PhoneNumber; + +class Util +{ + /** + * @internal Do not use in custom code. + * @return array{string, ?string} + */ + public static function splitExtension(string $value): array + { + $ext = null; + + $delimiters = [ + 'ext.', + 'x.', + 'x', + '#', + ]; + + foreach ($delimiters as $delimiter) { + $index = strrpos($value, $delimiter); + + if ($index === false || $index < 2) { + continue; + } + + $ext = trim(substr($value, $index + strlen($delimiter))); + $value = trim(substr($value, 0, $index)); + + break; + } + + return [$value, $ext]; + } +} diff --git a/application/Espo/Core/Portal/Acl.php b/application/Espo/Core/Portal/Acl.php new file mode 100644 index 0000000..5a12a44 --- /dev/null +++ b/application/Espo/Core/Portal/Acl.php @@ -0,0 +1,104 @@ +. + * + * 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\Portal; + +use Espo\ORM\Entity; + +use Espo\Entities\User; + +use Espo\Core\Acl as BaseAcl; + +class Acl extends BaseAcl +{ + public function __construct(AclManager $aclManager, User $user) + { + parent::__construct($aclManager, $user); + } + + /** + * Whether 'read' access is set to 'account' for a specific scope. + */ + public function checkReadOnlyAccount(string $scope): bool + { + /** @var AclManager $aclManager */ + $aclManager = $this->aclManager; + + return $aclManager->checkReadOnlyAccount($this->user, $scope); + } + + /** + * Whether 'read' access is set to 'contact' for a specific scope. + */ + public function checkReadOnlyContact(string $scope): bool + { + /** @var AclManager $aclManager */ + $aclManager = $this->aclManager; + + return $aclManager->checkReadOnlyContact($this->user, $scope); + } + + /** + * Check whether an entity belongs to a user account. + */ + public function checkOwnershipAccount(Entity $entity): bool + { + /** @var AclManager $aclManager */ + $aclManager = $this->aclManager; + + return $aclManager->checkOwnershipAccount($this->user, $entity); + } + + /** + * Check whether an entity belongs to a user contact. + */ + public function checkOwnershipContact(Entity $entity): bool + { + /** @var AclManager $aclManager */ + $aclManager = $this->aclManager; + + return $aclManager->checkOwnershipContact($this->user, $entity); + } + + /** + * @deprecate + */ + public function checkInAccount(Entity $entity): bool + { + return $this->checkOwnershipAccount($entity); + } + + /** + * @deprecate + */ + public function checkIsOwnContact(Entity $entity): bool + { + return $this->checkOwnershipContact($entity); + } +} diff --git a/application/Espo/Core/Portal/Acl/AccessChecker/AccessCheckerFactory.php b/application/Espo/Core/Portal/Acl/AccessChecker/AccessCheckerFactory.php new file mode 100644 index 0000000..1e12ce2 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/AccessChecker/AccessCheckerFactory.php @@ -0,0 +1,108 @@ +. + * + * 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\Portal\Acl\AccessChecker; + +use Espo\Core\Acl\AccessChecker; +use Espo\Core\Acl\Exceptions\NotImplemented; +use Espo\Core\AclManager; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Portal\Acl\DefaultAccessChecker; +use Espo\Core\Portal\AclManager as PortalAclManager; +use Espo\Core\Utils\Metadata; + +class AccessCheckerFactory +{ + /** @var class-string */ + private $defaultClassName = DefaultAccessChecker::class; + + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + /** + * Create an access checker. + * + * @throws NotImplemented + */ + public function create(string $scope, PortalAclManager $aclManager): AccessChecker + { + $className = $this->getClassName($scope); + + $bindingContainer = $this->createBindingContainer($className, $aclManager, $scope); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + /** + * @return class-string + */ + private function getClassName(string $scope): string + { + /** @var ?class-string $className1 */ + $className1 = $this->metadata->get(['aclDefs', $scope, 'portalAccessCheckerClassName']); + + if ($className1) { + return $className1; + } + + if (!$this->metadata->get(['scopes', $scope])) { + throw new NotImplemented(); + } + + return $this->defaultClassName; + } + + /** + * @param class-string $className + */ + private function createBindingContainer( + string $className, + PortalAclManager $aclManager, + string $scope + ): BindingContainer { + + $bindingData = new BindingData(); + $binder = new Binder($bindingData); + + $binder + ->bindInstance(PortalAclManager::class, $aclManager) + ->bindInstance(AclManager::class, $aclManager); + + $binder + ->for($className) + ->bindValue('$entityType', $scope); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Portal/Acl/AccessChecker/ScopeChecker.php b/application/Espo/Core/Portal/Acl/AccessChecker/ScopeChecker.php new file mode 100644 index 0000000..145dbfa --- /dev/null +++ b/application/Espo/Core/Portal/Acl/AccessChecker/ScopeChecker.php @@ -0,0 +1,91 @@ +. + * + * 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\Portal\Acl\AccessChecker; + +use Espo\Core\Acl\ScopeData; +use Espo\Core\Portal\Acl\Table; + +/** + * Checks scope access. + */ +class ScopeChecker +{ + public function __construct() + {} + + public function check(ScopeData $data, ?string $action = null, ?ScopeCheckerData $checkerData = null): bool + { + if ($data->isFalse()) { + return false; + } + + if ($data->isTrue()) { + return true; + } + + if ($action === null) { + return true; + } + + $level = $data->get($action); + + if ($level === Table::LEVEL_ALL || $level === Table::LEVEL_YES) { + return true; + } + + if ($level === Table::LEVEL_NO) { + return false; + } + + if (!$checkerData) { + return false; + } + + if ($level === Table::LEVEL_OWN || $level === Table::LEVEL_ACCOUNT || $level === Table::LEVEL_CONTACT) { + if ($checkerData->isOwn()) { + return true; + } + } + + if ($level === Table::LEVEL_ACCOUNT || $level === Table::LEVEL_CONTACT) { + if ($checkerData->inContact()) { + return true; + } + } + + if ($level === Table::LEVEL_ACCOUNT) { + if ($checkerData->inAccount()) { + return true; + } + } + + return false; + } +} diff --git a/application/Espo/Core/Portal/Acl/AccessChecker/ScopeCheckerData.php b/application/Espo/Core/Portal/Acl/AccessChecker/ScopeCheckerData.php new file mode 100644 index 0000000..ed352d5 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/AccessChecker/ScopeCheckerData.php @@ -0,0 +1,64 @@ +. + * + * 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\Portal\Acl\AccessChecker; + +use Closure; + +/** + * Scope checker data. + */ +class ScopeCheckerData +{ + public function __construct( + private Closure $isOwnChecker, + private Closure $inAccountChecker, + private Closure $inContactChecker + ) {} + + public function isOwn(): bool + { + return ($this->isOwnChecker)(); + } + + public function inAccount(): bool + { + return ($this->inAccountChecker)(); + } + + public function inContact(): bool + { + return ($this->inContactChecker)(); + } + + public static function createBuilder(): ScopeCheckerDataBuilder + { + return new ScopeCheckerDataBuilder(); + } +} diff --git a/application/Espo/Core/Portal/Acl/AccessChecker/ScopeCheckerDataBuilder.php b/application/Espo/Core/Portal/Acl/AccessChecker/ScopeCheckerDataBuilder.php new file mode 100644 index 0000000..18d3bc1 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/AccessChecker/ScopeCheckerDataBuilder.php @@ -0,0 +1,143 @@ +. + * + * 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\Portal\Acl\AccessChecker; + +use Closure; + +/** + * Builds scope checker data. + */ +class ScopeCheckerDataBuilder +{ + private Closure $isOwnChecker; + private Closure $inAccountChecker; + private Closure $inContactChecker; + + public function __construct() + { + $this->isOwnChecker = function (): bool { + return false; + }; + + $this->inAccountChecker = function (): bool { + return false; + }; + + $this->inContactChecker = function (): bool { + return false; + }; + } + + public function setIsOwn(bool $value): self + { + if ($value) { + $this->isOwnChecker = function (): bool { + return true; + }; + + return $this; + } + + $this->isOwnChecker = function (): bool { + return false; + }; + + return $this; + } + + public function setInAccount(bool $value): self + { + if ($value) { + $this->inAccountChecker = function (): bool { + return true; + }; + + return $this; + } + + $this->inAccountChecker = function (): bool { + return false; + }; + + return $this; + } + + public function setInContact(bool $value): self + { + if ($value) { + $this->inContactChecker = function (): bool { + return true; + }; + + return $this; + } + + $this->inContactChecker = function (): bool { + return false; + }; + + return $this; + } + + /** + * @param Closure(): bool $checker + */ + public function setIsOwnChecker(Closure $checker): self + { + $this->isOwnChecker = $checker; + + return $this; + } + + /** + * @param Closure(): bool $checker + */ + public function setInAccountChecker(Closure $checker): self + { + $this->inAccountChecker = $checker; + + return $this; + } + + /** + * @param Closure(): bool $checker + */ + public function setInContactChecker(Closure $checker): self + { + $this->inContactChecker = $checker; + + return $this; + } + + public function build(): ScopeCheckerData + { + return new ScopeCheckerData($this->isOwnChecker, $this->inAccountChecker, $this->inContactChecker); + } +} diff --git a/application/Espo/Core/Portal/Acl/DefaultAccessChecker.php b/application/Espo/Core/Portal/Acl/DefaultAccessChecker.php new file mode 100644 index 0000000..bdce952 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/DefaultAccessChecker.php @@ -0,0 +1,157 @@ +. + * + * 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\Portal\Acl; + +use Espo\Entities\User; +use Espo\ORM\Entity; + +use Espo\Core\Acl\AccessEntityCreateChecker; +use Espo\Core\Acl\AccessEntityDeleteChecker; +use Espo\Core\Acl\AccessEntityEditChecker; +use Espo\Core\Acl\AccessEntityReadChecker; +use Espo\Core\Acl\AccessEntityStreamChecker; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Portal\Acl\AccessChecker\ScopeChecker; +use Espo\Core\Portal\Acl\AccessChecker\ScopeCheckerData; +use Espo\Core\Portal\AclManager as PortalAclManager; + +/** + * A default implementation for access checking for portal. + * + * @implements AccessEntityCreateChecker + * @implements AccessEntityReadChecker + * @implements AccessEntityEditChecker + * @implements AccessEntityDeleteChecker + * @implements AccessEntityStreamChecker + */ +class DefaultAccessChecker implements + + AccessEntityCreateChecker, + AccessEntityReadChecker, + AccessEntityEditChecker, + AccessEntityDeleteChecker, + AccessEntityStreamChecker +{ + public function __construct( + private PortalAclManager $aclManager, + private ScopeChecker $scopeChecker + ) {} + + private function checkEntity(User $user, Entity $entity, ScopeData $data, string $action): bool + { + $checkerData = ScopeCheckerData + ::createBuilder() + ->setIsOwnChecker( + function () use ($user, $entity): bool { + return $this->aclManager->checkOwnershipOwn($user, $entity); + } + ) + ->setInAccountChecker( + function () use ($user, $entity): bool { + return $this->aclManager->checkOwnershipAccount($user, $entity); + } + ) + ->setInContactChecker( + function () use ($user, $entity): bool { + return $this->aclManager->checkOwnershipContact($user, $entity); + } + ) + ->build(); + + return $this->scopeChecker->check($data, $action, $checkerData); + } + + private function checkScope(User $user, ScopeData $data, ?string $action = null): bool + { + $checkerData = ScopeCheckerData + ::createBuilder() + ->setIsOwn(true) + ->setInAccount(true) + ->setInContact(true) + ->build(); + + return $this->scopeChecker->check($data, $action, $checkerData); + } + + public function check(User $user, ScopeData $data): bool + { + return $this->checkScope($user, $data); + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return $this->checkScope($user, $data, Table::ACTION_CREATE); + } + + public function checkRead(User $user, ScopeData $data): bool + { + return $this->checkScope($user, $data, Table::ACTION_READ); + } + + public function checkEdit(User $user, ScopeData $data): bool + { + return $this->checkScope($user, $data, Table::ACTION_EDIT); + } + + public function checkDelete(User $user, ScopeData $data): bool + { + return $this->checkScope($user, $data, Table::ACTION_DELETE); + } + + public function checkStream(User $user, ScopeData $data): bool + { + return $this->checkScope($user, $data, Table::ACTION_STREAM); + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntity($user, $entity, $data, Table::ACTION_CREATE); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntity($user, $entity, $data, Table::ACTION_READ); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntity($user, $entity, $data, Table::ACTION_EDIT); + } + + public function checkEntityStream(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntity($user, $entity, $data, Table::ACTION_STREAM); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + return $this->checkEntity($user, $entity, $data, Table::ACTION_DELETE); + } +} diff --git a/application/Espo/Core/Portal/Acl/DefaultOwnershipChecker.php b/application/Espo/Core/Portal/Acl/DefaultOwnershipChecker.php new file mode 100644 index 0000000..9cc3f51 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/DefaultOwnershipChecker.php @@ -0,0 +1,189 @@ +. + * + * 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\Portal\Acl; + +use Espo\Core\Field\Link; +use Espo\Core\Field\LinkParent; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Portal\Acl\OwnershipChecker\MetadataProvider; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Entities\User; +use Espo\Core\Acl\OwnershipOwnChecker; +use Espo\ORM\Type\RelationType; + +/** + * A default implementation for ownership checking for portal. + * + * @implements OwnershipOwnChecker<\Espo\Core\ORM\Entity> + * @implements OwnershipAccountChecker<\Espo\Core\ORM\Entity> + * @implements OwnershipContactChecker<\Espo\Core\ORM\Entity> + */ +class DefaultOwnershipChecker implements + OwnershipOwnChecker, + OwnershipAccountChecker, + OwnershipContactChecker +{ + private const ATTR_CREATED_BY_ID = Field::CREATED_BY . 'Id'; + + public function __construct( + private EntityManager $entityManager, + private MetadataProvider $metadataProvider, + ) {} + + public function checkOwn(User $user, Entity $entity): bool + { + if ($entity->hasAttribute(self::ATTR_CREATED_BY_ID)) { + if ( + $entity->has(self::ATTR_CREATED_BY_ID) && + $user->getId() === $entity->get(self::ATTR_CREATED_BY_ID) + ) { + return true; + } + } + + return false; + } + + public function checkAccount(User $user, Entity $entity): bool + { + $linkDefs = $this->metadataProvider->getAccountLink($entity->getEntityType()); + + if (!$linkDefs) { + return false; + } + + $link = $linkDefs->getName(); + + $accountIds = $user->getAccounts()->getIdList(); + + if ($accountIds === []) { + return false; + } + + $fieldDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()) + ->tryGetField($link); + + if ( + $linkDefs->getType() === RelationType::BELONGS_TO && + $fieldDefs?->getType() === FieldType::LINK + ) { + $setAccountLink = $entity->getValueObject($link); + + if (!$setAccountLink instanceof Link) { + return false; + } + + return in_array($setAccountLink->getId(), $accountIds); + } + + if ( + $linkDefs->getType() === RelationType::BELONGS_TO_PARENT && + $fieldDefs?->getType() === FieldType::LINK_PARENT + ) { + $setLink = $entity->getValueObject($link); + + if (!$setLink instanceof LinkParent || $setLink->getEntityType() !== Account::ENTITY_TYPE) { + return false; + } + + return in_array($setLink->getId(), $accountIds); + } + + foreach ($accountIds as $accountId) { + $isRelated = $this->entityManager + ->getRelation($entity, $link) + ->isRelatedById($accountId); + + if ($isRelated) { + return true; + } + } + + return false; + } + + public function checkContact(User $user, Entity $entity): bool + { + $linkDefs = $this->metadataProvider->getContactLink($entity->getEntityType()); + + if (!$linkDefs) { + return false; + } + + $link = $linkDefs->getName(); + + $contactId = $user->getContactId(); + + if (!$contactId) { + return false; + } + + $fieldDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()) + ->tryGetField($link); + + if ( + $linkDefs->getType() === RelationType::BELONGS_TO && + $fieldDefs?->getType() === FieldType::LINK + ) { + $setContactLink = $entity->getValueObject($link); + + if (!$setContactLink instanceof Link) { + return false; + } + + return $setContactLink->getId() === $contactId; + } + + if ( + $linkDefs->getType() === RelationType::BELONGS_TO_PARENT && + $fieldDefs?->getType() === FieldType::LINK_PARENT + ) { + $setLink = $entity->getValueObject($link); + + if (!$setLink instanceof LinkParent || $setLink->getEntityType() !== Contact::ENTITY_TYPE) { + return false; + } + + return $setLink->getId() === $contactId; + } + + return$this->entityManager + ->getRelation($entity, $link) + ->isRelatedById($contactId); + } +} diff --git a/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php b/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php new file mode 100644 index 0000000..49355e3 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Events/PortalUserRoleUpdate.php @@ -0,0 +1,73 @@ +. + * + * 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\Portal\Acl\Events; + +use Espo\Core\Utils\Event\CrossInstanceEvent; +use stdClass; +use UnexpectedValueException; + +/** + * @since 10.1.0 + */ +class PortalUserRoleUpdate implements CrossInstanceEvent +{ + public function __construct( + public string $userId, + public string $portalId, + ) {} + + public static function fromPayload(stdClass $payload): self + { + $userId = $payload->userId ?? null; + + if (!is_string($userId)) { + throw new UnexpectedValueException("No user ID."); + } + + $portalId = $payload->portalId ?? null; + + if (!is_string($portalId)) { + throw new UnexpectedValueException("No portal ID."); + } + + return new self( + userId: $userId, + portalId: $portalId, + ); + } + + public function toPayload(): stdClass + { + return (object) [ + 'userId' => $this->userId, + 'portalId' => $this->portalId, + ]; + } +} diff --git a/application/Espo/Core/Portal/Acl/Map/CacheKeyProvider.php b/application/Espo/Core/Portal/Acl/Map/CacheKeyProvider.php new file mode 100644 index 0000000..607404f --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Map/CacheKeyProvider.php @@ -0,0 +1,45 @@ +. + * + * 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\Portal\Acl\Map; + +use Espo\Entities\Portal; +use Espo\Entities\User; +use Espo\Core\Acl\Map\CacheKeyProvider as CacheKeyProviderInterface; + +class CacheKeyProvider implements CacheKeyProviderInterface +{ + public function __construct(private User $user, private Portal $portal) + {} + + public function get(): string + { + return 'aclPortalMap/' . $this->portal->getId() . '/' . $this->user->getId(); + } +} diff --git a/application/Espo/Core/Portal/Acl/Map/MapFactory.php b/application/Espo/Core/Portal/Acl/Map/MapFactory.php new file mode 100644 index 0000000..37c200c --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Map/MapFactory.php @@ -0,0 +1,74 @@ +. + * + * 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\Portal\Acl\Map; + +use Espo\Entities\Portal; +use Espo\Entities\User; + +use Espo\Core\Acl\Map\CacheKeyProvider; +use Espo\Core\Acl\Map\Map; +use Espo\Core\Acl\Map\MetadataProvider; +use Espo\Core\Acl\Table; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Portal\Acl\Map\CacheKeyProvider as PortalCacheKeyProvider; +use Espo\Core\Portal\Acl\Map\MetadataProvider as PortalMetadataProvider; +use Espo\Core\Portal\Acl\Table as PortalTable; + +class MapFactory +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function create(User $user, PortalTable $table, Portal $portal): Map + { + $bindingContainer = $this->createBindingContainer($user, $table, $portal); + + return $this->injectableFactory->createWithBinding(Map::class, $bindingContainer); + } + + private function createBindingContainer(User $user, PortalTable $table, Portal $portal): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(User::class, $user) + ->bindInstance(Table::class, $table) + ->bindInstance(Portal::class, $portal) + ->bindImplementation(MetadataProvider::class, PortalMetadataProvider::class) + ->bindImplementation(CacheKeyProvider::class, PortalCacheKeyProvider::class); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Portal/Acl/Map/MetadataProvider.php b/application/Espo/Core/Portal/Acl/Map/MetadataProvider.php new file mode 100644 index 0000000..cc60225 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Map/MetadataProvider.php @@ -0,0 +1,37 @@ +. + * + * 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\Portal\Acl\Map; + +use Espo\Core\Acl\Map\MetadataProvider as BaseMetadataProvider; + +class MetadataProvider extends BaseMetadataProvider +{ + protected string $type = 'aclPortal'; +} diff --git a/application/Espo/Core/Portal/Acl/OwnershipAccountChecker.php b/application/Espo/Core/Portal/Acl/OwnershipAccountChecker.php new file mode 100644 index 0000000..7bf93d6 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/OwnershipAccountChecker.php @@ -0,0 +1,48 @@ +. + * + * 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\Portal\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +use Espo\Core\Acl\OwnershipChecker; + +/** + * @template TEntity of Entity + */ +interface OwnershipAccountChecker extends OwnershipChecker +{ + /** + * Check whether an entity belongs to a portal user account. + * + * @param TEntity $entity + */ + public function checkAccount(User $user, Entity $entity): bool; +} diff --git a/application/Espo/Core/Portal/Acl/OwnershipChecker/MetadataProvider.php b/application/Espo/Core/Portal/Acl/OwnershipChecker/MetadataProvider.php new file mode 100644 index 0000000..9b82450 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/OwnershipChecker/MetadataProvider.php @@ -0,0 +1,64 @@ +. + * + * 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\Portal\Acl\OwnershipChecker; + +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs; +use Espo\ORM\Defs\RelationDefs; + +class MetadataProvider +{ + public function __construct( + private Metadata $metadata, + private Defs $defs, + ) {} + + public function getAccountLink(string $entityType): ?RelationDefs + { + $link = $this->metadata->get("aclDefs.$entityType.accountLink"); + + if (!$link) { + return null; + } + + return $this->defs->getEntity($entityType)->tryGetRelation($link); + } + + public function getContactLink(string $entityType): ?RelationDefs + { + $link = $this->metadata->get("aclDefs.$entityType.contactLink"); + + if (!$link) { + return null; + } + + return $this->defs->getEntity($entityType)->tryGetRelation($link); + } +} diff --git a/application/Espo/Core/Portal/Acl/OwnershipChecker/OwnershipCheckerFactory.php b/application/Espo/Core/Portal/Acl/OwnershipChecker/OwnershipCheckerFactory.php new file mode 100644 index 0000000..94a74c5 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/OwnershipChecker/OwnershipCheckerFactory.php @@ -0,0 +1,109 @@ +. + * + * 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\Portal\Acl\OwnershipChecker; + +use Espo\Core\Acl\Exceptions\NotImplemented; +use Espo\Core\Acl\OwnershipChecker; +use Espo\Core\AclManager; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Portal\Acl\DefaultOwnershipChecker; +use Espo\Core\Portal\AclManager as PortalAclManager; +use Espo\Core\Utils\Metadata; + +class OwnershipCheckerFactory +{ + /** @var class-string */ + private $defaultClassName = DefaultOwnershipChecker::class; + + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + /** + * Create an ownership checker. + * + * @throws NotImplemented + */ + public function create(string $scope, PortalAclManager $aclManager): OwnershipChecker + { + $className = $this->getClassName($scope); + + $bindingContainer = $this->createBindingContainer($className, $aclManager, $scope); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + /** + * @return class-string + */ + private function getClassName(string $scope): string + { + $className = $this->metadata->get(['aclDefs', $scope, 'portalOwnershipCheckerClassName']); + + if ($className) { + /** @var class-string */ + return $className; + } + + if (!$this->metadata->get(['scopes', $scope])) { + throw new NotImplemented(); + } + + return $this->defaultClassName; + } + + /** + * @param class-string $className + */ + private function createBindingContainer( + string $className, + PortalAclManager $aclManager, + string $scope + ): BindingContainer { + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(PortalAclManager::class, $aclManager) + ->bindInstance(AclManager::class, $aclManager); + + $binder + ->for($className) + ->bindValue('$entityType', $scope); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Portal/Acl/OwnershipContactChecker.php b/application/Espo/Core/Portal/Acl/OwnershipContactChecker.php new file mode 100644 index 0000000..2514429 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/OwnershipContactChecker.php @@ -0,0 +1,48 @@ +. + * + * 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\Portal\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +use Espo\Core\Acl\OwnershipChecker; + +/** + * @template TEntity of Entity + */ +interface OwnershipContactChecker extends OwnershipChecker +{ + /** + * Check whether an entity belongs to a portal user contact. + * + * @param TEntity $entity + */ + public function checkContact(User $user, Entity $entity): bool; +} diff --git a/application/Espo/Core/Portal/Acl/Table.php b/application/Espo/Core/Portal/Acl/Table.php new file mode 100644 index 0000000..78baea3 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Table.php @@ -0,0 +1,110 @@ +. + * + * 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\Portal\Acl; + +use Espo\Core\Acl\Table\DefaultTable as BaseTable; + +use RuntimeException; +use stdClass; + +class Table extends BaseTable +{ + public const LEVEL_ACCOUNT = 'account'; + public const LEVEL_CONTACT = 'contact'; + + protected string $type = 'aclPortal'; + + /** + * @var string[] + */ + protected $levelList = [ + self::LEVEL_YES, + self::LEVEL_ALL, + self::LEVEL_ACCOUNT, + self::LEVEL_CONTACT, + self::LEVEL_OWN, + self::LEVEL_NO, + ]; + + /** + * @return string[] + */ + protected function getScopeWithAclList(): array + { + $scopeList = []; + + $scopes = $this->metadata->get('scopes'); + + foreach ($scopes as $scope => $item) { + if (empty($item['acl'])) { + continue; + } + + if (empty($item['aclPortal'])) { + continue; + } + + $scopeList[] = $scope; + } + + return $scopeList; + } + + protected function applyDefault(stdClass &$table, stdClass &$fieldTable): void + { + parent::applyDefault($table, $fieldTable); + + foreach ($this->getScopeList() as $scope) { + if (!isset($table->$scope)) { + $table->$scope = false; + } + } + } + + protected function applyDisabled(stdClass $table, stdClass $fieldTable): void + { + foreach ($this->getScopeList() as $scope) { + $item = $this->metadata->get(['scopes', $scope]) ?? []; + + if (!empty($item['disabled']) || !empty($item['portalDisabled'])) { + $table->$scope = false; + + unset($fieldTable->$scope); + } + } + } + + protected function stripScopes(stdClass $table, stdClass $fieldTable): void + { + if ($this->user->getScopes() !== null) { + throw new RuntimeException("Unexpected having scopes in portal user."); + } + } +} diff --git a/application/Espo/Core/Portal/Acl/Table/CacheKeyProvider.php b/application/Espo/Core/Portal/Acl/Table/CacheKeyProvider.php new file mode 100644 index 0000000..08e147d --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Table/CacheKeyProvider.php @@ -0,0 +1,46 @@ +. + * + * 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\Portal\Acl\Table; + +use Espo\Entities\Portal; +use Espo\Entities\User; + +use Espo\Core\Acl\Table\CacheKeyProvider as CacheKeyProviderInterface; + +class CacheKeyProvider implements CacheKeyProviderInterface +{ + public function __construct(private User $user, private Portal $portal) + {} + + public function get(): string + { + return 'aclPortal/' . $this->portal->getId() . '/' . $this->user->getId(); + } +} diff --git a/application/Espo/Core/Portal/Acl/Table/RoleListProvider.php b/application/Espo/Core/Portal/Acl/Table/RoleListProvider.php new file mode 100644 index 0000000..3467ffa --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Table/RoleListProvider.php @@ -0,0 +1,83 @@ +. + * + * 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\Portal\Acl\Table; + +use Espo\ORM\EntityManager; + +use Espo\Entities\Portal; +use Espo\Entities\PortalRole; +use Espo\Entities\User; +use Espo\Core\Acl\Table\Role; +use Espo\Core\Acl\Table\RoleEntityWrapper; +use Espo\Core\Acl\Table\RoleListProvider as RoleListProviderInterface; + +class RoleListProvider implements RoleListProviderInterface +{ + public function __construct( + private User $user, + private Portal $portal, + private EntityManager $entityManager + ) {} + + /** + * @return Role[] + */ + public function get(): array + { + $roleList = []; + + /** @var iterable $userRoleList */ + $userRoleList = $this->entityManager + ->getRDBRepository(User::ENTITY_TYPE) + ->getRelation($this->user, 'portalRoles') + ->find(); + + foreach ($userRoleList as $role) { + $roleList[] = $role; + } + + /** @var iterable $portalRoleList */ + $portalRoleList = $this->entityManager + ->getRDBRepository(Portal::ENTITY_TYPE) + ->getRelation($this->portal, 'portalRoles') + ->find(); + + foreach ($portalRoleList as $role) { + $roleList[] = $role; + } + + return array_map( + function (PortalRole $role): RoleEntityWrapper { + return new RoleEntityWrapper($role); + }, + $roleList + ); + } +} diff --git a/application/Espo/Core/Portal/Acl/Table/TableFactory.php b/application/Espo/Core/Portal/Acl/Table/TableFactory.php new file mode 100644 index 0000000..0084113 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Table/TableFactory.php @@ -0,0 +1,74 @@ +. + * + * 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\Portal\Acl\Table; + +use Espo\Entities\Portal; +use Espo\Entities\User; + +use Espo\Core\Acl\Table\CacheKeyProvider; +use Espo\Core\Acl\Table\RoleListProvider; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Portal\Acl\Table; +use Espo\Core\Portal\Acl\Table\CacheKeyProvider as PortalCacheKeyProvider; +use Espo\Core\Portal\Acl\Table\RoleListProvider as PortalRoleListProvider; + +class TableFactory +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + /** + * Create a table. + */ + public function create(User $user, Portal $portal): Table + { + $bindingContainer = $this->createBindingContainer($user, $portal); + + return $this->injectableFactory->createWithBinding(Table::class, $bindingContainer); + } + + private function createBindingContainer(User $user, Portal $portal): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(User::class, $user) + ->bindInstance(Portal::class, $portal) + ->bindImplementation(RoleListProvider::class, PortalRoleListProvider::class) + ->bindImplementation(CacheKeyProvider::class, PortalCacheKeyProvider::class); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Portal/Acl/Traits/DefaultAccessCheckerDependency.php b/application/Espo/Core/Portal/Acl/Traits/DefaultAccessCheckerDependency.php new file mode 100644 index 0000000..d74d84f --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Traits/DefaultAccessCheckerDependency.php @@ -0,0 +1,95 @@ +. + * + * 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\Portal\Acl\Traits; + +use Espo\ORM\Entity; +use Espo\Entities\User; +use Espo\Core\Acl\ScopeData; +use Espo\Core\Portal\Acl\DefaultAccessChecker; + +trait DefaultAccessCheckerDependency +{ + private DefaultAccessChecker $defaultAccessChecker; + + public function check(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->check($user, $data); + } + + public function checkCreate(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkCreate($user, $data); + } + + public function checkRead(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkRead($user, $data); + } + + public function checkEdit(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEdit($user, $data); + } + + public function checkDelete(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkDelete($user, $data); + } + + public function checkStream(User $user, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkStream($user, $data); + } + + public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data); + } + + public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityRead($user, $entity, $data); + } + + public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityEdit($user, $entity, $data); + } + + public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityDelete($user, $entity, $data); + } + + public function checkEntityStream(User $user, Entity $entity, ScopeData $data): bool + { + return $this->defaultAccessChecker->checkEntityStream($user, $entity, $data); + } +} diff --git a/application/Espo/Core/Portal/AclManager.php b/application/Espo/Core/Portal/AclManager.php new file mode 100644 index 0000000..3d9ab99 --- /dev/null +++ b/application/Espo/Core/Portal/AclManager.php @@ -0,0 +1,359 @@ +. + * + * 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\Portal; + +use Espo\Core\Acl\Permission; +use Espo\Core\Portal\Acl\Events\PortalUserRoleUpdate; +use Espo\Core\Utils\Event\EventDispatcher; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Entities\Portal; +use Espo\Entities\User; +use Espo\Core\Acl\GlobalRestriction; +use Espo\Core\Acl\Map\Map; +use Espo\Core\Acl\OwnerUserFieldProvider; +use Espo\Core\Acl\Table; +use Espo\Core\AclManager as InternalAclManager; +use Espo\Core\Portal\Acl\AccessChecker\AccessCheckerFactory as PortalAccessCheckerFactory; +use Espo\Core\Portal\Acl\Map\MapFactory as PortalMapFactory; +use Espo\Core\Portal\Acl\OwnershipAccountChecker; +use Espo\Core\Portal\Acl\OwnershipChecker\OwnershipCheckerFactory as PortalOwnershipCheckerFactory; +use Espo\Core\Portal\Acl\OwnershipContactChecker; +use Espo\Core\Portal\Acl\Table as PortalTable; +use Espo\Core\Portal\Acl\Table\TableFactory as PortalTableFactory; + +use stdClass; +use RuntimeException; + +class AclManager extends InternalAclManager +{ + /** @var class-string */ + protected $userAclClassName = Acl::class; + + private InternalAclManager $internalAclManager; + private ?Portal $portal = null; + private PortalTableFactory $portalTableFactory; + private PortalMapFactory $portalMapFactory; + + public function __construct( + PortalAccessCheckerFactory $accessCheckerFactory, + PortalOwnershipCheckerFactory $ownershipCheckerFactory, + PortalTableFactory $portalTableFactory, + PortalMapFactory $portalMapFactory, + GlobalRestriction $globalRestriction, + OwnerUserFieldProvider $ownerUserFieldProvider, + EntityManager $entityManager, + InternalAclManager $internalAclManager, + EventDispatcher $eventDispatcher, + ) { + $this->accessCheckerFactory = $accessCheckerFactory; + $this->ownershipCheckerFactory = $ownershipCheckerFactory; + $this->portalTableFactory = $portalTableFactory; + $this->portalMapFactory = $portalMapFactory; + $this->globalRestriction = $globalRestriction; + $this->ownerUserFieldProvider = $ownerUserFieldProvider; + $this->entityManager = $entityManager; + $this->internalAclManager = $internalAclManager; + $this->eventDispatcher = $eventDispatcher; + + $this->initEventHandling(); + } + + public function setPortal(Portal $portal): void + { + $this->portal = $portal; + } + + protected function getPortal(): Portal + { + if (!$this->portal) { + throw new RuntimeException("Portal is not set."); + } + + return $this->portal; + } + + protected function getTable(User $user): Table + { + $key = $user->hasId() ? $user->getId() : spl_object_hash($user); + + if (!array_key_exists($key, $this->tableHashMap)) { + $this->tableHashMap[$key] = $this->portalTableFactory->create($user, $this->getPortal()); + } + + return $this->tableHashMap[$key]; + } + + protected function getMap(User $user): Map + { + $key = $user->hasId() ? $user->getId() : spl_object_hash($user); + + if (!array_key_exists($key, $this->mapHashMap)) { + /** @var PortalTable $table */ + $table = $this->getTable($user); + + $this->mapHashMap[$key] = $this->portalMapFactory->create($user, $table, $this->getPortal()); + } + + return $this->mapHashMap[$key]; + } + + public function getMapData(User $user): stdClass + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->getMapData($user); + } + + return parent::getMapData($user); + } + + public function getLevel(User $user, string $scope, string $action): string + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->getLevel($user, $scope, $action); + } + + return parent::getLevel($user, $scope, $action); + } + + public function getPermissionLevel(User $user, string $permission): string + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->getPermissionLevel($user, $permission); + } + + return parent::getPermissionLevel($user, $permission); + } + + public function checkReadOnlyTeam(User $user, string $scope): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkReadOnlyTeam($user, $scope); + } + + return false; + } + + public function checkReadNo(User $user, string $scope): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkReadNo($user, $scope); + } + + return parent::checkReadNo($user, $scope); + } + + public function checkReadOnlyOwn(User $user, string $scope): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkReadOnlyOwn($user, $scope); + } + + return parent::checkReadOnlyOwn($user, $scope); + } + + public function checkReadAll(User $user, string $scope): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkReadAll($user, $scope); + } + + return parent::checkReadAll($user, $scope); + } + + /** + * Whether 'read' access is set to 'account' for a specific scope. + */ + public function checkReadOnlyAccount(User $user, string $scope): bool + { + return $this->getLevel($user, $scope, Table::ACTION_READ) === PortalTable::LEVEL_ACCOUNT; + } + + /** + * Whether 'read' access is set to 'contact' for a specific scope. + */ + public function checkReadOnlyContact(User $user, string $scope): bool + { + return $this->getLevel($user, $scope, Table::ACTION_READ)=== PortalTable::LEVEL_CONTACT; + } + + public function check(User $user, $subject, ?string $action = null): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->check($user, $subject, $action); + } + + return parent::check($user, $subject, $action); + } + + public function checkEntity(User $user, Entity $entity, string $action = Table::ACTION_READ): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkEntity($user, $entity, $action); + } + + return parent::checkEntity($user, $entity, $action); + } + + public function checkUserPermission(User $user, $target, string $permissionType = Permission::USER): bool + { + return $this->internalAclManager->checkUserPermission($user, $target, $permissionType); + } + + public function checkOwnershipOwn(User $user, Entity $entity): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkOwnershipOwn($user, $entity); + } + + return parent::checkOwnershipOwn($user, $entity); + } + + public function checkOwnershipShared(User $user, Entity $entity, string $action): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkOwnershipShared($user, $entity, $action); + } + + return false; + } + + public function checkOwnershipTeam(User $user, Entity $entity): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkOwnershipTeam($user, $entity); + } + + return false; + } + + /** + * Check whether an entity belongs to a user account. + */ + public function checkOwnershipAccount(User $user, Entity $entity): bool + { + $checker = $this->getOwnershipChecker($entity->getEntityType()); + + if (!$checker instanceof OwnershipAccountChecker) { + return false; + } + + return $checker->checkAccount($user, $entity); + } + + /** + * Check whether an entity belongs to a user account. + */ + public function checkOwnershipContact(User $user, Entity $entity): bool + { + $checker = $this->getOwnershipChecker($entity->getEntityType()); + + if (!$checker instanceof OwnershipContactChecker) { + return false; + } + + return $checker->checkContact($user, $entity); + } + + public function checkScope(User $user, string $scope, ?string $action = null): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkScope($user, $scope, $action); + } + + return parent::checkScope($user, $scope, $action); + } + + public function checkUser(User $user, string $permission, User $entity): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkUser($user, $permission, $entity); + } + + return parent::checkUser($user, $permission, $entity); + } + + public function getScopeForbiddenAttributeList( + User $user, + string $scope, + string $action = PortalTable::ACTION_READ, + string $thresholdLevel = PortalTable::LEVEL_NO + ): array { + + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager + ->getScopeForbiddenAttributeList($user, $scope, $action, $thresholdLevel); + } + + return parent::getScopeForbiddenAttributeList($user, $scope, $action, $thresholdLevel); + } + + public function getScopeForbiddenFieldList( + User $user, + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager + ->getScopeForbiddenFieldList($user, $scope, $action, $thresholdLevel); + } + + return parent::getScopeForbiddenFieldList($user, $scope, $action, $thresholdLevel); + } + + protected function checkUserIsNotPortal(User $user): bool + { + return !$user->isPortal(); + } + + /** + * @deprecated As of v6.0. Use `getPermissionLevel` instead. + */ + public function get(User $user, string $permission): string + { + return $this->getPermissionLevel($user, $permission); + } + + protected function initEventHandling(): void + { + $this->eventDispatcher->subscribe(PortalUserRoleUpdate::class, function (PortalUserRoleUpdate $event) { + if ($this->portal?->getId() !== $event->portalId) { + return; + } + + $userId = $event->userId; + + unset($this->tableHashMap[$userId]); + unset($this->mapHashMap[$userId]); + }); + } +} diff --git a/application/Espo/Core/Portal/AclManagerContainer.php b/application/Espo/Core/Portal/AclManagerContainer.php new file mode 100644 index 0000000..b4c9b55 --- /dev/null +++ b/application/Espo/Core/Portal/AclManagerContainer.php @@ -0,0 +1,68 @@ +. + * + * 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\Portal; + +use Espo\Entities\Portal; +use Espo\Core\InjectableFactory; + +use LogicException; + +/** + * Used when logged to CRM (not to portal) to provide an access checking ability for a specific portal. + * E.g. check whether a portal user has access to some record within a specific portal. + */ +class AclManagerContainer +{ + /** + * @var array + */ + private $data = []; + + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function get(Portal $portal): AclManager + { + if (!$portal->hasId()) { + throw new LogicException("AclManagerContainer: portal should have ID."); + } + + $id = $portal->getId(); + + if (!isset($this->data[$id])) { + $aclManager = $this->injectableFactory->create(AclManager::class); + $aclManager->setPortal($portal); + + $this->data[$id] = $aclManager; + } + + return $this->data[$id]; + } +} diff --git a/application/Espo/Core/Portal/Api/Starter.php b/application/Espo/Core/Portal/Api/Starter.php new file mode 100644 index 0000000..e066db5 --- /dev/null +++ b/application/Espo/Core/Portal/Api/Starter.php @@ -0,0 +1,66 @@ +. + * + * 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\Portal\Api; + +use Espo\Core\Api\MiddlewareProvider; +use Espo\Core\Api\Starter as StarterBase; +use Espo\Core\ApplicationState; +use Espo\Core\Portal\Utils\Route as RouteUtil; +use Espo\Core\Api\RouteProcessor; +use Espo\Core\Api\Route\RouteParamsFetcher; +use Espo\Core\Utils\Config\SystemConfig; +use Espo\Core\Utils\Log; + +class Starter extends StarterBase +{ + public function __construct( + RouteProcessor $requestProcessor, + RouteUtil $routeUtil, + RouteParamsFetcher $routeParamsFetcher, + MiddlewareProvider $middlewareProvider, + Log $log, + SystemConfig $systemConfig, + ApplicationState $applicationState + ) { + $part = basename($applicationState->getPortalId()); + + $routeCacheFile = 'data/cache/application/slim-routes-portal-' . $part . '.php'; + + parent::__construct( + $requestProcessor, + $routeUtil, + $routeParamsFetcher, + $middlewareProvider, + $log, + $systemConfig, + $routeCacheFile + ); + } +} diff --git a/application/Espo/Core/Portal/Application.php b/application/Espo/Core/Portal/Application.php new file mode 100644 index 0000000..ede37ef --- /dev/null +++ b/application/Espo/Core/Portal/Application.php @@ -0,0 +1,127 @@ +. + * + * 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\Portal; + +use Espo\Core\Application\ApplicationParams; +use Espo\Entities\Portal; +use Espo\ORM\EntityManager; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Application as BaseApplication; +use Espo\Core\Container\ContainerBuilder; +use Espo\Core\Portal\Container as PortalContainer; +use Espo\Core\Portal\Container\ContainerConfiguration as PortalContainerConfiguration; +use Espo\Core\Portal\Utils\Config; +use LogicException; + +class Application extends BaseApplication +{ + /** + * @throws Forbidden + * @throws NotFound + * @noinspection PhpMissingParentConstructorInspection + */ + public function __construct( + ?string $portalId, + ?ApplicationParams $params = null, + ) { + date_default_timezone_set('UTC'); + + $this->initContainer($params); + $this->initPortal($portalId); + $this->initAutoloads(); + $this->initPreloads(); + } + + protected function initContainer(?ApplicationParams $params): void + { + $container = (new ContainerBuilder()) + ->withConfigClassName(Config::class) + ->withContainerClassName(PortalContainer::class) + ->withContainerConfigurationClassName(PortalContainerConfiguration::class) + ->withParams($params) + ->build(); + + if (!$container instanceof PortalContainer) { + throw new LogicException("Wrong container created."); + } + + $this->container = $container; + } + + /** + * @throws Forbidden + * @throws NotFound + */ + protected function initPortal(?string $portalId): void + { + if (!$portalId) { + throw new LogicException("Portal ID was not passed to Portal\Application."); + } + + $entityManager = $this->container->getByClass(EntityManager::class); + + $portal = $entityManager->getEntityById(Portal::ENTITY_TYPE, $portalId); + + if (!$portal) { + $portal = $entityManager + ->getRDBRepositoryByClass(Portal::class) + ->where(['customId' => $portalId]) + ->findOne(); + } + + if (!$portal) { + throw new NotFound("Portal $portalId not found."); + } + + if (!$portal->isActive()) { + throw new Forbidden("Portal $portalId is not active."); + } + + $container = $this->container; + + if (!$container instanceof PortalContainer) { + throw new LogicException(); + } + + $container->setPortal($portal); + } + + protected function initPreloads(): void + { + parent::initPreloads(); + + foreach ($this->getMetadata()->get(['app', 'portalContainerServices']) ?? [] as $name => $defs) { + if ($defs['preload'] ?? false) { + $this->container->get($name); + } + } + } +} diff --git a/application/Espo/Core/Portal/ApplicationRunners/Api.php b/application/Espo/Core/Portal/ApplicationRunners/Api.php new file mode 100644 index 0000000..f9b80f5 --- /dev/null +++ b/application/Espo/Core/Portal/ApplicationRunners/Api.php @@ -0,0 +1,44 @@ +. + * + * 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\Portal\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\Portal\Api\Starter; + +class Api implements Runner +{ + public function __construct(private Starter $starter) + {} + + public function run(): void + { + $this->starter->start(); + } +} diff --git a/application/Espo/Core/Portal/ApplicationRunners/Client.php b/application/Espo/Core/Portal/ApplicationRunners/Client.php new file mode 100644 index 0000000..571b932 --- /dev/null +++ b/application/Espo/Core/Portal/ApplicationRunners/Client.php @@ -0,0 +1,57 @@ +. + * + * 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\Portal\ApplicationRunners; + +use Espo\Core\Application\Runner; +use Espo\Core\ApplicationState; +use Espo\Core\Utils\ClientManager; + +/** + * Displays the main HTML page for a portal. + */ +class Client implements Runner +{ + public function __construct( + private ClientManager $clientManager, + private ApplicationState $applicationState + ) {} + + public function run(): void + { + $portalId = $this->applicationState->getPortal()->getId(); + + $this->clientManager->display(null, null, [ + 'portalId' => $portalId, + 'applicationId' => $portalId, + 'apiUrl' => 'api/v1/portal-access/' . $portalId, + 'appClientClassName' => 'app-portal', + ]); + } +} diff --git a/application/Espo/Core/Portal/Container.php b/application/Espo/Core/Portal/Container.php new file mode 100644 index 0000000..9439dd2 --- /dev/null +++ b/application/Espo/Core/Portal/Container.php @@ -0,0 +1,81 @@ +. + * + * 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\Portal; + +use Espo\Core\Container\Exceptions\NotSettableException; +use Espo\Entities\Portal as PortalEntity; +use Espo\Core\Portal\Utils\Config; +use Espo\Core\Container as BaseContainer; + +use Psr\Container\NotFoundExceptionInterface; + +use LogicException; + +class Container extends BaseContainer +{ + private const ID_PORTAL = 'portal'; + private const ID_CONFIG = 'config'; + private const ID_ACL_MANAGER = 'aclManager'; + + private bool $portalIsSet = false; + + /** + * @throws NotSettableException + */ + public function setPortal(PortalEntity $portal): void + { + if ($this->portalIsSet) { + throw new NotSettableException("Can't set portal second time."); + } + + $this->portalIsSet = true; + + $this->setForced(self::ID_PORTAL, $portal); + + $data = []; + + foreach ($portal->getSettingsAttributeList() as $attribute) { + $data[$attribute] = $portal->get($attribute); + } + + try { + /** @var Config $config */ + $config = $this->get(self::ID_CONFIG); + $config->setPortalParameters($data); + + /** @var AclManager $aclManager */ + $aclManager = $this->get(self::ID_ACL_MANAGER); + } catch (NotFoundExceptionInterface) { + throw new LogicException(); + } + + $aclManager->setPortal($portal); + } +} diff --git a/application/Espo/Core/Portal/Container/ContainerConfiguration.php b/application/Espo/Core/Portal/Container/ContainerConfiguration.php new file mode 100644 index 0000000..5cb652a --- /dev/null +++ b/application/Espo/Core/Portal/Container/ContainerConfiguration.php @@ -0,0 +1,88 @@ +. + * + * 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\Portal\Container; + +use Espo\Core\Container\ContainerConfiguration as BaseContainerConfiguration; + +class ContainerConfiguration extends BaseContainerConfiguration +{ + /** + * @return ?class-string + */ + public function getLoaderClassName(string $name): ?string + { + $className = null; + + try { + $className = $this->metadata->get(['app', 'portalContainerServices', $name, 'loaderClassName']); + } catch (\Exception) {} + + if ($className && class_exists($className)) { + return $className; + } + + $className = 'Espo\Custom\Core\Portal\Loaders\\' . ucfirst($name); + if (!class_exists($className)) { + $className = 'Espo\Core\Portal\Loaders\\' . ucfirst($name); + } + + if (class_exists($className)) { + return $className; + } + + return parent::getLoaderClassName($name); + } + + /** + * @return ?class-string + */ + public function getServiceClassName(string $name): ?string + { + return $this->metadata->get(['app', 'portalContainerServices', $name, 'className']) ?? + parent::getServiceClassName($name); + } + + /** + * @return ?string[] + */ + public function getServiceDependencyList(string $name): ?array + { + return + $this->metadata->get(['app', 'portalContainerServices', $name, 'dependencyList']) ?? + parent::getServiceDependencyList($name); + } + + public function isSettable(string $name): bool + { + return + $this->metadata->get(['app', 'portalContainerServices', $name, 'settable']) ?? + parent::isSettable($name); + } +} diff --git a/application/Espo/Core/Portal/Loaders/Acl.php b/application/Espo/Core/Portal/Loaders/Acl.php new file mode 100644 index 0000000..3668766 --- /dev/null +++ b/application/Espo/Core/Portal/Loaders/Acl.php @@ -0,0 +1,59 @@ +. + * + * 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\Portal\Loaders; + +use Espo\Core\Portal\AclManager as PortalAclManager; +use Espo\Core\AclManager; +use Espo\Core\Container\Loader; +use Espo\Core\Portal\Acl as AclService; +use Espo\Entities\User; + +use InvalidArgumentException; + +class Acl implements Loader +{ + private PortalAclManager $aclManager; + private User $user; + + public function __construct(AclManager $aclManager, User $user) + { + if (!$aclManager instanceof PortalAclManager) { + throw new InvalidArgumentException(); + } + + $this->aclManager = $aclManager; + $this->user = $user; + } + + public function load(): AclService + { + return new AclService($this->aclManager, $this->user); + } +} diff --git a/application/Espo/Core/Portal/Loaders/AclManager.php b/application/Espo/Core/Portal/Loaders/AclManager.php new file mode 100644 index 0000000..0581fb7 --- /dev/null +++ b/application/Espo/Core/Portal/Loaders/AclManager.php @@ -0,0 +1,50 @@ +. + * + * 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\Portal\Loaders; + +use Espo\Core\AclManager as InternalAclManager; +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Portal\AclManager as PortalAclManager; + +class AclManager implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + private InternalAclManager $internalAclManager + ) {} + + public function load(): PortalAclManager + { + return $this->injectableFactory->createWith(PortalAclManager::class,[ + 'internalAclManager' => $this->internalAclManager, + ]); + } +} diff --git a/application/Espo/Core/Portal/Loaders/InternalAclManager.php b/application/Espo/Core/Portal/Loaders/InternalAclManager.php new file mode 100644 index 0000000..26b4b5a --- /dev/null +++ b/application/Espo/Core/Portal/Loaders/InternalAclManager.php @@ -0,0 +1,45 @@ +. + * + * 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\Portal\Loaders; + +use Espo\Core\AclManager as InternalAclManagerService; +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; + +class InternalAclManager implements Loader +{ + public function __construct(private InjectableFactory $injectableFactory) + {} + + public function load(): InternalAclManagerService + { + return $this->injectableFactory->create(InternalAclManagerService::class); + } +} diff --git a/application/Espo/Core/Portal/Loaders/Language.php b/application/Espo/Core/Portal/Loaders/Language.php new file mode 100644 index 0000000..05354ad --- /dev/null +++ b/application/Espo/Core/Portal/Loaders/Language.php @@ -0,0 +1,54 @@ +. + * + * 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\Portal\Loaders; + +use Espo\Core\Container\Loader; +use Espo\Core\InjectableFactory; +use Espo\Core\Portal\Utils\Language as LanguageService; +use Espo\Core\Utils\Config; + +use Espo\Entities\Preferences; + +class Language implements Loader +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Config $config, + private Preferences $preferences + ) {} + + public function load(): LanguageService + { + return $this->injectableFactory->createWith(LanguageService::class, [ + 'language' => LanguageService::detectLanguage($this->config, $this->preferences), + 'useCache' => $this->config->get('useCache') ?? false, + ]); + } +} diff --git a/application/Espo/Core/Portal/Utils/Config.php b/application/Espo/Core/Portal/Utils/Config.php new file mode 100644 index 0000000..cf6ef90 --- /dev/null +++ b/application/Espo/Core/Portal/Utils/Config.php @@ -0,0 +1,160 @@ +. + * + * 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\Portal\Utils; + +use Espo\Core\Utils\Config as BaseConfig; + +use RuntimeException; +use stdClass; + +class Config extends BaseConfig +{ + private bool $portalParamsSet = false; + + /** + * @var array + */ + private $portalData = []; + + /** + * @var string[] + */ + private $portalParamList = [ + 'applicationName', + 'companyLogoId', + 'tabList', + 'quickCreateList', + 'dashboardLayout', + 'dashletsOptions', + 'theme', + 'themeParams', + 'language', + 'timeZone', + 'dateFormat', + 'timeFormat', + 'weekStart', + 'defaultCurrency', + ]; + + /** + * @param mixed $default + * @return mixed + */ + public function get(string $name, $default = null) + { + if (array_key_exists($name, $this->portalData)) { + return $this->portalData[$name]; + } + + return parent::get($name, $default); + } + + public function has(string $name): bool + { + if (array_key_exists($name, $this->portalData)) { + return true; + } + + return parent::has($name); + } + + public function getAllNonInternalData(): stdClass + { + $data = parent::getAllNonInternalData(); + + foreach ($this->portalData as $k => $v) { + $data->$k = $v; + } + + return $data; + } + + /** + * Override parameters for a portal. Can be called only once. + * + * @param array $data + */ + public function setPortalParameters(array $data = []): void + { + if ($this->portalParamsSet) { + throw new RuntimeException("Can't set portal params second time."); + } + + $this->portalParamsSet = true; + + if (empty($data['applicationName'])) { + unset($data['applicationName']); + } + + if (empty($data['language'])) { + unset($data['language']); + } + + if (empty($data['theme'])) { + unset($data['theme']); + } + + if (empty($data['timeZone'])) { + unset($data['timeZone']); + } + + if (empty($data['dateFormat'])) { + unset($data['dateFormat']); + } + + if (empty($data['timeFormat'])) { + unset($data['timeFormat']); + } + + if (empty($data['defaultCurrency'])) { + unset($data['defaultCurrency']); + } + + if (isset($data['weekStart']) && $data['weekStart'] === -1) { + unset($data['weekStart']); + } + + if (array_key_exists('weekStart', $data) && is_null($data['weekStart'])) { + unset($data['weekStart']); + } + + if ($this->get('webSocketInPortalDisabled')) { + $this->portalData['useWebSocket'] = false; + } + + foreach ($data as $attribute => $value) { + if (!in_array($attribute, $this->portalParamList)) { + continue; + } + + $this->portalData[$attribute] = $value; + } + } +} diff --git a/application/Espo/Core/Portal/Utils/Language.php b/application/Espo/Core/Portal/Utils/Language.php new file mode 100644 index 0000000..503fdf2 --- /dev/null +++ b/application/Espo/Core/Portal/Utils/Language.php @@ -0,0 +1,32 @@ +. + * + * 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\Portal\Utils; + +class Language extends \Espo\Core\Utils\Language {} diff --git a/application/Espo/Core/Portal/Utils/Route.php b/application/Espo/Core/Portal/Utils/Route.php new file mode 100644 index 0000000..f5c3cf9 --- /dev/null +++ b/application/Espo/Core/Portal/Utils/Route.php @@ -0,0 +1,67 @@ +. + * + * 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\Portal\Utils; + +use Espo\Core\Api\Route as RouteItem; +use Espo\Core\Utils\Route as BaseRoute; + +class Route extends BaseRoute +{ + public function getFullList(): array + { + $originalRouteList = parent::getFullList(); + + $newRouteList = []; + + foreach ($originalRouteList as $route) { + $path = $route->getAdjustedRoute(); + + if ($path[0] !== '/') { + $path = '/' . $path; + } + + $path = '/{portalId}' . $path; + + $newRoute = new RouteItem( + method: $route->getMethod(), + route: $route->getRoute(), + adjustedRoute: $path, + params: $route->getParams(), + noAuth: $route->noAuth(), + consumes: $route->getConsumes(), + actionClassName: $route->getActionClassName(), + ); + + $newRouteList[] = $newRoute; + } + + return $newRouteList; + } +} diff --git a/application/Espo/Core/Portal/Utils/ThemeManager.php b/application/Espo/Core/Portal/Utils/ThemeManager.php new file mode 100644 index 0000000..7bcc5c2 --- /dev/null +++ b/application/Espo/Core/Portal/Utils/ThemeManager.php @@ -0,0 +1,63 @@ +. + * + * 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\Portal\Utils; + +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\Theme\MetadataProvider; +use Espo\Entities\Portal; +use Espo\Core\Utils\ThemeManager as BaseThemeManager; + +class ThemeManager extends BaseThemeManager +{ + private Portal $portal; + + public function __construct( + Config $config, + Metadata $metadata, + MetadataProvider $metadataProvider, + Portal $portal, + ) { + parent::__construct($config, $metadata, $metadataProvider); + + $this->portal = $portal; + } + + public function getName(): string + { + $theme = $this->portal->get('theme'); + + if ($theme) { + return $theme; + } + + return parent::getName(); + } +} diff --git a/application/Espo/Core/Portal/Utils/Url.php b/application/Espo/Core/Portal/Utils/Url.php new file mode 100644 index 0000000..b4dd2ab --- /dev/null +++ b/application/Espo/Core/Portal/Utils/Url.php @@ -0,0 +1,161 @@ +. + * + * 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\Portal\Utils; + +class Url +{ + public static function detectPortalIdForApi(): ?string + { + $portalId = filter_input(INPUT_GET, 'portalId'); + + if ($portalId) { + return $portalId; + } + + $url = $_SERVER['REQUEST_URI'] ?? null; + $scriptName = $_SERVER['SCRIPT_NAME']; + + if (!$url) { + return null; + } + + $scriptNameModified = str_replace('public/api/', 'api/', $scriptName); + + return explode('/', $url)[count(explode('/', $scriptNameModified)) - 1] ?? null; + } + + public static function getPortalIdFromEnv(): ?string + { + return $_SERVER['ESPO_PORTAL_ID'] ?? null; + } + + public static function detectPortalId(): ?string + { + $portalId = self::getPortalIdFromEnv(); + + if ($portalId) { + return $portalId; + } + + $url = $_SERVER['REQUEST_URI'] ?? null; + $scriptName = $_SERVER['SCRIPT_NAME']; + + $scriptNameModified = str_replace('public/api/', 'api/', $scriptName); + + $idIndex = count(explode('/', $scriptNameModified)) - 1; + + if ($url) { + $portalId = explode('/', $url)[$idIndex] ?? null; + + if (str_contains($url, '=')) { + $portalId = null; + } + } + + if ($portalId) { + return $portalId; + } + + $url = $_SERVER['REDIRECT_URL'] ?? null; + + if (!$url) { + return null; + } + + $portalId = explode('/', $url)[$idIndex] ?? null; + + if ($portalId === '') { + $portalId = null; + } + + return $portalId; + } + + protected static function detectIsCustomUrl(): bool + { + return (bool) ($_SERVER['ESPO_PORTAL_IS_CUSTOM_URL'] ?? false); + } + + public static function detectIsInPortalDir(): bool + { + $isCustomUrl = self::detectIsCustomUrl(); + + if ($isCustomUrl) { + return false; + } + + $a = explode('?', $_SERVER['REQUEST_URI']); + + $url = rtrim($a[0], '/'); + + return str_contains($url, '/portal'); + } + + public static function detectIsInPortalWithId(): bool + { + if (!self::detectIsInPortalDir()) { + return false; + } + + $url = $_SERVER['REQUEST_URI']; + + $a = explode('?', $url); + + $url = rtrim($a[0], '/'); + + $folders = explode('/', $url); + + if (count($folders) > 1 && $folders[count($folders) - 2] === 'portal') { + return true; + } + + return false; + } + + public static function getRedirectUrlWithTrailingSlash(): ?string + { + $uri = $_SERVER['REQUEST_URI'] ?? ''; + $path = explode('?', $uri, 2)[0]; + + if ($path === '' || $path === '/' || str_ends_with($path, '/')) { + return null; + } + + $output = $path . '/'; + + $queryString = $_SERVER['QUERY_STRING'] ?? null; + + if ($queryString !== null && $queryString !== '') { + $output .= '?' . $queryString; + } + + return $output; + } +} diff --git a/application/Espo/Core/Rebuild/Actions/AddSystemData.php b/application/Espo/Core/Rebuild/Actions/AddSystemData.php new file mode 100644 index 0000000..157f2e1 --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/AddSystemData.php @@ -0,0 +1,52 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Entities\SystemData; +use Espo\ORM\EntityManager; + +class AddSystemData implements RebuildAction +{ + public function __construct( + private EntityManager $entityManager + ) {} + + public function process(): void + { + $entity = $this->entityManager->getEntityById(SystemData::ENTITY_TYPE, SystemData::ONLY_ID); + + if ($entity) { + return; + } + + $this->entityManager->createEntity(SystemData::ENTITY_TYPE, ['id' => SystemData::ONLY_ID]); + } +} diff --git a/application/Espo/Core/Rebuild/Actions/AddSystemState.php b/application/Espo/Core/Rebuild/Actions/AddSystemState.php new file mode 100644 index 0000000..05618f9 --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/AddSystemState.php @@ -0,0 +1,53 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Entities\SystemState; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +class AddSystemState implements RebuildAction +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function process(): void + { + $entity = $this->entityManager->getEntityById(SystemState::ENTITY_TYPE, SystemState::ID_VALUE); + + if ($entity) { + return; + } + + $this->entityManager->createEntity(SystemState::ENTITY_TYPE, [Attribute::ID => SystemState::ID_VALUE]); + } +} diff --git a/application/Espo/Core/Rebuild/Actions/AddSystemUser.php b/application/Espo/Core/Rebuild/Actions/AddSystemUser.php new file mode 100644 index 0000000..9a3d594 --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/AddSystemUser.php @@ -0,0 +1,86 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\SystemUser; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +class AddSystemUser implements RebuildAction +{ + public function __construct( + private EntityManager $entityManager, + private Config $config, + private SystemUser $systemUser + ) {} + + public function process(): void + { + $repository = $this->entityManager->getRDBRepositoryByClass(User::class); + + $user = $repository + ->where(['userName' => SystemUser::NAME]) + ->findOne(); + + if ($user) { + if ($user->getId() === $this->systemUser->getId()) { + return; + } + + $this->entityManager + ->getQueryExecutor() + ->execute( + $this->entityManager + ->getQueryBuilder() + ->delete() + ->from(User::ENTITY_TYPE) + ->where([Attribute::ID => $user->getId()]) + ->build() + ); + } + + /** @var array $attributes */ + $attributes = $this->config->get('systemUserAttributes'); + + $user = $repository->getNew(); + + $user + ->set(Attribute::ID, $this->systemUser->getId()) + ->setUserName(SystemUser::NAME) + ->setType(User::TYPE_SYSTEM); + + $user->setMultiple($attributes); + + $repository->save($user); + } +} diff --git a/application/Espo/Core/Rebuild/Actions/ConfigMetadataCheck.php b/application/Espo/Core/Rebuild/Actions/ConfigMetadataCheck.php new file mode 100644 index 0000000..e0654c6 --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/ConfigMetadataCheck.php @@ -0,0 +1,70 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Rebuild\RebuildAction; + +use Espo\Core\Utils\Config\Access; +use Espo\Core\Utils\Metadata; + +/** + * Check whether config metadata is valid. + */ +class ConfigMetadataCheck implements RebuildAction +{ + public function __construct(private Metadata $metadata) + {} + + /** + * @throws Error + */ + public function process(): void + { + $levelList = [ + Access::LEVEL_DEFAULT, + Access::LEVEL_INTERNAL, + Access::LEVEL_ADMIN, + Access::LEVEL_GLOBAL, + Access::LEVEL_SUPER_ADMIN, + Access::LEVEL_SYSTEM, + ]; + + $params = $this->metadata->get(['app', 'config', 'params']) ?? []; + + foreach ($params as $name => $item) { + $level = $item['level'] ?? null; + + if ($level !== null && !in_array($level, $levelList)) { + throw new Error("Config parameter '{$name}' has not a allowed level in app > config."); + } + } + } +} diff --git a/application/Espo/Core/Rebuild/Actions/CurrencyRates.php b/application/Espo/Core/Rebuild/Actions/CurrencyRates.php new file mode 100644 index 0000000..b99a763 --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/CurrencyRates.php @@ -0,0 +1,46 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Core\Utils\Currency\DatabasePopulator; + +/** + * @noinspection PhpUnused + */ +class CurrencyRates implements RebuildAction +{ + public function __construct(private DatabasePopulator $databasePopulator) {} + + public function process(): void + { + $this->databasePopulator->process(); + } +} diff --git a/application/Espo/Core/Rebuild/Actions/GenerateInstanceId.php b/application/Espo/Core/Rebuild/Actions/GenerateInstanceId.php new file mode 100644 index 0000000..ba6339b --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/GenerateInstanceId.php @@ -0,0 +1,57 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Util; + +/** + * @noinspection PhpUnused + */ +class GenerateInstanceId implements RebuildAction +{ + public function __construct( + private Config $config, + private Config\ConfigWriter $configWriter + ) {} + + public function process(): void + { + if ($this->config->get('instanceId')) { + return; + } + + $id = Util::generateUuid4(); + + $this->configWriter->set('instanceId', $id); + $this->configWriter->save(); + } +} diff --git a/application/Espo/Core/Rebuild/Actions/ScheduledJobs.php b/application/Espo/Core/Rebuild/Actions/ScheduledJobs.php new file mode 100644 index 0000000..9730d63 --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/ScheduledJobs.php @@ -0,0 +1,123 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Core\Utils\Metadata; +use Espo\Entities\ScheduledJob; +use Espo\ORM\EntityManager; + +/** + * Rebuilds scheduled jobs. Creates system jobs. + */ +class ScheduledJobs implements RebuildAction +{ + public function __construct( + private Metadata $metadata, + private EntityManager $entityManager + ) {} + + public function process(): void + { + $jobDefs = array_merge( + $this->metadata->get(['entityDefs', 'ScheduledJob', 'jobs'], []), // for bc + $this->metadata->get(['app', 'scheduledJobs'], []) + ); + + $systemJobNameList = []; + + foreach ($jobDefs as $jobName => $defs) { + if (!$jobName) { + continue; + } + + if (empty($defs['isSystem']) || empty($defs['scheduling'])) { + continue; + } + + $systemJobNameList[] = $jobName; + + $sj = $this->entityManager + ->getRDBRepository(ScheduledJob::ENTITY_TYPE) + ->where([ + 'job' => $jobName, + 'status' => ScheduledJob::STATUS_ACTIVE, + 'scheduling' => $defs['scheduling'], + ]) + ->findOne(); + + if ($sj) { + continue; + } + + $existingJob = $this->entityManager + ->getRDBRepository(ScheduledJob::ENTITY_TYPE) + ->where([ + 'job' => $jobName, + ]) + ->findOne(); + + if ($existingJob) { + $this->entityManager->removeEntity($existingJob); + } + + $name = $jobName; + + if (!empty($defs['name'])) { + $name = $defs['name']; + } + + $this->entityManager->createEntity(ScheduledJob::ENTITY_TYPE, [ + 'job' => $jobName, + 'status' => ScheduledJob::STATUS_ACTIVE, + 'scheduling' => $defs['scheduling'], + 'isInternal' => true, + 'name' => $name, + ]); + } + + $internalScheduledJobList = $this->entityManager + ->getRDBRepository(ScheduledJob::ENTITY_TYPE) + ->where([ + 'isInternal' => true, + ]) + ->find(); + + foreach ($internalScheduledJobList as $scheduledJob) { + $jobName = $scheduledJob->get('job'); + + if (!in_array($jobName, $systemJobNameList)) { + $this->entityManager + ->getRDBRepository(ScheduledJob::ENTITY_TYPE) + ->deleteFromDb($scheduledJob->getId()); + } + } + } +} diff --git a/application/Espo/Core/Rebuild/Actions/SetIntegrationDefaults.php b/application/Espo/Core/Rebuild/Actions/SetIntegrationDefaults.php new file mode 100644 index 0000000..271f4c9 --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/SetIntegrationDefaults.php @@ -0,0 +1,86 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Integration; +use Espo\ORM\EntityManager; + +/** + * @noinspection PhpUnused + */ +class SetIntegrationDefaults implements RebuildAction +{ + public function __construct( + private Metadata $metadata, + private EntityManager $entityManager, + ) {} + + public function process(): void + { + /** @var string[] $integrations */ + $integrations = array_keys($this->metadata->get('integrations') ?? []); + + foreach ($integrations as $integration) { + $this->processItem($integration); + } + } + + private function processItem(string $name): void + { + $integration = $this->entityManager + ->getRDBRepositoryByClass(Integration::class) + ->getById($name); + + if (!$integration || !$integration->isEnabled()) { + return; + } + + /** @var array> $fields */ + $fields = $this->metadata->get("integrations.$name.fields") ?? []; + + foreach ($fields as $field => $defs) { + $default = $defs['default'] ?? null; + + if ($default === null) { + continue; + } + + if ($integration->has($field)) { + continue; + } + + $integration->set($field, $default); + } + + $this->entityManager->saveEntity($integration); + } +} diff --git a/application/Espo/Core/Rebuild/Actions/SyncCurrency.php b/application/Espo/Core/Rebuild/Actions/SyncCurrency.php new file mode 100644 index 0000000..c6985cd --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/SyncCurrency.php @@ -0,0 +1,48 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Tools\Currency\SyncManager; + +/** + * @noinspection PhpUnused + */ +class SyncCurrency implements RebuildAction +{ + public function __construct( + private SyncManager $syncManager, + ) {} + + public function process(): void + { + $this->syncManager->sync(); + } +} diff --git a/application/Espo/Core/Rebuild/Actions/VersionUpdate.php b/application/Espo/Core/Rebuild/Actions/VersionUpdate.php new file mode 100644 index 0000000..16f0651 --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/VersionUpdate.php @@ -0,0 +1,63 @@ +. + * + * 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\Rebuild\Actions; + +use Espo\Core\Rebuild\RebuildAction; +use Espo\Core\Upgrades\Migration\VersionDataProvider; +use Espo\Core\Utils\Config\ConfigWriter; + +/** + * @noinspection PhpUnused + */ +class VersionUpdate implements RebuildAction +{ + private const string DEV_VERSION = '@@version'; + + public function __construct( + private VersionDataProvider $versionDataProvider, + private ConfigWriter $configWriter, + ) {} + + public function process(): void + { + $coreVersion = $this->versionDataProvider->getTargetVersion(); + $stateVersion = $this->versionDataProvider->getPreviousVersion(); + + if ($coreVersion === self::DEV_VERSION || $stateVersion === self::DEV_VERSION) { + return; + } + + if (version_compare($stateVersion, $coreVersion) > 0) { + $this->configWriter->set('version', $coreVersion); + + $this->configWriter->save();; + } + } +} diff --git a/application/Espo/Core/Rebuild/RebuildAction.php b/application/Espo/Core/Rebuild/RebuildAction.php new file mode 100644 index 0000000..eeb91a0 --- /dev/null +++ b/application/Espo/Core/Rebuild/RebuildAction.php @@ -0,0 +1,35 @@ +. + * + * 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\Rebuild; + +interface RebuildAction +{ + public function process(): void; +} diff --git a/application/Espo/Core/Rebuild/RebuildActionProcessor.php b/application/Espo/Core/Rebuild/RebuildActionProcessor.php new file mode 100644 index 0000000..2eb8700 --- /dev/null +++ b/application/Espo/Core/Rebuild/RebuildActionProcessor.php @@ -0,0 +1,73 @@ +. + * + * 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\Rebuild; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +class RebuildActionProcessor +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata + ) {} + + public function process(): void + { + foreach ($this->getActionList() as $action) { + $action->process(); + } + } + + /** + * @return RebuildAction[] + */ + private function getActionList(): array + { + $classNameList = $this->getClassNameList(); + + $list = []; + + foreach ($classNameList as $className) { + $list[] = $this->injectableFactory->create($className); + } + + return $list; + } + + /** + * @return class-string[] + */ + private function getClassNameList(): array + { + /** @var class-string[] */ + return $this->metadata->get(['app', 'rebuild', 'actionClassNameList']) ?? []; + } +} diff --git a/application/Espo/Core/Record/Access/LinkCheck.php b/application/Espo/Core/Record/Access/LinkCheck.php new file mode 100644 index 0000000..2c28fc3 --- /dev/null +++ b/application/Espo/Core/Record/Access/LinkCheck.php @@ -0,0 +1,803 @@ +. + * + * 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\Access; + +use Espo\Core\Acl; +use Espo\Core\Acl\LinkChecker; +use Espo\Core\Acl\LinkChecker\LinkCheckerFactory; +use Espo\Core\Acl\Table as AclTable; +use Espo\Core\Exceptions\Error\Body as ErrorBody; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\ForbiddenSilent; +use Espo\Core\InjectableFactory; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Defs\AttributeParam; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Attachment; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Defs; +use Espo\ORM\Defs\EntityDefs; +use Espo\ORM\Defs\FieldDefs; +use Espo\ORM\Defs\RelationDefs; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\ORM\Type\AttributeType; +use Espo\ORM\Type\RelationType; +use stdClass; + +/** + * Check access for record linking. When linking directly through relationships or via link fields. + * Also loads foreign name attributes. + */ +class LinkCheck +{ + /** @var array> */ + private $linkCheckerCache = []; + + /** @var string[] */ + private array $oneFieldTypeList = [ + FieldType::LINK, + FieldType::LINK_PARENT, + FieldType::LINK_ONE, + FieldType::FILE, + FieldType::IMAGE, + ]; + + /** @var string[] */ + private array $manyFieldTypeList = [ + FieldType::LINK_MULTIPLE, + FieldType::ATTACHMENT_MULTIPLE, + ]; + + public function __construct( + private Defs $ormDefs, + private EntityManager $entityManager, + private Acl $acl, + private Metadata $metadata, + private InjectableFactory $injectableFactory, + private User $user, + ) {} + + /** + * Checks relation fields set in an entity (link-multiple, link and others). + * + * @throws Forbidden + */ + public function processFields(Entity $entity): void + { + $this->processLinkMultipleFields($entity); + $this->processLinkFields($entity); + } + + /** + * @throws Forbidden + */ + private function processLinkMultipleFields(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType); + + $typeList = [ + Entity::HAS_MANY, + Entity::MANY_MANY, + Entity::HAS_CHILDREN, + ]; + + foreach ($entityDefs->getRelationList() as $relationDefs) { + $name = $relationDefs->getName(); + + if (!in_array($relationDefs->getType(), $typeList)) { + continue; + } + + $attribute = $name . 'Ids'; + $namesAttribute = $name . 'Names'; + + if ( + !$entityDefs->hasAttribute($attribute) || + !$entity->isAttributeChanged($attribute) + ) { + continue; + } + + /** @var string[] $ids */ + $ids = $entity->get($attribute) ?? []; + /** @var string[] $oldIds */ + $oldIds = $entity->getFetched($attribute) ?? []; + + $setIds = $ids; + $ids = array_values(array_diff($ids, $oldIds)); + $removedIds = array_values(array_diff($oldIds, $ids)); + + if ($ids === [] && $removedIds === []) { + continue; + } + + $this->processCheckLinkWithoutField($entityDefs, $name, false, $setIds); + + $names = $this->prepareNames($entity, $namesAttribute, $setIds); + + foreach ($ids as $id) { + $foreignEntity = $this->processLinkedRecordsCheckItem($entity, $relationDefs, $id); + + if ($foreignEntity) { + $names->$id = $foreignEntity->get(Field::NAME); + } + } + + if (!$entityDefs->tryGetAttribute($namesAttribute)?->getParam(AttributeParam::IS_LINK_MULTIPLE_NAME_MAP)) { + continue; + } + + $entity->set($namesAttribute, $names); + } + } + + /** + * @param ?string[] $ids + * @throws Forbidden + */ + private function processCheckLinkWithoutField( + EntityDefs $entityDefs, + string $name, + bool $isOne, + ?array $ids = null + ): void { + + $fieldTypes = $isOne ? $this->oneFieldTypeList : $this->manyFieldTypeList; + + $hasField = + $entityDefs->hasField($name) && + in_array($entityDefs->getField($name)->getType(), $fieldTypes); + + if ($hasField) { + return; + } + + if ($isOne) { + throw new ForbiddenSilent("Cannot set ID attribute for link '$name' as there's no link field."); + } + + if ($ids !== null && count($ids) > 1) { + throw new ForbiddenSilent("Cannot set multiple IDs for link '$name' as there's no link-multiple field."); + } + + $forbiddenLinkList = $this->acl->getScopeForbiddenLinkList($entityDefs->getName(), AclTable::ACTION_EDIT); + + if (!in_array($name, $forbiddenLinkList)) { + return; + } + + throw ForbiddenSilent::createWithBody( + "No access to link $name.", + ErrorBody::create() + ->withMessageTranslation('cannotRelateForbiddenLink', null, ['link' => $name]) + ->encode() + ); + } + + /** + * @throws Forbidden + */ + private function processLinkedRecordsCheckItem( + Entity $entity, + RelationDefs $defs, + string $id, + bool $isOne = false + ): ?Entity { + + $entityType = $entity->getEntityType(); + $link = $defs->getName(); + + if ($this->getParam($entityType, $link, 'linkCheckDisabled')) { + return null; + } + + $foreignEntityType = null; + + if ($defs->getType() === RelationType::BELONGS_TO_PARENT) { + $foreignEntityType = $entity->get($link . 'Type'); + } + + if (!$foreignEntityType && !$defs->hasForeignEntityType()) { + return null; + } + + $foreignEntityType ??= $defs->getForeignEntityType(); + + $foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $id); + + if (!$foreignEntity) { + throw ForbiddenSilent::createWithBody( + "Can't relate with non-existing record. entity type: $entityType, link: $link.", + ErrorBody::create() + ->withMessageTranslation( + 'cannotRelateNonExisting', null, ['foreignEntityType' => $foreignEntityType]) + ->encode() + ); + } + + $toSkip = $this->linkForeignAccessCheck($isOne, $entity, $link, $foreignEntity); + + if ($toSkip) { + return $foreignEntity; + } + + $this->linkEntityAccessCheck($entity, $foreignEntity, $link); + + return $foreignEntity; + } + + /** + * @throws Forbidden + */ + private function linkForeignAccessCheck( + bool $isOne, + Entity $entity, + string $link, + Entity $foreignEntity, + ): bool { + + if ($isOne) { + return $this->linkForeignAccessCheckOne($entity, $link, $foreignEntity); + } + + return $this->linkForeignAccessCheckMany($entity, $link, $foreignEntity, true); + } + + private function getParam(string $entityType, string $link, string $param): mixed + { + return $this->metadata->get(['recordDefs', $entityType, 'relationships', $link, $param]); + } + + /** + * Check access to a specific link. + * + * @throws Forbidden + */ + public function processLink(Entity $entity, string $link): void + { + $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'); + + if (!$action) { + $action = AclTable::ACTION_EDIT; + } + + if (!$this->acl->check($entity, $action)) { + throw ForbiddenSilent::createWithBody( + "No record access for link operation ($entityType:$link).", + ErrorBody::create() + ->withMessageTranslation('noAccessToRecord', null, ['action' => $action]) + ->encode() + ); + } + } + + /** + * Check unlink access to a specific link. + * + * @throws Forbidden + */ + public function processUnlink(Entity $entity, string $link): void + { + $this->processLink($entity, $link); + } + + /** + * Check link access for a specific foreign entity. + * + * @throws Forbidden + */ + public function processLinkForeign(Entity $entity, string $link, Entity $foreignEntity): void + { + $this->processLinkForeignInternal($entity, $link, $foreignEntity); + $this->processLinkAlreadyLinkedCheck($entity, $link, $foreignEntity); + } + + /** + * Check link access for a specific foreign entity. + * + * @throws Forbidden + */ + private function processLinkForeignInternal(Entity $entity, string $link, Entity $foreignEntity): void + { + $toSkip = $this->linkForeignAccessCheckMany($entity, $link, $foreignEntity); + + if ($toSkip) { + return; + } + + $this->linkEntityAccessCheck($entity, $foreignEntity, $link); + } + + /** + * Check unlink access for a specific foreign entity. + * + * @throws Forbidden + */ + public function processUnlinkForeign(Entity $entity, string $link, Entity $foreignEntity): void + { + $this->processLinkForeignInternal($entity, $link, $foreignEntity); + $this->processUnlinkForeignRequired($entity, $link, $foreignEntity); + } + + /** + * Check access to foreign record for has-many and many-many links. + * + * @return bool True indicates that the link checker should be bypassed. + * @throws Forbidden + */ + private function linkForeignAccessCheckMany( + Entity $entity, + string $link, + Entity $foreignEntity, + bool $fromUpdate = false, + ): bool { + + $entityType = $entity->getEntityType(); + + /** @var AclTable::ACTION_* $action */ + $action = $this->getParam($entityType, $link, 'linkRequiredForeignAccess') ?? AclTable::ACTION_EDIT; + + if ($this->getParam($entityType, $link, 'linkForeignAccessCheckDisabled')) { + return true; + } + + $fieldDefs = $fromUpdate ? + $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->tryGetField($link) : + null; + + if ( + $fromUpdate && + $fieldDefs && + in_array($fieldDefs->getType(), $this->manyFieldTypeList) + ) { + $action = AclTable::ACTION_READ; + + if ($this->checkInDefaults($fieldDefs, $link, $foreignEntity)) { + return true; + } + } + + if ( + $action === AclTable::ACTION_READ && + $this->checkIsAllowedForPortal($foreignEntity) + ) { + return true; + } + + // Check parent and related IDs instead? + if ($foreignEntity instanceof Attachment && !$this->checkAttachment($entity, $foreignEntity)) { + throw ForbiddenSilent::createWithBody( + "Cannot link an already linked attachment ($entityType:$link).", + ErrorBody::create() + ); + } + + if ($this->acl->check($foreignEntity, $action)) { + return false; + } + + if ($this->getLinkChecker($entityType, $link)) { + return false; + } + + $body = ErrorBody::create(); + + $body = $fromUpdate ? + $body->withMessageTranslation('cannotRelateForbidden', null, [ + 'foreignEntityType' => $foreignEntity->getEntityType(), + 'action' => $action, + ]) : + $body->withMessageTranslation('noAccessToForeignRecord', null, ['action' => $action]); + + throw ForbiddenSilent::createWithBody( + "No foreign record access for link operation ($entityType:$link).", + $body->encode() + ); + } + + public function checkIsAllowedForPortal(Entity $foreignEntity): bool + { + if (!$this->user->isPortal()) { + return false; + } + + if ( + $foreignEntity->getEntityType() === Account::ENTITY_TYPE && + $this->user->getAccounts()->hasId($foreignEntity->getId()) + ) { + return true; + } + + if ( + $foreignEntity->getEntityType() === Contact::ENTITY_TYPE && + $this->user->getContactId() === $foreignEntity->getId() + ) { + return true; + } + + return false; + } + + /** + * @throws Forbidden + */ + private function linkEntityAccessCheck(Entity $entity, Entity $foreignEntity, string $link): void + { + $entityType = $entity->getEntityType(); + + $checker = $this->getLinkChecker($entityType, $link); + + if (!$checker) { + return; + } + + if ($checker->check($this->user, $entity, $foreignEntity)) { + return; + } + + throw ForbiddenSilent::createWithBody( + "No access for link operation ($entityType:$link).", + ErrorBody::create() + ->withMessageTranslation('noLinkAccess', null, [ + 'foreignEntityType' => $foreignEntity->getEntityType(), + 'link' => $link, + ]) + ); + } + + /** + * @return ?LinkChecker + */ + private function getLinkChecker(string $entityType, string $link): ?LinkChecker + { + $key = $entityType . '_' . $link; + + if (array_key_exists($key, $this->linkCheckerCache)) { + return $this->linkCheckerCache[$key]; + } + + $factory = $this->injectableFactory->create(LinkCheckerFactory::class); + + if (!$factory->isCreatable($entityType, $link)) { + return null; + } + + $checker = $factory->create($entityType, $link); + + $this->linkCheckerCache[$link] = $checker; + + return $checker; + } + + /** + * @throws Forbidden + */ + private function processUnlinkForeignRequired(Entity $entity, string $link, Entity $foreignEntity): void + { + $relationDefs = $this->ormDefs + ->getEntity($entity->getEntityType()) + ->tryGetRelation($link); + + if (!$relationDefs) { + return; + } + + if ( + !$relationDefs->hasForeignEntityType() || + !$relationDefs->hasForeignRelationName() + ) { + return; + } + + $foreignLink = $relationDefs->getForeignRelationName(); + + $foreignRelationDefs = $this->ormDefs + ->getEntity($foreignEntity->getEntityType()) + ->tryGetRelation($foreignLink); + + if (!$foreignRelationDefs) { + return; + } + + if ( + !in_array($foreignRelationDefs->getType(), [ + RelationType::BELONGS_TO, + RelationType::HAS_ONE, + RelationType::BELONGS_TO_PARENT, + ]) + ) { + return; + } + + $foreignFieldDefs = $this->ormDefs + ->getEntity($foreignEntity->getEntityType()) + ->tryGetField($foreignLink); + + if (!$foreignFieldDefs) { + return; + } + + if (!$foreignFieldDefs->getParam('required')) { + return; + } + + throw ForbiddenSilent::createWithBody( + "Can't unlink required field ({$foreignEntity->getEntityType()}:$foreignLink}).", + ErrorBody::create() + ->withMessageTranslation('cannotUnrelateRequiredLink') + ->encode() + ); + } + + /** + * @throws Forbidden + */ + private function processLinkFields(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType); + + $typeList = [ + Entity::BELONGS_TO, + Entity::BELONGS_TO_PARENT, + Entity::HAS_ONE, + ]; + + foreach ($entityDefs->getRelationList() as $relationDefs) { + $name = $relationDefs->getName(); + $attribute = $name . 'Id'; + $nameAttribute = $name . 'Name'; + + if ( + !in_array($relationDefs->getType(), $typeList) || + !$entityDefs->hasAttribute($attribute) || + !$entity->isAttributeChanged($attribute) || + $entity->get($attribute) === null + ) { + continue; + } + + $this->processCheckLinkWithoutField($entityDefs, $name, true); + + $id = $entity->get($attribute); + + $foreignEntity = $this->processLinkedRecordsCheckItem($entity, $relationDefs, $id, true); + + if (!$foreignEntity) { + continue; + } + + $nameAttributeDefs = $entityDefs->tryGetAttribute($nameAttribute); + + if (!$nameAttributeDefs) { + return; + } + + if ( + $nameAttributeDefs->getType() === AttributeType::FOREIGN || + $nameAttributeDefs->isNotStorable() + ) { + $foreignName = $relationDefs->getParam('foreignName') ?? 'name'; + + $entity->set($nameAttribute, $foreignEntity->get($foreignName)); + } + } + } + + /** + * Check access to foreign record for belongs-to, has-one and belongs-to-parent links. + * + * @return bool True indicates that the link checker should be bypassed. + * @throws Forbidden + */ + private function linkForeignAccessCheckOne(Entity $entity, string $link, Entity $foreignEntity): bool + { + $entityType = $entity->getEntityType(); + + if ($this->getParam($entityType, $link, 'linkForeignAccessCheckDisabled')) { + return true; + } + + $fieldDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->tryGetField($link); + + if ( + $fieldDefs && + in_array($fieldDefs->getType(), $this->oneFieldTypeList) + ) { + if ($this->checkIsDefault($fieldDefs, $link, $foreignEntity)) { + return true; + } + } + + if ($foreignEntity instanceof Attachment && !$this->checkAttachment($entity, $foreignEntity)) { + throw ForbiddenSilent::createWithBody( + "Cannot link an already linked attachment ($entityType:$link).", + ErrorBody::create() + ); + } + + if ($this->checkIsAllowedForPortal($foreignEntity)) { + return true; + } + + if ($this->acl->check($foreignEntity, AclTable::ACTION_READ)) { + return false; + } + + if ($this->getLinkChecker($entityType, $link)) { + return false; + } + + throw ForbiddenSilent::createWithBody( + "No foreign record access for link operation ($entityType:$link).", + ErrorBody::create() + ->withMessageTranslation('cannotRelateForbidden', null, [ + 'foreignEntityType' => $foreignEntity->getEntityType(), + 'action' => AclTable::ACTION_READ, + ]) + ->encode() + ); + } + + private function checkInDefaults(FieldDefs $fieldDefs, string $link, Entity $foreignEntity): bool + { + /** @var string[] $defaults */ + $defaults = $this->getDefault($fieldDefs, $link . 'Ids') ?? []; + + return in_array($foreignEntity->getId(), $defaults); + } + + private function checkIsDefault(FieldDefs $fieldDefs, string $link, Entity $foreignEntity): bool + { + return $foreignEntity->getId() === $this->getDefault($fieldDefs, $link . 'Id'); + } + private function getDefault(FieldDefs $fieldDefs, string $attribute): mixed + { + $defaultAttributes = (object) ($fieldDefs->getParam('defaultAttributes') ?? []); + + return $defaultAttributes->$attribute ?? null; + } + + /** + * @param string[] $setIds + */ + private function prepareNames(Entity $entity, string $namesAttribute, array $setIds): stdClass + { + $oldNames = $entity->getFetched($namesAttribute); + + if (!$oldNames instanceof stdClass) { + $oldNames = (object) []; + } + + $names = (object) []; + + foreach ($setIds as $id) { + if (isset($oldNames->$id)) { + $names->$id = $oldNames->$id; + } + } + + return $names; + } + + /** + * @throws Forbidden + */ + private function processLinkAlreadyLinkedCheck(Entity $entity, string $link, Entity $foreignEntity): void + { + if (!$this->getParam($entity->getEntityType(), $link, 'linkOnlyNotLinked')) { + return; + } + + $entityType = $entity->getEntityType(); + + $foreign = $this->ormDefs + ->getEntity($entityType) + ->tryGetRelation($link) + ?->tryGetForeignRelationName(); + + if (!$foreign) { + return; + } + + $one = $this->entityManager + ->getRDBRepository($foreignEntity->getEntityType()) + ->getRelation($foreignEntity, $foreign) + ->findOne(); + + if (!$one) { + return; + } + + throw ForbiddenSilent::createWithBody( + "Cannot link as the record is already linked ($entityType:$link).", + ErrorBody::create() + ->withMessageTranslation('cannotLinkAlreadyLinked') + ->encode() + ); + } + + private function checkAttachment(Entity $entity, Attachment $attachment): bool + { + $parentType = $attachment->getParentType(); + $parentId = $attachment->getParentId(); + + if ($parentType && $parentId) { + $toSame = + $entity->getEntityType() === $parentType && + $entity->hasId() && + $entity->getId() === $parentId; + + if (!$toSame) { + return false; + } + } + + $relatedType = $attachment->getRelatedType(); + $relatedId = $attachment->getRelatedId(); + + if ($relatedType && $relatedId) { + $toSame = + $entity->getEntityType() === $relatedType && + $entity->hasId() && + $entity->getId() === $relatedId; + + if (!$toSame) { + return false; + } + } + + return true; + } +} diff --git a/application/Espo/Core/Record/ActionHistory/Action.php b/application/Espo/Core/Record/ActionHistory/Action.php new file mode 100644 index 0000000..48edc2c --- /dev/null +++ b/application/Espo/Core/Record/ActionHistory/Action.php @@ -0,0 +1,38 @@ +. + * + * 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\ActionHistory; + +class Action +{ + public const CREATE = 'create'; + public const READ = 'read'; + public const UPDATE = 'update'; + public const DELETE = 'delete'; +} diff --git a/application/Espo/Core/Record/ActionHistory/ActionLogger.php b/application/Espo/Core/Record/ActionHistory/ActionLogger.php new file mode 100644 index 0000000..14206d8 --- /dev/null +++ b/application/Espo/Core/Record/ActionHistory/ActionLogger.php @@ -0,0 +1,45 @@ +. + * + * 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\ActionHistory; + +use Espo\ORM\Entity; + +/** + * Logs actions users do with records. + */ +interface ActionLogger +{ + /** + * Log an action. + * + * @param Action::* $action + */ + public function log(string $action, Entity $entity): void; +} diff --git a/application/Espo/Core/Record/ActionHistory/DefaultActionLogger.php b/application/Espo/Core/Record/ActionHistory/DefaultActionLogger.php new file mode 100644 index 0000000..38ae422 --- /dev/null +++ b/application/Espo/Core/Record/ActionHistory/DefaultActionLogger.php @@ -0,0 +1,64 @@ +. + * + * 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\ActionHistory; + +use Espo\Core\Field\LinkParent; +use Espo\Entities\ActionHistoryRecord; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +class DefaultActionLogger implements ActionLogger +{ + public function __construct( + private EntityManager $entityManager, + private User $user + ) {} + + /** + * @inheritDoc + */ + public function log(string $action, Entity $entity): void + { + $historyRecord = $this->entityManager + ->getRepositoryByClass(ActionHistoryRecord::class) + ->getNew(); + + $historyRecord + ->setAction($action) + ->setUserId($this->user->getId()) + ->setAuthTokenId($this->user->get('authTokenId')) + ->setAuthLogRecordId($this->user->get('authLogRecordId')) + ->setIpAddress($this->user->get('ipAddress')) + ->setTarget(LinkParent::fromEntity($entity)); + + $this->entityManager->saveEntity($historyRecord); + } +} diff --git a/application/Espo/Core/Record/Collection.php b/application/Espo/Core/Record/Collection.php new file mode 100644 index 0000000..d651ec2 --- /dev/null +++ b/application/Espo/Core/Record/Collection.php @@ -0,0 +1,149 @@ +. + * + * 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; + +use Espo\ORM\Collection as OrmCollection; +use Espo\ORM\Entity; +use Espo\ORM\EntityCollection; + +use stdClass; + +/** + * Contains an ORM collection and total number of records. + * + * @template-covariant TEntity of Entity + */ +class Collection +{ + public const TOTAL_HAS_MORE = -1; + public const TOTAL_HAS_NO_MORE = -2; + + /** + * @param OrmCollection $collection + */ + public function __construct( + private OrmCollection $collection, + private ?int $total = null + ) {} + + /** + * Get a total number of records in DB (that matches applied search parameters). + */ + public function getTotal(): ?int + { + return $this->total; + } + + /** + * Get an ORM collection. + * + * @return OrmCollection + */ + public function getCollection(): OrmCollection + { + return $this->collection; + } + + /** + * Get a value map list. + * + * @return stdClass[] + */ + public function getValueMapList(): array + { + if ( + $this->collection instanceof EntityCollection && + !$this->collection->getEntityType() + ) { + $list = []; + + foreach ($this->collection as $e) { + $item = $e->getValueMap(); + + $item->_scope = $e->getEntityType(); + + $list[] = $item; + } + + return $list; + } + + return $this->collection->getValueMapList(); + } + + /** + * Create. + * + * @template CEntity of Entity + * @param OrmCollection $collection + * @return self + */ + public static function create(OrmCollection $collection, ?int $total = null): self + { + return new self($collection, $total); + } + + /** + * Create w/o count. + * + * @template CEntity of Entity + * @param OrmCollection $collection + * @return self + */ + public static function createNoCount(OrmCollection $collection, ?int $maxSize): self + { + if ( + $maxSize !== null && + $collection instanceof EntityCollection && + count($collection) > $maxSize + ) { + $copyCollection = new EntityCollection([...$collection], $collection->getEntityType()); + + unset($copyCollection[count($copyCollection) - 1]); + + return new self($copyCollection, self::TOTAL_HAS_MORE); + } + + return new self($collection, self::TOTAL_HAS_NO_MORE); + } + + /** + * To API output. To be used in API actions. + * + * @since 9.1.0 + */ + public function toApiOutput(): stdClass + { + return (object) [ + 'total' => $this->getTotal(), + 'list' => $this->getValueMapList(), + ]; + } +} diff --git a/application/Espo/Core/Record/ConcurrencyControl/Optimistic/Result.php b/application/Espo/Core/Record/ConcurrencyControl/Optimistic/Result.php new file mode 100644 index 0000000..56de8d8 --- /dev/null +++ b/application/Espo/Core/Record/ConcurrencyControl/Optimistic/Result.php @@ -0,0 +1,46 @@ +. + * + * 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\ConcurrencyControl\Optimistic; + +use stdClass; + +readonly class Result +{ + /** + * @param string[] $fieldList Changed fields. + * @param stdClass $values Previous values. + * @param int $versionNumber A previous version number. + */ + public function __construct( + public array $fieldList, + public stdClass $values, + public int $versionNumber, + ) {} +} diff --git a/application/Espo/Core/Record/ConcurrencyControl/OptimisticProcessor.php b/application/Espo/Core/Record/ConcurrencyControl/OptimisticProcessor.php new file mode 100644 index 0000000..81b879b --- /dev/null +++ b/application/Espo/Core/Record/ConcurrencyControl/OptimisticProcessor.php @@ -0,0 +1,116 @@ +. + * + * 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\ConcurrencyControl; + +use Espo\Core\Name\Field; +use Espo\Core\Record\ConcurrencyControl\Optimistic\Result; +use Espo\Core\Utils\FieldUtil; +use Espo\ORM\BaseEntity; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @internal + */ +class OptimisticProcessor +{ + public function __construct( + private EntityManager $entityManager, + private FieldUtil $fieldUtil, + ) {} + + public function process(Entity $entity, int $versionNumber): ?Result + { + $previousVersionNumber = $entity->getFetched(Field::VERSION_NUMBER); + + if ($previousVersionNumber === null) { + return null; + } + + if ($versionNumber === $previousVersionNumber) { + return null; + } + + $changedFieldList = []; + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()); + + foreach ($entityDefs->getFieldList() as $fieldDefs) { + $field = $fieldDefs->getName(); + + if ( + $fieldDefs->getParam('optimisticConcurrencyControlIgnore') || + $fieldDefs->getParam(FieldParam::READ_ONLY) + ) { + continue; + } + + foreach ($this->fieldUtil->getActualAttributeList($entityDefs->getName(), $field) as $attribute) { + if ( + $entity instanceof BaseEntity && + !$entity->isAttributeWritten($attribute) + ) { + continue; + } + + if (!$entity->hasFetched($attribute)) { + continue; + } + + if ($entity->isAttributeChanged($attribute)) { + $changedFieldList[] = $field; + + continue 2; + } + } + } + + if ($changedFieldList === []) { + return null; + } + + $values = (object) []; + + foreach ($changedFieldList as $field) { + foreach ($this->fieldUtil->getAttributeList($entityDefs->getName(), $field) as $attribute) { + $values->$attribute = $entity->getFetched($attribute); + } + } + + return new Result( + fieldList: $changedFieldList, + values: $values, + versionNumber: $previousVersionNumber, + ); + } +} diff --git a/application/Espo/Core/Record/CreateParams.php b/application/Espo/Core/Record/CreateParams.php new file mode 100644 index 0000000..d442dee --- /dev/null +++ b/application/Espo/Core/Record/CreateParams.php @@ -0,0 +1,74 @@ +. + * + * 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; + +/** + * Immutable. + */ +class CreateParams +{ + private bool $skipDuplicateCheck = false; + private ?string $duplicateSourceId = null; + + public function __construct() {} + + public function withSkipDuplicateCheck(bool $skipDuplicateCheck = true): self + { + $obj = clone $this; + + $obj->skipDuplicateCheck = $skipDuplicateCheck; + + return $obj; + } + + public function withDuplicateSourceId(?string $duplicateSourceId): self + { + $obj = clone $this; + + $obj->duplicateSourceId = $duplicateSourceId; + + return $obj; + } + + public function skipDuplicateCheck(): bool + { + return $this->skipDuplicateCheck; + } + + public function getDuplicateSourceId(): ?string + { + return $this->duplicateSourceId; + } + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Record/CreateParamsFetcher.php b/application/Espo/Core/Record/CreateParamsFetcher.php new file mode 100644 index 0000000..88ef1c9 --- /dev/null +++ b/application/Espo/Core/Record/CreateParamsFetcher.php @@ -0,0 +1,53 @@ +. + * + * 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; + +use Espo\Core\Api\Request; + +class CreateParamsFetcher +{ + public function __construct() {} + + public function fetch(Request $request): CreateParams + { + $data = $request->getParsedBody(); + + $skipDuplicateCheck = $request->hasHeader('X-Skip-Duplicate-Check') ? + strtolower($request->getHeader('X-Skip-Duplicate-Check') ?? '') === 'true' : + $data->_skipDuplicateCheck ?? // legacy + false; + + $duplicateSourceId = $request->getHeader('X-Duplicate-Source-Id'); + + return CreateParams::create() + ->withSkipDuplicateCheck($skipDuplicateCheck) + ->withDuplicateSourceId($duplicateSourceId); + } +} diff --git a/application/Espo/Core/Record/CreateResult.php b/application/Espo/Core/Record/CreateResult.php new file mode 100644 index 0000000..94df7d7 --- /dev/null +++ b/application/Espo/Core/Record/CreateResult.php @@ -0,0 +1,76 @@ +. + * + * 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; + +use Espo\ORM\Entity; +use stdClass; + +/** + * @template TEntity of Entity = Entity + * @since 10.0.0 + */ +class CreateResult +{ + /** + * @param TEntity $entity + */ + public function __construct( + private Entity $entity, + ) {} + + public function getValueMap(): stdClass + { + return $this->entity->getValueMap(); + } + + /** + * @return TEntity + */ + public function getEntity(): Entity + { + return $this->entity; + } + + /** + * @deprecated Since v10.0.0. For bc. Use entity->get(...). + */ + public function getId(): string + { + return $this->entity->getId(); + } + + /** + * @deprecated Since v10.0.0. For bc. Use entity->get(...). + */ + public function get(string $name): mixed + { + return $this->entity->get($name); + } +} diff --git a/application/Espo/Core/Record/Crud.php b/application/Espo/Core/Record/Crud.php new file mode 100644 index 0000000..0242f5f --- /dev/null +++ b/application/Espo/Core/Record/Crud.php @@ -0,0 +1,66 @@ +. + * + * 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; + +use Espo\ORM\Entity; + +use stdClass; + +/** + * @template TEntity of Entity + */ +interface Crud +{ + /** + * Create a record. + * + * @return CreateResult + */ + public function create(stdClass $data, CreateParams $params): CreateResult; + + /** + * Read a record. + * + * @return ReadResult + */ + public function read(string $id, ReadParams $params): ReadResult; + + /** + * Update a record. + * + * @return UpdateResult + */ + public function update(string $id, stdClass $data, UpdateParams $params): UpdateResult; + + /** + * Delete a record. + */ + public function delete(string $id, DeleteParams $params): DeleteResult; +} diff --git a/application/Espo/Core/Record/Defaults/DefaultPopulator.php b/application/Espo/Core/Record/Defaults/DefaultPopulator.php new file mode 100644 index 0000000..2ef3651 --- /dev/null +++ b/application/Espo/Core/Record/Defaults/DefaultPopulator.php @@ -0,0 +1,362 @@ +. + * + * 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\Defaults; + +use Espo\Core\Acl; +use Espo\Core\Acl\Permission; +use Espo\Core\Acl\Table as AclTable; +use Espo\Core\Currency\ConfigDataProvider as CurrencyConfigDataProvider; +use Espo\Core\Field\Link; +use Espo\Core\Field\LinkMultiple; +use Espo\Core\Field\LinkMultipleItem; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\FieldUtil; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Tools\Pipeline\Data\PipelineData; +use Espo\Tools\Pipeline\MetadataProvider as PipelineMetadata; +use Espo\Tools\Pipeline\UserPipelineDataProvider; +use LogicException; +use RuntimeException; + +/** + * @implements Populator + */ +class DefaultPopulator implements Populator +{ + public function __construct( + private Acl $acl, + private User $user, + private FieldUtil $fieldUtil, + private EntityManager $entityManager, + private CurrencyConfigDataProvider $currencyConfig, + private Metadata $metadata, + private PipelineMetadata $pipelineMetadata, + private UserPipelineDataProvider $userPipelineDataProvider, + private Acl\AssignmentChecker\Helper $assignmentHelper, + ) {} + + public function populate(Entity $entity): void + { + $this->processAssignedUser($entity); + $this->processDefaultTeam($entity); + $this->processCurrency($entity); + $this->processPortal($entity); + $this->processPipeline($entity); + } + + /** + * If no edit access to assignedUser field. + */ + private function isAssignedUserShouldBeSetWithSelf(string $entityType): bool + { + if ($this->user->isPortal()) { + return false; + } + + if ($this->assignmentHelper->hasAssignedUsersField($entityType)) { + return false; + } + + $defs = $this->entityManager->getDefs()->getEntity($entityType); + + if ($defs->tryGetField(Field::ASSIGNED_USER)?->getType() !== FieldType::LINK) { + return false; + } + + if ( + $this->acl->getPermissionLevel(Permission::ASSIGNMENT) === AclTable::LEVEL_NO && + !$this->user->isApi() + ) { + return true; + } + + if (!$this->acl->checkField($entityType, Field::ASSIGNED_USER, AclTable::ACTION_EDIT)) { + return true; + } + + return false; + } + + private function toAddDefaultTeam(Entity $entity): bool + { + if ($this->user->isPortal()) { + return false; + } + + if (!$this->user->getDefaultTeam()) { + return false; + } + + if (!$entity instanceof CoreEntity) { + return false; + } + + $entityType = $entity->getEntityType(); + + $defs = $this->entityManager->getDefs()->getEntity($entityType); + + if ($defs->tryGetField(Field::TEAMS)?->getType() !== FieldType::LINK_MULTIPLE) { + return false; + } + + if ($entity->hasLinkMultipleId(Field::TEAMS, $this->user->getDefaultTeam()->getId())) { + return false; + } + + if ($this->acl->getPermissionLevel(Permission::ASSIGNMENT) === AclTable::LEVEL_NO) { + return true; + } + + if (!$this->acl->checkField($entityType, Field::TEAMS, AclTable::ACTION_EDIT)) { + return true; + } + + return false; + } + + private function processCurrency(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + foreach ($this->fieldUtil->getEntityTypeFieldList($entityType) as $field) { + $type = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, FieldParam::TYPE); + + if ($type !== FieldType::CURRENCY) { + continue; + } + + $currencyAttribute = $field . 'Currency'; + + if ($entity->get($field) !== null && !$entity->get($currencyAttribute)) { + $entity->set($currencyAttribute, $this->currencyConfig->getDefaultCurrency()); + } + } + } + + private function processDefaultTeam(Entity $entity): void + { + if (!$this->toAddDefaultTeam($entity)) { + return; + } + + $defaultTeamId = $this->user->getDefaultTeam()?->getId(); + + if (!$defaultTeamId || !$entity instanceof CoreEntity) { + throw new RuntimeException(); + } + + $entity->addLinkMultipleId(Field::TEAMS, $defaultTeamId); + + $teamsNames = $entity->get(Field::TEAMS . 'Names'); + + if (!$teamsNames || !is_object($teamsNames)) { + $teamsNames = (object)[]; + } + + $teamsNames->$defaultTeamId = $this->user->getDefaultTeam()?->getName(); + + $entity->set(Field::TEAMS . 'Names', $teamsNames); + } + + private function processAssignedUser(Entity $entity): void + { + if (!$this->isAssignedUserShouldBeSetWithSelf($entity->getEntityType())) { + return; + } + + // @todo Support Multiple Assigned Users. + + $entity->set(Field::ASSIGNED_USER . 'Id', $this->user->getId()); + $entity->set(Field::ASSIGNED_USER . 'Name', $this->user->getName()); + } + + private function processPortal(Entity $entity): void + { + if (!$this->user->isPortal()) { + return; + } + + $this->processPortalAccount($entity); + $this->processPortalContact($entity); + } + + private function processPortalAccount(Entity $entity): void + { + /** @var ?string $link */ + $link = $this->metadata->get("aclDefs.{$entity->getEntityType()}.accountLink"); + + if (!$link) { + return; + } + + $account = $this->user->getContact()?->getAccount(); + + if (!$account) { + return; + } + + $this->processPortalRecord($entity, $link, $account); + } + + private function processPortalContact(Entity $entity): void + { + /** @var ?string $link */ + $link = $this->metadata->get("aclDefs.{$entity->getEntityType()}.contactLink"); + + if (!$link) { + return; + } + + $contact = $this->user->getContact(); + + if (!$contact) { + return; + } + + $this->processPortalRecord($entity, $link, $contact); + } + + private function processPortalRecord(Entity $entity, string $link, Account|Contact $record): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + $fieldDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()) + ->tryGetField($link); + + if (!$fieldDefs) { + return; + } + + if ( + $fieldDefs->getType() === FieldType::LINK || + $fieldDefs->getType() === FieldType::LINK_ONE + ) { + if ($entity->has($link . 'Id')) { + return; + } + + $entity->setValueObject($link, Link::create($record->getId(), $record->getName())); + + return; + } + + if ($fieldDefs->getType() === FieldType::LINK_MULTIPLE) { + if ($entity->has($link . 'Ids')) { + return; + } + + $linkMultiple = LinkMultiple::create([LinkMultipleItem::create($record->getId(), $record->getName())]); + + $entity->setValueObject($link, $linkMultiple); + } + } + + private function processPipeline(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + if (!$this->pipelineMetadata->isEnabled($entityType)) { + return; + } + + $pipelineIdAttr = Field::PIPELINE . 'Id'; + $stageIdAttr = Field::PIPELINE_STAGE . 'Id'; + + if ( + $entity->get($pipelineIdAttr) && + $entity->get($stageIdAttr) + ) { + return; + } + + if (!$entity->get($pipelineIdAttr)) { + $pipeline = $this->userPipelineDataProvider->getForEntityType($entityType)[0] ?? null; + + if (!$pipeline) { + return; + } + + $entity->setMultiple([ + $pipelineIdAttr => $pipeline->id, + Field::PIPELINE . 'Name' => $pipeline->name, + ]); + } + + $id = $entity->get($pipelineIdAttr); + + if (!$id) { + throw new LogicException(); + } + + if ($entity->get($stageIdAttr)) { + return; + } + + $pipeline = $this->getPipeline($entityType, $id); + + if (!$pipeline) { + return; + } + + $stage = $pipeline->stages[0] ?? null; + + if (!$stage) { + return; + } + + $entity->setMultiple([ + $stageIdAttr => $stage->id, + Field::PIPELINE_STAGE . 'Name' => $stage->name, + ]); + } + + private function getPipeline(string $entityType, mixed $id): ?PipelineData + { + foreach ($this->userPipelineDataProvider->getForEntityType($entityType) as $it) { + if ($id === $it->id) { + return $it; + } + } + + return null; + } +} diff --git a/application/Espo/Core/Record/Defaults/Populator.php b/application/Espo/Core/Record/Defaults/Populator.php new file mode 100644 index 0000000..3cd9bff --- /dev/null +++ b/application/Espo/Core/Record/Defaults/Populator.php @@ -0,0 +1,47 @@ +. + * + * 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\Defaults; + +use Espo\ORM\Entity; + +/** + * Populate default values. + * + * @template TEntity of Entity + * @noinspection PhpUnused + */ +interface Populator +{ + /** + * @param TEntity $entity + * @noinspection PhpDocSignatureInspection + */ + public function populate(Entity $entity): void; +} diff --git a/application/Espo/Core/Record/Defaults/PopulatorFactory.php b/application/Espo/Core/Record/Defaults/PopulatorFactory.php new file mode 100644 index 0000000..56f59ed --- /dev/null +++ b/application/Espo/Core/Record/Defaults/PopulatorFactory.php @@ -0,0 +1,78 @@ +. + * + * 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\Defaults; + +use Espo\Core\Acl; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\ORM\Entity; + +class PopulatorFactory +{ + /** @var class-string */ + private string $defaultClassName = DefaultPopulator::class; + + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private User $user, + private Acl $acl + ) {} + + /** + * @return Populator + */ + public function create(string $entityType): Populator + { + $binding = BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->bindInstance(Acl::class, $this->acl) + ->build(); + + return $this->injectableFactory->createWithBinding($this->getClassName($entityType), $binding); + } + + /** + * @return class-string> + */ + private function getClassName(string $entityType): string + { + /** @var ?class-string> $className */ + $className = $this->metadata->get("recordDefs.$entityType.defaultsPopulatorClassName"); + + if ($className) { + return $className; + } + + return $this->defaultClassName; + } +} diff --git a/application/Espo/Core/Record/DeleteParams.php b/application/Espo/Core/Record/DeleteParams.php new file mode 100644 index 0000000..856ecdd --- /dev/null +++ b/application/Espo/Core/Record/DeleteParams.php @@ -0,0 +1,43 @@ +. + * + * 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; + +/** + * Immutable. + */ +class DeleteParams +{ + public function __construct() {} + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Record/DeleteParamsFetcher.php b/application/Espo/Core/Record/DeleteParamsFetcher.php new file mode 100644 index 0000000..472228d --- /dev/null +++ b/application/Espo/Core/Record/DeleteParamsFetcher.php @@ -0,0 +1,42 @@ +. + * + * 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; + +use Espo\Core\Api\Request; + +class DeleteParamsFetcher +{ + public function __construct() {} + + public function fetch(Request $request): DeleteParams + { + return DeleteParams::create(); + } +} diff --git a/application/Espo/Core/Record/DeleteResult.php b/application/Espo/Core/Record/DeleteResult.php new file mode 100644 index 0000000..269cc9a --- /dev/null +++ b/application/Espo/Core/Record/DeleteResult.php @@ -0,0 +1,41 @@ +. + * + * 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; + +/** + * For potential future use. + * + * @since 10.0.0 + */ +class DeleteResult +{ + public function __construct( + ) {} +} diff --git a/application/Espo/Core/Record/Deleted/DefaultRestorer.php b/application/Espo/Core/Record/Deleted/DefaultRestorer.php new file mode 100644 index 0000000..2193f63 --- /dev/null +++ b/application/Espo/Core/Record/Deleted/DefaultRestorer.php @@ -0,0 +1,183 @@ +. + * + * 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\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 + */ +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 Conflict("Entity is not soft-deleted."); + } + + $this->entityManager + ->getTransactionManager() + ->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()); + + if ( + $entity->hasAttribute('deleteId') && + $this->metadata->get("entityDefs.{$entity->getEntityType()}.deleteId") + ) { + $this->entityManager->refreshEntity($entity); + + $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; + } +} diff --git a/application/Espo/Core/Record/Deleted/Restorer.php b/application/Espo/Core/Record/Deleted/Restorer.php new file mode 100644 index 0000000..4d570f4 --- /dev/null +++ b/application/Espo/Core/Record/Deleted/Restorer.php @@ -0,0 +1,45 @@ +. + * + * 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\Exceptions\Conflict; +use Espo\ORM\Entity; + +/** + * @template TEntity of Entity = Entity + */ +interface Restorer +{ + /** + * @param TEntity $entity A deleted entity. + * @throws Conflict + */ + public function restore(Entity $entity): void; +} diff --git a/application/Espo/Core/Record/Deleted/RestorerFactory.php b/application/Espo/Core/Record/Deleted/RestorerFactory.php new file mode 100644 index 0000000..56db41a --- /dev/null +++ b/application/Espo/Core/Record/Deleted/RestorerFactory.php @@ -0,0 +1,63 @@ +. + * + * 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> $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(); + } +} diff --git a/application/Espo/Core/Record/Duplicator/EntityDuplicator.php b/application/Espo/Core/Record/Duplicator/EntityDuplicator.php new file mode 100644 index 0000000..bcaee97 --- /dev/null +++ b/application/Espo/Core/Record/Duplicator/EntityDuplicator.php @@ -0,0 +1,167 @@ +. + * + * 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\Duplicator; + +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs\RelationDefs; +use Espo\ORM\Entity; +use Espo\ORM\Defs; +use Espo\ORM\Defs\FieldDefs; +use Espo\Core\Utils\FieldUtil; +use Espo\ORM\Type\RelationType; +use stdClass; + +/** + * Duplicates an entity. + */ +class EntityDuplicator +{ + public function __construct( + private Defs $defs, + private FieldDuplicatorFactory $fieldDuplicatorFactory, + private FieldUtil $fieldUtil, + private Metadata $metadata, + ) {} + + public function duplicate(Entity $entity): stdClass + { + $entityType = $entity->getEntityType(); + $valueMap = $entity->getValueMap(); + + unset($valueMap->id); + + $entityDefs = $this->defs->getEntity($entityType); + + foreach ($entityDefs->getFieldList() as $fieldDefs) { + $this->processIgnoreField($entity, $fieldDefs, $valueMap); + } + + foreach ($entityDefs->getFieldList() as $fieldDefs) { + $this->processField($entity, $fieldDefs, $valueMap); + } + + foreach ($entityDefs->getRelationList() as $relationDefs) { + $this->processLink($entity, $relationDefs, $entityDefs, $valueMap); + } + + return $valueMap; + } + + private function processIgnoreField(Entity $entity, FieldDefs $fieldDefs, stdClass $valueMap): void + { + $entityType = $entity->getEntityType(); + $field = $fieldDefs->getName(); + + if ($this->toIgnoreField($entityType, $fieldDefs)) { + $attributeList = $this->fieldUtil->getAttributeList($entityType, $field); + + foreach ($attributeList as $attribute) { + unset($valueMap->$attribute); + } + } + } + + private function processField(Entity $entity, FieldDefs $fieldDefs, stdClass $valueMap): void + { + $entityType = $entity->getEntityType(); + $field = $fieldDefs->getName(); + + if ($this->toIgnoreField($entityType, $fieldDefs)) { + return; + } + + if (!$this->fieldDuplicatorFactory->has($entityType, $field)) { + return; + } + + $fieldDuplicator = $this->fieldDuplicatorFactory->create($entityType, $field); + + $fieldValueMap = $fieldDuplicator->duplicate($entity, $field); + + foreach (get_object_vars($fieldValueMap) as $attribute => $value) { + $valueMap->$attribute = $value; + } + } + + private function processLink( + Entity $entity, + RelationDefs $relationDefs, + Defs\EntityDefs $entityDefs, + stdClass $valueMap, + ): void { + + $link = $relationDefs->getName(); + + if ( + !in_array($relationDefs->getType(), [ + RelationType::BELONGS_TO, + RelationType::BELONGS_TO_PARENT, + RelationType::HAS_ONE, + ]) + ) { + return; + } + + if ($entityDefs->hasField($link)) { + return; + } + + unset($valueMap->{$link . 'Id'}); + unset($valueMap->{$link . 'Name'}); + + if ($relationDefs->getType() === RelationType::BELONGS_TO_PARENT) { + unset($valueMap->{$link . 'Type'}); + } + } + + private function toIgnoreField(string $entityType, FieldDefs $fieldDefs): bool + { + $type = $fieldDefs->getType(); + + if ( + in_array($type, [ + FieldType::AUTOINCREMENT, + FieldType::NUMBER, + FieldType::LINK_ONE, + // Additional measure. It's not fetched. + FieldType::PASSWORD, + ]) + ) { + return true; + } + + if ($this->metadata->get(['scopes', $entityType, 'statusField']) === $fieldDefs->getName()) { + return true; + } + + return (bool) $fieldDefs->getParam('duplicateIgnore'); + } +} diff --git a/application/Espo/Core/Record/Duplicator/FieldDuplicator.php b/application/Espo/Core/Record/Duplicator/FieldDuplicator.php new file mode 100644 index 0000000..4237ac9 --- /dev/null +++ b/application/Espo/Core/Record/Duplicator/FieldDuplicator.php @@ -0,0 +1,48 @@ +. + * + * 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\Duplicator; + +use Espo\ORM\Entity; + +use stdClass; + +/** + * Duplicates attributes of a field. Some fields can require some processing + * when an entity is being duplicated. + * + * @template TEntity of Entity = Entity + */ +interface FieldDuplicator +{ + /** + * @param TEntity $entity + */ + public function duplicate(Entity $entity, string $field): stdClass; +} diff --git a/application/Espo/Core/Record/Duplicator/FieldDuplicatorFactory.php b/application/Espo/Core/Record/Duplicator/FieldDuplicatorFactory.php new file mode 100644 index 0000000..9637fb1 --- /dev/null +++ b/application/Espo/Core/Record/Duplicator/FieldDuplicatorFactory.php @@ -0,0 +1,103 @@ +. + * + * 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\Duplicator; + +use Espo\Core\Acl; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Entities\User; +use Espo\ORM\Defs; +use Espo\Core\Utils\Metadata; +use Espo\Core\InjectableFactory; + +use RuntimeException; + +class FieldDuplicatorFactory +{ + public function __construct( + private Defs $defs, + private Metadata $metadata, + private InjectableFactory $injectableFactory, + private Acl $acl, + private User $user, + ) {} + + public function create(string $entityType, string $field): FieldDuplicator + { + $className = $this->getClassName($entityType, $field); + + if (!$className) { + throw new RuntimeException("No field duplicator for the field."); + } + + return $this->injectableFactory->createWithBinding($className, $this->createBinding()); + } + + public function has(string $entityType, string $field): bool + { + return $this->getClassName($entityType, $field) !== null; + } + + /** + * @return ?class-string + */ + private function getClassName(string $entityType, string $field): ?string + { + $fieldDefs = $this->defs + ->getEntity($entityType) + ->getField($field); + + $className1 = $fieldDefs->getParam('duplicatorClassName'); + + if ($className1) { + /** @var class-string */ + return $className1; + } + + $type = $fieldDefs->getType(); + + $className2 = $this->metadata->get(['fields', $type, 'duplicatorClassName']); + + if ($className2) { + /** @var class-string */ + return $className2; + } + + return null; + } + + private function createBinding(): BindingContainer + { + return BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->bindInstance(Acl::class, $this->acl) + ->build(); + } +} diff --git a/application/Espo/Core/Record/DynamicLogic/InputFilterProcessor.php b/application/Espo/Core/Record/DynamicLogic/InputFilterProcessor.php new file mode 100644 index 0000000..9ab2966 --- /dev/null +++ b/application/Espo/Core/Record/DynamicLogic/InputFilterProcessor.php @@ -0,0 +1,90 @@ +. + * + * 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\DynamicLogic; + +use Espo\Core\Utils\FieldUtil; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; +use Espo\Tools\DynamicLogic\ConditionChecker; +use Espo\Tools\DynamicLogic\ConditionCheckerFactory; +use Espo\Tools\DynamicLogic\Exceptions\BadCondition; +use Espo\Tools\DynamicLogic\Item; +use RuntimeException; +use stdClass; + +class InputFilterProcessor +{ + public function __construct( + private Metadata $metadata, + private FieldUtil $fieldUtil, + private ConditionCheckerFactory $conditionCheckerFactory, + ) {} + + public function process(Entity $entity, stdClass $input): void + { + /** @var array> $fieldsDefs */ + $fieldsDefs = $this->metadata->get("logicDefs.{$entity->getEntityType()}.fields") ?? []; + + $checker = null; + + foreach ($fieldsDefs as $field => $defs) { + if ($defs['readOnlySaved'] ?? null) { + $checker ??= $this->conditionCheckerFactory->create($entity); + + $this->processField($entity, $input, $field, $checker); + } + } + } + + private function processField(Entity $entity, stdClass $input, string $field, ConditionChecker $checker): void + { + /** @var ?stdClass[] $group */ + $group = $this->metadata + ->getObjects("logicDefs.{$entity->getEntityType()}.fields.$field.readOnlySaved.conditionGroup"); + + if (!$group) { + return; + } + + try { + $item = Item::fromGroupDefinition($group); + + if (!$checker->check($item)) { + return; + } + } catch (BadCondition $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + + foreach ($this->fieldUtil->getAttributeList($entity->getEntityType(), $field) as $attribute) { + unset($input->$attribute); + } + } +} diff --git a/application/Espo/Core/Record/EntityProvider.php b/application/Espo/Core/Record/EntityProvider.php new file mode 100644 index 0000000..11a6ccd --- /dev/null +++ b/application/Espo/Core/Record/EntityProvider.php @@ -0,0 +1,105 @@ +. + * + * 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; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * Fetches entities. + * + * @since 8.1.0 + */ +class EntityProvider +{ + public function __construct( + private EntityManager $entityManager, + private Acl $acl + ) {} + + /** + * Fetch an entity. + * + * @template T of Entity + * @param class-string $className An entity class name. + * @return T + * @throws NotFound A record not found. + * @throws Forbidden Read is forbidden for a current user. + * @since 8.3.0 + * @noinspection PhpDocSignatureInspection + */ + public function getByClass(string $className, string $id): Entity + { + $entity = $this->entityManager + ->getRDBRepositoryByClass($className) + ->getById($id); + + return $this->processGet($entity); + } + + /** + * Fetch an entity by an entity type. + * + * @return Entity + * @throws NotFound + * @throws Forbidden + * @since 9.0.0 + */ + public function get(string $entityType, string $id): Entity + { + $entity = $this->entityManager->getEntityById($entityType, $id); + + return $this->processGet($entity); + } + + /** + * @template T of Entity + * @param ?T $entity + * @return T + * @throws Forbidden + * @throws NotFound + * @noinspection PhpDocSignatureInspection + */ + private function processGet(?Entity $entity): Entity + { + if (!$entity) { + throw new NotFound(); + } + + if (!$this->acl->checkEntityRead($entity)) { + throw new Forbidden(); + } + + return $entity; + } +} diff --git a/application/Espo/Core/Record/Exceptions/DuplicateConflict.php b/application/Espo/Core/Record/Exceptions/DuplicateConflict.php new file mode 100644 index 0000000..a4c6d71 --- /dev/null +++ b/application/Espo/Core/Record/Exceptions/DuplicateConflict.php @@ -0,0 +1,69 @@ +. + * + * 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\Exceptions; + +use Espo\Core\Exceptions\ConflictSilent; +use Espo\ORM\Collection; +use Espo\Core\Utils\Json; +use Espo\ORM\Entity; +use UnexpectedValueException; + +/** + * @since 10.0.5 + */ +class DuplicateConflict extends ConflictSilent +{ + /** + * @var ?Collection + */ + private ?Collection $duplicates = null; + + /** + * Important. Use `prepareEntityForOutput` on entities before passing the collection. + * + * @param Collection $duplicates + */ + public static function create(Collection $duplicates): self + { + $object = DuplicateConflict::createWithBody('duplicate', Json::encode($duplicates->getValueMapList())); + + $object->duplicates = $duplicates; + + return $object; + } + + /** + * @return Collection + */ + public function getDuplicates(): Collection + { + return $this->duplicates ?? throw new UnexpectedValueException("No collection."); + } +} diff --git a/application/Espo/Core/Record/FindParams.php b/application/Espo/Core/Record/FindParams.php new file mode 100644 index 0000000..cca6dc4 --- /dev/null +++ b/application/Espo/Core/Record/FindParams.php @@ -0,0 +1,58 @@ +. + * + * 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; + +/** + * Immutable. + */ +class FindParams +{ + private bool $noTotal = false; + + public function __construct() {} + + public function withNoTotal(bool $noTotal = true): self + { + $obj = clone $this; + $obj->noTotal = $noTotal; + + return $obj; + } + + public function noTotal(): bool + { + return $this->noTotal; + } + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Record/FindParamsFetcher.php b/application/Espo/Core/Record/FindParamsFetcher.php new file mode 100644 index 0000000..96cbeb3 --- /dev/null +++ b/application/Espo/Core/Record/FindParamsFetcher.php @@ -0,0 +1,49 @@ +. + * + * 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; + +use Espo\Core\Api\Request; + +class FindParamsFetcher +{ + public function __construct() {} + + public function fetch(Request $request): FindParams + { + $noTotal = strtolower($request->getHeader('X-No-Total') ?? '') === 'true'; + + if ($request->getQueryParam('q')) { + $noTotal = true; + } + + return FindParams::create() + ->withNoTotal($noTotal); + } +} diff --git a/application/Espo/Core/Record/Formula/Processor.php b/application/Espo/Core/Record/Formula/Processor.php new file mode 100644 index 0000000..2ffa2c9 --- /dev/null +++ b/application/Espo/Core/Record/Formula/Processor.php @@ -0,0 +1,122 @@ +. + * + * 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\Formula; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Formula\Exceptions\Error as FormulaError; +use Espo\Core\Formula\Exceptions\WrapperException; +use Espo\Core\Formula\Manager as FormulaManager; +use Espo\Core\Record\CreateParams; +use Espo\Core\Record\UpdateParams; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; +use RuntimeException; +use stdClass; + +/** + * Formula script processing for API requests. + */ +class Processor +{ + public function __construct( + private FormulaManager $formulaManager, + private Metadata $metadata, + ) {} + + /** + * Process a before-create formula script. + * + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processBeforeCreate(Entity $entity, CreateParams $params): void + { + $script = $this->getScript($entity->getEntityType()); + + if (!$script) { + return; + } + + $variables = (object) [ + '__skipDuplicateCheck' => $params->skipDuplicateCheck(), + '__isRecordService' => true, + ]; + + $this->run($script, $entity, $variables); + } + + /** + * Process a before-update formula script. + * + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processBeforeUpdate(Entity $entity, UpdateParams $params): void + { + $script = $this->getScript($entity->getEntityType()); + + if (!$script) { + return; + } + + $variables = (object) [ + '__skipDuplicateCheck' => $params->skipDuplicateCheck(), + '__isRecordService' => true, + ]; + + $this->run($script, $entity, $variables); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + private function run(string $script, Entity $entity, stdClass $variables): void + { + try { + $this->formulaManager->run($script, $entity, $variables); + } catch (WrapperException $e) { + throw $e->getWrappedException(); + } catch (FormulaError $e) { + throw new RuntimeException('Before save API script error.', previous: $e); + } + } + + private function getScript(string $entityType): ?string + { + /** @var ?string */ + return $this->metadata->get(['formula', $entityType, 'beforeSaveApiScript']); + } +} diff --git a/application/Espo/Core/Record/Hook/CreateHook.php b/application/Espo/Core/Record/Hook/CreateHook.php new file mode 100644 index 0000000..358e43f --- /dev/null +++ b/application/Espo/Core/Record/Hook/CreateHook.php @@ -0,0 +1,50 @@ +. + * + * 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\Hook; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +use Espo\ORM\Entity; +use Espo\Core\Record\CreateParams; + +/** + * @template TEntity of Entity = Entity + */ +interface CreateHook +{ + /** + * @param TEntity $entity + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function process(Entity $entity, CreateParams $params): void; +} diff --git a/application/Espo/Core/Record/Hook/DeleteHook.php b/application/Espo/Core/Record/Hook/DeleteHook.php new file mode 100644 index 0000000..490d7db --- /dev/null +++ b/application/Espo/Core/Record/Hook/DeleteHook.php @@ -0,0 +1,50 @@ +. + * + * 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\Hook; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +use Espo\ORM\Entity; +use Espo\Core\Record\DeleteParams; + +/** + * @template TEntity of Entity = Entity + */ +interface DeleteHook +{ + /** + * @param TEntity $entity + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function process(Entity $entity, DeleteParams $params): void; +} diff --git a/application/Espo/Core/Record/Hook/LinkHook.php b/application/Espo/Core/Record/Hook/LinkHook.php new file mode 100644 index 0000000..cc6afd9 --- /dev/null +++ b/application/Espo/Core/Record/Hook/LinkHook.php @@ -0,0 +1,43 @@ +. + * + * 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\Hook; + +use Espo\ORM\Entity; + +/** + * @template TEntity of Entity = Entity + */ +interface LinkHook +{ + /** + * @param TEntity $entity + */ + public function process(Entity $entity, string $link, Entity $foreignEntity): void; +} diff --git a/application/Espo/Core/Record/Hook/Provider.php b/application/Espo/Core/Record/Hook/Provider.php new file mode 100644 index 0000000..8d0b713 --- /dev/null +++ b/application/Espo/Core/Record/Hook/Provider.php @@ -0,0 +1,135 @@ +. + * + * 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\Hook; + +use Espo\Core\Acl; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Utils\Metadata; +use Espo\Core\InjectableFactory; + +use Espo\Entities\User; +use ReflectionClass; +use RuntimeException; + +class Provider +{ + /** @var array */ + private $map = []; + + /** @var array */ + private $typeInterfaceListMap = [ + Type::BEFORE_READ => [ReadHook::class], + Type::EARLY_BEFORE_CREATE => [CreateHook::class, SaveHook::class], + Type::BEFORE_CREATE => [CreateHook::class, SaveHook::class], + Type::AFTER_CREATE => [CreateHook::class, SaveHook::class], + Type::EARLY_BEFORE_UPDATE => [UpdateHook::class, SaveHook::class], + Type::BEFORE_UPDATE => [UpdateHook::class, SaveHook::class], + Type::AFTER_UPDATE => [UpdateHook::class, SaveHook::class], + Type::BEFORE_DELETE => [DeleteHook::class], + Type::AFTER_DELETE => [DeleteHook::class], + Type::BEFORE_LINK => [LinkHook::class], + Type::BEFORE_UNLINK => [UnlinkHook::class], + Type::AFTER_LINK => [LinkHook::class], + Type::AFTER_UNLINK => [UnlinkHook::class], + ]; + + private BindingContainer $bindingContainer; + + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory, + private Acl $acl, + private User $user + ) { + $this->bindingContainer = BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->bindInstance(Acl::class, $this->acl) + ->build(); + } + + /** + * @return object[] + */ + public function getList(string $entityType, string $type): array + { + $key = $entityType . '_' . $type; + + if (!array_key_exists($key, $this->map)) { + $this->map[$key] = $this->loadList($entityType, $type); + } + + return $this->map[$key]; + } + + /** + * @return object[] + */ + private function loadList(string $entityType, string $type): array + { + $key = $type . 'HookClassNameList'; + + /** @var class-string[] $classNameList */ + $classNameList = [ + ...$this->metadata->get("app.record.$key", []), + ...$this->metadata->get("recordDefs.$entityType.$key", []) + ]; + + $interfaces = $this->typeInterfaceListMap[$type] ?? null; + + if (!$interfaces) { + throw new RuntimeException("Unsupported record hook type '$type'."); + } + + $list = []; + + foreach ($classNameList as $className) { + $class = new ReflectionClass($className); + + $found = false; + + foreach ($interfaces as $interface) { + if ($class->implementsInterface($interface)) { + $found = true; + + break; + } + } + + if (!$found) { + throw new RuntimeException("Hook '$className' does not implement any required interface."); + } + + $list[] = $this->injectableFactory->createWithBinding($className, $this->bindingContainer); + } + + return $list; + } +} diff --git a/application/Espo/Core/Record/Hook/ReadHook.php b/application/Espo/Core/Record/Hook/ReadHook.php new file mode 100644 index 0000000..f3d2c2e --- /dev/null +++ b/application/Espo/Core/Record/Hook/ReadHook.php @@ -0,0 +1,44 @@ +. + * + * 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\Hook; + +use Espo\ORM\Entity; +use Espo\Core\Record\ReadParams; + +/** + * @template TEntity of Entity = Entity + */ +interface ReadHook +{ + /** + * @param TEntity $entity + */ + public function process(Entity $entity, ReadParams $params): void; +} diff --git a/application/Espo/Core/Record/Hook/SaveHook.php b/application/Espo/Core/Record/Hook/SaveHook.php new file mode 100644 index 0000000..041de4d --- /dev/null +++ b/application/Espo/Core/Record/Hook/SaveHook.php @@ -0,0 +1,52 @@ +. + * + * 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\Hook; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +use Espo\ORM\Entity; + +/** + * On record create or update. + * + * @template TEntity of Entity = Entity + * @since 8.1.0. + */ +interface SaveHook +{ + /** + * @param TEntity $entity + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function process(Entity $entity): void; +} diff --git a/application/Espo/Core/Record/Hook/Type.php b/application/Espo/Core/Record/Hook/Type.php new file mode 100644 index 0000000..50577ef --- /dev/null +++ b/application/Espo/Core/Record/Hook/Type.php @@ -0,0 +1,51 @@ +. + * + * 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\Hook; + +class Type +{ + public const BEFORE_READ = 'beforeRead'; + + public const EARLY_BEFORE_CREATE = 'earlyBeforeCreate'; + public const EARLY_BEFORE_UPDATE = 'earlyBeforeUpdate'; + + public const BEFORE_CREATE = 'beforeCreate'; + public const BEFORE_UPDATE = 'beforeUpdate'; + public const BEFORE_DELETE = 'beforeDelete'; + + public const AFTER_CREATE = 'afterCreate'; + public const AFTER_UPDATE = 'afterUpdate'; + public const AFTER_DELETE = 'afterDelete'; + + public const BEFORE_LINK = 'beforeLink'; + public const BEFORE_UNLINK = 'beforeUnlink'; + public const AFTER_LINK = 'afterLink'; + public const AFTER_UNLINK = 'afterUnlink'; +} diff --git a/application/Espo/Core/Record/Hook/UnlinkHook.php b/application/Espo/Core/Record/Hook/UnlinkHook.php new file mode 100644 index 0000000..dd94b30 --- /dev/null +++ b/application/Espo/Core/Record/Hook/UnlinkHook.php @@ -0,0 +1,43 @@ +. + * + * 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\Hook; + +use Espo\ORM\Entity; + +/** + * @template TEntity of Entity = Entity + */ +interface UnlinkHook +{ + /** + * @param TEntity $entity + */ + public function process(Entity $entity, string $link, Entity $foreignEntity): void; +} diff --git a/application/Espo/Core/Record/Hook/UpdateHook.php b/application/Espo/Core/Record/Hook/UpdateHook.php new file mode 100644 index 0000000..a1890ea --- /dev/null +++ b/application/Espo/Core/Record/Hook/UpdateHook.php @@ -0,0 +1,51 @@ +. + * + * 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\Hook; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +use Espo\ORM\Entity; + +use Espo\Core\Record\UpdateParams; + +/** + * @template TEntity of Entity = Entity + */ +interface UpdateHook +{ + /** + * @param TEntity $entity + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function process(Entity $entity, UpdateParams $params): void; +} diff --git a/application/Espo/Core/Record/HookManager.php b/application/Espo/Core/Record/HookManager.php new file mode 100644 index 0000000..5053982 --- /dev/null +++ b/application/Espo/Core/Record/HookManager.php @@ -0,0 +1,334 @@ +. + * + * 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; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\CreateHook; +use Espo\Core\Record\Hook\DeleteHook; +use Espo\Core\Record\Hook\LinkHook; +use Espo\Core\Record\Hook\ReadHook; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Core\Record\Hook\UnlinkHook; +use Espo\Core\Record\Hook\UpdateHook; +use Espo\Core\Record\Hook\Provider; +use Espo\Core\Record\Hook\Type; +use Espo\ORM\Entity; + +class HookManager +{ + public function __construct(private Provider $provider) + {} + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processEarlyBeforeCreate(Entity $entity, CreateParams $params): void + { + foreach ($this->getEarlyBeforeCreateHookList($entity->getEntityType()) as $hook) { + if ($hook instanceof SaveHook) { + $hook->process($entity); + + continue; + } + + $hook->process($entity, $params); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processBeforeCreate(Entity $entity, CreateParams $params): void + { + foreach ($this->getBeforeCreateHookList($entity->getEntityType()) as $hook) { + if ($hook instanceof SaveHook) { + $hook->process($entity); + + continue; + } + + $hook->process($entity, $params); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processAfterCreate(Entity $entity, CreateParams $params): void + { + foreach ($this->getAfterCreateHookList($entity->getEntityType()) as $hook) { + if ($hook instanceof SaveHook) { + $hook->process($entity); + + continue; + } + + $hook->process($entity, $params); + } + } + + public function processBeforeRead(Entity $entity, ReadParams $params): void + { + foreach ($this->getBeforeReadHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $params); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processEarlyBeforeUpdate(Entity $entity, UpdateParams $params): void + { + foreach ($this->getEarlyBeforeUpdateHookList($entity->getEntityType()) as $hook) { + if ($hook instanceof SaveHook) { + $hook->process($entity); + + continue; + } + + $hook->process($entity, $params); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processBeforeUpdate(Entity $entity, UpdateParams $params): void + { + foreach ($this->getBeforeUpdateHookList($entity->getEntityType()) as $hook) { + if ($hook instanceof SaveHook) { + $hook->process($entity); + + continue; + } + + $hook->process($entity, $params); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processAfterUpdate(Entity $entity, UpdateParams $params): void + { + foreach ($this->getAfterUpdateHookList($entity->getEntityType()) as $hook) { + if ($hook instanceof SaveHook) { + $hook->process($entity); + + continue; + } + + $hook->process($entity, $params); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processBeforeDelete(Entity $entity, DeleteParams $params): void + { + foreach ($this->getBeforeDeleteHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $params); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws Conflict + */ + public function processAfterDelete(Entity $entity, DeleteParams $params): void + { + foreach ($this->getAfterDeleteHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $params); + } + } + + public function processBeforeLink(Entity $entity, string $link, Entity $foreignEntity): void + { + foreach ($this->getBeforeLinkHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $link, $foreignEntity); + } + } + + public function processBeforeUnlink(Entity $entity, string $link, Entity $foreignEntity): void + { + foreach ($this->getBeforeUnlinkHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $link, $foreignEntity); + } + } + + public function processAfterLink(Entity $entity, string $link, Entity $foreignEntity): void + { + foreach ($this->getAfterLinkHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $link, $foreignEntity); + } + } + + public function processAfterUnlink(Entity $entity, string $link, Entity $foreignEntity): void + { + foreach ($this->getAfterUnlinkHookList($entity->getEntityType()) as $hook) { + $hook->process($entity, $link, $foreignEntity); + } + } + + /** + * @return ReadHook[] + */ + private function getBeforeReadHookList(string $entityType): array + { + /** @var ReadHook[] */ + return $this->provider->getList($entityType, Type::BEFORE_READ); + } + + /** + * @return (CreateHook|SaveHook)[] + */ + private function getEarlyBeforeCreateHookList(string $entityType): array + { + /** @var (CreateHook|SaveHook)[] */ + return $this->provider->getList($entityType, Type::EARLY_BEFORE_CREATE); + } + + /** + * @return (CreateHook|SaveHook)[] + */ + private function getBeforeCreateHookList(string $entityType): array + { + /** @var (CreateHook|SaveHook)[] */ + return $this->provider->getList($entityType, Type::BEFORE_CREATE); + } + + /** + * @return (CreateHook|SaveHook)[] + */ + private function getAfterCreateHookList(string $entityType): array + { + /** @var (CreateHook|SaveHook)[] */ + return $this->provider->getList($entityType, Type::AFTER_CREATE); + } + + /** + * @return (UpdateHook|SaveHook)[] + */ + private function getEarlyBeforeUpdateHookList(string $entityType): array + { + /** @var (UpdateHook|SaveHook)[] */ + return $this->provider->getList($entityType, Type::EARLY_BEFORE_UPDATE); + } + + /** + * @return (UpdateHook|SaveHook)[] + */ + private function getBeforeUpdateHookList(string $entityType): array + { + /** @var (UpdateHook|SaveHook)[] */ + return $this->provider->getList($entityType, Type::BEFORE_UPDATE); + } + + /** + * @return (UpdateHook|SaveHook)[] + */ + private function getAfterUpdateHookList(string $entityType): array + { + /** @var (UpdateHook|SaveHook)[] */ + return $this->provider->getList($entityType, Type::AFTER_UPDATE); + } + + /** + * @return DeleteHook[] + */ + private function getBeforeDeleteHookList(string $entityType): array + { + /** @var DeleteHook[] */ + return $this->provider->getList($entityType, Type::BEFORE_DELETE); + } + + /** + * @return DeleteHook[] + */ + private function getAfterDeleteHookList(string $entityType): array + { + /** @var DeleteHook[] */ + return $this->provider->getList($entityType, Type::AFTER_DELETE); + } + + /** + * @return LinkHook[] + */ + private function getBeforeLinkHookList(string $entityType): array + { + /** @var LinkHook[] */ + return $this->provider->getList($entityType, Type::BEFORE_LINK); + } + + /** + * @return UnlinkHook[] + */ + private function getBeforeUnlinkHookList(string $entityType): array + { + /** @var UnlinkHook[] */ + return $this->provider->getList($entityType, Type::BEFORE_UNLINK); + } + + /** + * @return LinkHook[] + */ + private function getAfterLinkHookList(string $entityType): array + { + /** @var LinkHook[] */ + return $this->provider->getList($entityType, Type::AFTER_LINK); + } + + /** + * @return UnlinkHook[] + */ + private function getAfterUnlinkHookList(string $entityType): array + { + /** @var UnlinkHook[] */ + return $this->provider->getList($entityType, Type::AFTER_UNLINK); + } +} diff --git a/application/Espo/Core/Record/Input/Data.php b/application/Espo/Core/Record/Input/Data.php new file mode 100644 index 0000000..a8fa3dc --- /dev/null +++ b/application/Espo/Core/Record/Input/Data.php @@ -0,0 +1,90 @@ +. + * + * 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\Input; + +use stdClass; + +class Data +{ + public function __construct(private stdClass $raw) {} + + /** + * Get all attributes. + * + * @return string[] + */ + public function getAttributeList(): array + { + return array_keys(get_object_vars($this->raw)); + } + + /** + * Unset an attribute. + * + * @param string $name An attribute name. + */ + public function clear(string $name): self + { + unset($this->raw->$name); + + return $this; + } + + /** + * Whether an attribute is set. + * + * @param string $name An attribute name. + */ + public function has(string $name): bool + { + return property_exists($this->raw, $name); + } + + /** + * Get an attribute value. + * + * @param string $name An attribute name. + */ + public function get(string $name): mixed + { + return $this->raw->$name ?? null; + } + + /** + * Set an attribute value. + * + * @param string $name An attribute name. + * @param mixed $value A value + */ + public function set(string $name, mixed $value): mixed + { + return $this->raw->$name = $value; + } +} diff --git a/application/Espo/Core/Record/Input/Filter.php b/application/Espo/Core/Record/Input/Filter.php new file mode 100644 index 0000000..8739cc8 --- /dev/null +++ b/application/Espo/Core/Record/Input/Filter.php @@ -0,0 +1,40 @@ +. + * + * 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\Input; + +/** + * An input filter. + * + * $entityType is passed to the constructor as of v9.3. + */ +interface Filter +{ + public function filter(Data $data): void; +} diff --git a/application/Espo/Core/Record/Input/FilterProvider.php b/application/Espo/Core/Record/Input/FilterProvider.php new file mode 100644 index 0000000..4d00c5d --- /dev/null +++ b/application/Espo/Core/Record/Input/FilterProvider.php @@ -0,0 +1,115 @@ +. + * + * 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\Input; + +use Espo\Core\Acl; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Binding\ContextualBinder; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; + +class FilterProvider +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private Acl $acl, + private User $user + ) {} + + /** + * @return Filter[] + */ + public function getForCreate(string $entityType): array + { + $list = []; + + foreach ($this->getCreateClassNameList($entityType) as $className) { + $list[] = $this->createFilter($className, $entityType); + } + + return $list; + } + + /** + * @return Filter[] + */ + public function getForUpdate(string $entityType): array + { + $list = []; + + foreach ($this->getUpdateClassNameList($entityType) as $className) { + $list[] = $this->createFilter($className, $entityType); + } + + return $list; + } + + /** + * @param class-string $className + */ + private function createFilter(string $className, string $entityType): Filter + { + $binding = BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->bindInstance(Acl::class, $this->acl) + ->inContext($className, function (ContextualBinder $binder) use ($entityType) { + $binder->bindValue('$entityType', $entityType); + }) + ->build(); + + return $this->injectableFactory->createWithBinding($className, $binding); + } + + /** + * @return class-string[] + */ + private function getCreateClassNameList(string $entityType): array + { + /** @var class-string[] */ + return [ + ...$this->metadata->get("app.record.createInputFilterClassNameList", []), + ...$this->metadata->get("recordDefs.$entityType.createInputFilterClassNameList", []) + ]; + } + + /** + * @return class-string[] + */ + private function getUpdateClassNameList(string $entityType): array + { + /** @var class-string[] */ + return [ + ...$this->metadata->get("app.record.updateInputFilterClassNameList", []), + ...$this->metadata->get("recordDefs.$entityType.updateInputFilterClassNameList", []) + ]; + } +} diff --git a/application/Espo/Core/Record/LinkResult.php b/application/Espo/Core/Record/LinkResult.php new file mode 100644 index 0000000..70ea2f4 --- /dev/null +++ b/application/Espo/Core/Record/LinkResult.php @@ -0,0 +1,36 @@ +. + * + * 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; + +/** + * @since 10.0.0 + */ +class LinkResult +{} diff --git a/application/Espo/Core/Record/MassLinkResult.php b/application/Espo/Core/Record/MassLinkResult.php new file mode 100644 index 0000000..3c5aee9 --- /dev/null +++ b/application/Espo/Core/Record/MassLinkResult.php @@ -0,0 +1,45 @@ +. + * + * 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; + +/** + * @since 10.0.0 + */ +class MassLinkResult +{ + public function __construct( + private ?int $affectedCount = null, + ) {} + + public function getAffectedCount(): ?int + { + return $this->affectedCount; + } +} diff --git a/application/Espo/Core/Record/Output/Filter.php b/application/Espo/Core/Record/Output/Filter.php new file mode 100644 index 0000000..287fe72 --- /dev/null +++ b/application/Espo/Core/Record/Output/Filter.php @@ -0,0 +1,45 @@ +. + * + * 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\Output; + +use Espo\ORM\Entity; + +/** + * Filters entity attribute values for output. + * + * @template TEntity of Entity + */ +interface Filter +{ + /** + * @param TEntity $entity + */ + public function filter(Entity $entity): void; +} diff --git a/application/Espo/Core/Record/Output/FilterProvider.php b/application/Espo/Core/Record/Output/FilterProvider.php new file mode 100644 index 0000000..fc5d8f0 --- /dev/null +++ b/application/Espo/Core/Record/Output/FilterProvider.php @@ -0,0 +1,77 @@ +. + * + * 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\Output; + +use Espo\Core\Acl; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\ORM\Entity; + +class FilterProvider +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private Acl $acl, + private User $user + ) {} + + /** + * @return Filter[] + */ + public function get(string $entityType): array + { + $classNameList = $this->getClassNameList($entityType); + + $binding = BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->bindInstance(Acl::class, $this->acl) + ->build(); + + return array_map( + fn ($className) => $this->injectableFactory->createWithBinding($className, $binding), + $classNameList + ); + } + + /** + * @return class-string>[] + */ + private function getClassNameList(string $entityType): array + { + /** @var class-string>[] */ + return [ + ...$this->metadata->get("app.record.outputFilterClassNameList", []), + ...$this->metadata->get("recordDefs.$entityType.outputFilterClassNameList", []), + ]; + } +} diff --git a/application/Espo/Core/Record/ReadParams.php b/application/Espo/Core/Record/ReadParams.php new file mode 100644 index 0000000..9f55e50 --- /dev/null +++ b/application/Espo/Core/Record/ReadParams.php @@ -0,0 +1,43 @@ +. + * + * 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; + +/** + * Immutable. + */ +class ReadParams +{ + public function __construct() {} + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Record/ReadParamsFetcher.php b/application/Espo/Core/Record/ReadParamsFetcher.php new file mode 100644 index 0000000..f33f0d3 --- /dev/null +++ b/application/Espo/Core/Record/ReadParamsFetcher.php @@ -0,0 +1,42 @@ +. + * + * 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; + +use Espo\Core\Api\Request; + +class ReadParamsFetcher +{ + public function __construct() {} + + public function fetch(Request $request): ReadParams + { + return ReadParams::create(); + } +} diff --git a/application/Espo/Core/Record/ReadResult.php b/application/Espo/Core/Record/ReadResult.php new file mode 100644 index 0000000..f3ffbd3 --- /dev/null +++ b/application/Espo/Core/Record/ReadResult.php @@ -0,0 +1,68 @@ +. + * + * 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; + +use Espo\ORM\Entity; +use stdClass; + +/** + * @template TEntity of Entity = Entity + * @since 10.0.0 + */ +readonly class ReadResult +{ + /** + * @param TEntity $entity + */ + public function __construct( + private Entity $entity, + ) {} + + /** + * @return TEntity + */ + public function getEntity(): Entity + { + return $this->entity; + } + + public function getValueMap(): stdClass + { + return $this->entity->getValueMap(); + } + + /** + * @deprecated Since v10.0.0. For bc. Use entity->get(...). + */ + public function get(string $name): mixed + { + return $this->entity->get($name); + } +} diff --git a/application/Espo/Core/Record/SearchParamsFetcher.php b/application/Espo/Core/Record/SearchParamsFetcher.php new file mode 100644 index 0000000..2eb4072 --- /dev/null +++ b/application/Espo/Core/Record/SearchParamsFetcher.php @@ -0,0 +1,255 @@ +. + * + * 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; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Utils\Config; +use Espo\Core\Api\Request; +use Espo\Core\Select\SearchParams; +use Espo\Core\Select\Text\MetadataProvider as TextMetadataProvider; +use Espo\Core\Utils\Json; + +use InvalidArgumentException; +use JsonException; + +class SearchParamsFetcher +{ + private const MAX_SIZE_LIMIT = 200; + + public function __construct( + private Config $config, + private TextMetadataProvider $textMetadataProvider + ) {} + + /** + * Fetch search params from a request. + * + * @throws BadRequest + * @throws Forbidden + */ + public function fetch(Request $request): SearchParams + { + try { + return SearchParams::fromRaw($this->fetchRaw($request)); + } catch (InvalidArgumentException $e) { + throw new BadRequest($e->getMessage()); + } + } + + /** + * @return array + * @throws BadRequest + * @throws Forbidden + */ + private function fetchRaw(Request $request): array + { + $params = $request->hasQueryParam('searchParams') ? + $this->fetchRawJsonSearchParams($request): + $this->fetchRawMultipleParams($request); + + $this->handleRawParams($params, $request); + + return $params; + } + + /** + * @return array + * @throws BadRequest + */ + private function fetchRawJsonSearchParams(Request $request): array + { + try { + return Json::decode($request->getQueryParam('searchParams') ?? '', true); + } catch (JsonException) { + throw new BadRequest("Invalid search params JSON."); + } + } + + /** + * @return array + */ + private function fetchRawMultipleParams(Request $request): array + { + $params = []; + + $queryParams = $request->getQueryParams(); + + $params['where'] = $queryParams['whereGroup'] ?? $queryParams['where'] ?? null; + + if ($params['where'] !== null && !is_array($params['where'])) { + $params['where'] = null; + } + + $params['maxSize'] = $request->getQueryParam('maxSize'); + $params['offset'] = $request->getQueryParam('offset'); + + if ($params['maxSize'] === '') { + $params['maxSize'] = null; + } + + if ($params['offset'] === '') { + $params['offset'] = null; + } + + if ($params['maxSize'] !== null) { + $params['maxSize'] = intval($params['maxSize']); + } + + if ($params['offset'] !== null) { + $params['offset'] = intval($params['offset']); + } + + if ($request->getQueryParam('orderBy')) { + $params['orderBy'] = $request->getQueryParam('orderBy'); + } else if ($request->getQueryParam('sortBy')) { + // legacy + $params['orderBy'] = $request->getQueryParam('sortBy'); + } + + if ($request->getQueryParam('order')) { + $params['order'] = strtoupper($request->getQueryParam('order')); + } else if ($request->getQueryParam('asc')) { + // legacy + $params['order'] = $request->getQueryParam('asc') === 'true' ? + SearchParams::ORDER_ASC : SearchParams::ORDER_DESC; + } + + $q = $request->getQueryParam('q'); + + if ($q) { + $params['q'] = trim($q); + } + + if ($request->getQueryParam('textFilter')) { + $params['textFilter'] = $request->getQueryParam('textFilter'); + } + + if ($request->getQueryParam('primaryFilter')) { + $params['primaryFilter'] = $request->getQueryParam('primaryFilter'); + } + + if ($queryParams['boolFilterList'] ?? null) { + $params['boolFilterList'] = (array) $queryParams['boolFilterList']; + } + + if ($queryParams['filterList'] ?? null) { + $params['filterList'] = (array) $queryParams['filterList']; + } + + $select = $request->getQueryParam('attributeSelect') ?? $request->getQueryParam('select'); + + if ($select) { + $params['select'] = explode(',', $select); + } + + return $params; + } + + /** + * @param array $params + * @throws BadRequest + * @throws Forbidden + */ + private function handleRawParams(array &$params, Request $request): void + { + if (isset($params['maxSize']) && !is_int($params['maxSize'])) { + throw new BadRequest('maxSize must be integer.'); + } + + $this->handleQ($params, $request); + $this->handleMaxSize($params); + } + + private function hasFullTextSearch(Request $request): bool + { + $scope = $request->getRouteParam('controller'); + + if (!$scope) { + return false; + } + + if ($request->getRouteParam('action') !== 'index') { + return false; + } + + return $this->textMetadataProvider->hasFullTextSearch($scope); + } + + /** + * @param array $params + * @throws Forbidden + */ + private function handleMaxSize(array &$params): void + { + $value = $params['maxSize'] ?? null; + + $limit = $this->config->get('recordListMaxSizeLimit') ?? self::MAX_SIZE_LIMIT; + + if ($value === null) { + $params['maxSize'] = $limit; + } + + if ($value > $limit) { + throw new Forbidden("Max size should not exceed $limit. Use offset and limit."); + } + } + + /** + * @param array $params + * @throws BadRequest + */ + private function handleQ(array &$params, Request $request): void + { + $q = $params['q'] ?? null; + + if ($q === null) { + return; + } + + if (!is_string($q)) { + throw new BadRequest("q must be string."); + } + + if (!$this->config->get('quickSearchFullTextAppendWildcard')) { + return; + } + + if ( + !str_contains($q, '*') && + !str_contains($q, '"') && + !str_contains($q, '+') && + !str_contains($q, '-') && + $this->hasFullTextSearch($request) + ) { + $params['q'] = $q . '*'; + } + } +} diff --git a/application/Espo/Core/Record/Select/ApplierClassNameListProvider.php b/application/Espo/Core/Record/Select/ApplierClassNameListProvider.php new file mode 100644 index 0000000..48d0310 --- /dev/null +++ b/application/Espo/Core/Record/Select/ApplierClassNameListProvider.php @@ -0,0 +1,50 @@ +. + * + * 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\Select; + +use Espo\Core\Select\Applier\AdditionalApplier; +use Espo\Core\Utils\Metadata; + +class ApplierClassNameListProvider +{ + public function __construct(private Metadata $metadata) + {} + + /** + * @return class-string[] + */ + public function get(string $entityType): array + { + return [ + ...$this->metadata->get("app.record.selectApplierClassNameList", []), + ...$this->metadata->get("recordDefs.$entityType.selectApplierClassNameList", []), + ]; + } +} diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php new file mode 100644 index 0000000..64afeae --- /dev/null +++ b/application/Espo/Core/Record/Service.php @@ -0,0 +1,1748 @@ +. + * + * 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; + +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Record\Exceptions\DuplicateConflict; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\ConflictSilent; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\ForbiddenSilent; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Exceptions\NotFoundSilent; +use Espo\Core\FieldSanitize\SanitizeManager; +use Espo\Core\ORM\Defs\AttributeParam; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\ORM\Repository\Option\RemoveOption; +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; +use Espo\Core\Record\ActionHistory\Action; +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\DynamicLogic\InputFilterProcessor; +use Espo\Core\Record\Formula\Processor as FormulaProcessor; +use Espo\Core\Record\Input\Data; +use Espo\Core\Record\Input\Filter; +use Espo\Core\Record\Input\FilterProvider; +use Espo\Core\Select\Primary\Filters\One; +use Espo\Core\Select\SelectBuilderFactory; +use Espo\Core\Utils\Json; +use Espo\Core\Acl; +use Espo\Core\Acl\Table as AclTable; +use Espo\Core\Duplicate\Finder as DuplicateFinder; +use Espo\Core\FieldProcessing\ListLoadProcessor; +use Espo\Core\FieldProcessing\Loader\Params as FieldLoaderParams; +use Espo\Core\FieldProcessing\ReadLoadProcessor; +use Espo\Core\FieldValidation\FieldValidationParams as FieldValidationParams; +use Espo\Core\Record\Collection as RecordCollection; +use Espo\Core\Record\Duplicator\EntityDuplicator; +use Espo\Core\Record\Select\ApplierClassNameListProvider; +use Espo\Core\Select\SearchParams; +use Espo\Core\Di; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Entity; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Repository\RDBRepository; +use Espo\ORM\Collection; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Type\AttributeType; +use Espo\Tools\Stream\Service as StreamService; +use Espo\Entities\User; + +use stdClass; +use InvalidArgumentException; +use LogicException; +use RuntimeException; + +/** + * The layer between a controller and ORM repository. For CRUD and other operations with records. + * Access control is processed here. + * + * Extending is not recommended. Use composition with metadata > recordDefs. + * + * @template TEntity of Entity + * @implements Crud + */ +class Service implements Crud, + + Di\ConfigAware, + Di\ServiceFactoryAware, + Di\EntityManagerAware, + Di\UserAware, + Di\MetadataAware, + Di\AclAware, + Di\InjectableFactoryAware, + Di\FieldUtilAware, + Di\FieldValidationManagerAware, + Di\RecordServiceContainerAware, + Di\AssignmentCheckerManagerAware +{ + use Di\ConfigSetter; + use Di\ServiceFactorySetter; + use Di\EntityManagerSetter; + use Di\UserSetter; + use Di\MetadataSetter; + use Di\AclSetter; + use Di\InjectableFactorySetter; + use Di\FieldUtilSetter; + use Di\FieldValidationManagerSetter; + use Di\RecordServiceContainerSetter; + use Di\AssignmentCheckerManagerSetter; + + protected string $entityType; + protected bool $getEntityBeforeUpdate = false; + + protected bool $maxSelectTextAttributeLengthDisabled = false; + protected ?int $maxSelectTextAttributeLength = null; + + private ?StreamService $streamService = null; + + private ?ListLoadProcessor $listLoadProcessor = null; + private ?DuplicateFinder $duplicateFinder = null; + private ?LinkCheck $linkCheck = null; + private ?ActionLogger $actionLogger = null; + /** @var ?DefaultsPopulator */ + private ?DefaultsPopulator $defaultsPopulator = null; + private ?HookManager $recordHookManager = null; + /** @var ?Filter[] */ + private ?array $createFilterList = null; + /** @var ?Filter[] */ + private ?array $updateFilterList = null; + /** @var ?Output\Filter[] */ + private ?array $outputFilterList = null; + + protected const int MAX_SELECT_TEXT_ATTRIBUTE_LENGTH = 10000; + + public function __construct( + protected SelectBuilderFactory $selectBuilderFactory, + string $entityType = '', + ) { + $this->entityType = $entityType; + + $this->initEntityType(); + } + + /** + * @internal + */ + protected function initEntityType(): void + {} + + /** + * @return RDBRepository + */ + protected function getRepository(): RDBRepository + { + return $this->entityManager->getRDBRepository($this->entityType); + } + + /** + * Add an action-history record. + * + * @param Action::* $action + * @noinspection PhpDocSignatureInspection + */ + public function processActionHistoryRecord(string $action, Entity $entity): void + { + if ( + $this->config->get('actionHistoryDisabled') || + $this->metadata->get("recordDefs.$this->entityType.actionHistoryDisabled") + ) { + return; + } + + $this->getActionLogger()->log($action, $entity); + } + + private function getActionLogger(): ActionLogger + { + if (!$this->actionLogger) { + $this->actionLogger = $this->injectableFactory->createResolved(ActionLogger::class); + } + + return $this->actionLogger; + } + + /** + * Read a record by ID. Access control check is performed. + * + * Is not supposed to be directly used in customizations. + * + * @param non-empty-string $id + * @return ReadResult + * @throws NotFoundSilent If not found. + * @throws Forbidden If no read access. + */ + public function read(string $id, ReadParams $params = new ReadParams()): ReadResult + { + if ($id === '') { + throw new InvalidArgumentException(); + } + + if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) { + throw new ForbiddenSilent("No read access."); + } + + $entity = $this->getEntity($id); + + if (!$entity) { + throw new NotFoundSilent("Record $id does not exist."); + } + + $this->getRecordHookManager()->processBeforeRead($entity, $params); + $this->processActionHistoryRecord(Action::READ, $entity); + + return new ReadResult($entity); + } + + /** + * Get an entity by ID. Access control check is performed. + * + * @throws Forbidden If no read access. + * @return ?TEntity + */ + public function getEntity(string $id): ?Entity + { + try { + $builder = $this->selectBuilderFactory->create() + ->from($this->entityType) + ->withSearchParams( + SearchParams::create() + ->withSelect(['*']) + ->withPrimaryFilter(One::NAME) + ) + ->withAdditionalApplierClassNameList( + $this->createSelectApplierClassNameListProvider()->get($this->entityType) + ); + + // @todo Apply access control filter. If a parameter enabled? Check compatibility. + + $query = $builder + ->buildQueryBuilder() + ->order([]) + ->build(); + } catch (BadRequest $e) { + throw new RuntimeException($e->getMessage()); + } + + $entity = $this->getRepository() + ->clone($query) + ->where([Attribute::ID => $id]) + ->findOne(); + + if (!$entity && $this->user->isEffectiveAdmin()) { + $entity = $this->getEntityEvenDeleted($id); + } + + if (!$entity) { + return null; + } + + $this->loadAdditionalFields($entity); + + if (!$this->acl->check($entity, AclTable::ACTION_READ)) { + throw new ForbiddenSilent("No 'read' access."); + } + + $this->prepareEntityForOutput($entity); + + return $entity; + } + + protected function getStreamService(): StreamService + { + if (empty($this->streamService)) { + $this->streamService = $this->injectableFactory->create(StreamService::class); + } + + return $this->streamService; + } + + private function createReadLoadProcessor(): ReadLoadProcessor + { + return $this->injectableFactory->createWithBinding(ReadLoadProcessor::class, $this->createBinding()); + } + + private function getListLoadProcessor(): ListLoadProcessor + { + if (!$this->listLoadProcessor) { + $this->listLoadProcessor = + $this->injectableFactory->createWithBinding(ListLoadProcessor::class, $this->createBinding()); + } + + return $this->listLoadProcessor; + } + + /** + * @param TEntity $entity + */ + public function loadAdditionalFields(Entity $entity): void + { + $loadProcessor = $this->createReadLoadProcessor(); + + $loadProcessor->process($entity); + } + + /** + * @param Entity $entity + */ + private function loadListAdditionalFields(Entity $entity, ?SearchParams $searchParams = null): void + { + $params = new FieldLoaderParams(); + + if ($searchParams && $searchParams->getSelect()) { + $params = $params->withSelect($searchParams->getSelect()); + } + + $loadProcessor = $this->getListLoadProcessor(); + + $loadProcessor->process($entity, $params); + } + + /** + * Validate an entity. + * + * @param TEntity $entity An entity. + * @param stdClass $data Raw input data. + * @throws BadRequest + */ + public function processValidation(Entity $entity, stdClass $data): void + { + $params = FieldValidationParams::create(); + + $this->fieldValidationManager->process($entity, $data, $params); + } + + /** + * @param TEntity $entity + * @throws Forbidden + */ + protected function processAssignmentCheck(Entity $entity): void + { + if (!$this->checkAssignment($entity)) { + throw new Forbidden("Assignment failure: assigned user or team not allowed."); + } + } + + /** + * Check whether assignment can be applied for an entity. + * + * @param TEntity $entity + */ + public function checkAssignment(Entity $entity): bool + { + return $this->assignmentCheckerManager->check($this->user, $entity); + } + + private function getLinkCheck(): LinkCheck + { + if (!$this->linkCheck) { + $linkCheck = $this->injectableFactory->createWithBinding( + LinkCheck::class, + BindingContainerBuilder::create() + ->bindInstance(Acl::class, $this->acl) + ->bindInstance(User::class, $this->user) + ->build() + ); + + $this->linkCheck = $linkCheck; + } + + return $this->linkCheck; + } + + /** + * Sanitize input data. + * + * @param stdClass $data Input data. + * @since 8.1.0 + */ + public function sanitizeInput(stdClass $data): void + { + $manager = $this->injectableFactory->create(SanitizeManager::class); + + $manager->process($this->entityType, $data); + + $this->sanitizeInputForeign($data); + } + + private function sanitizeInputForeign(stdClass $data): void + { + $entityDefs = $this->entityManager->getDefs()->getEntity($this->entityType); + + /** @var array $map */ + $map = []; + + foreach ($entityDefs->getFieldList() as $fieldDefs) { + if ($fieldDefs->getType() !== FieldType::FOREIGN) { + continue; + } + + $link = $fieldDefs->getParam(FieldParam::LINK); + $foreignField = $fieldDefs->getParam(FieldParam::FIELD); + + if (!$link || !$foreignField) { + continue; + } + + $foreignEntityType = $entityDefs->tryGetRelation($link)?->tryGetForeignEntityType(); + + if (!$foreignEntityType) { + continue; + } + + $id = $data->{$link . 'Id'} ?? null; + + if (!is_string($id)) { + continue; + } + + if (!array_key_exists($link, $map)) { + $map[$link] = $this->entityManager->getEntityById($foreignEntityType, $id); + } + + $foreignEntity = $map[$link] ?? null; + + if (!$foreignEntity) { + continue; + } + + $field = $fieldDefs->getName(); + + $data->$field = $foreignEntity->get($foreignField); + } + } + + protected function filterInput(stdClass $data): void + { + $forbiddenAttributeList = $this->acl + ->getScopeForbiddenAttributeList($this->entityType, AclTable::ACTION_EDIT); + + foreach ($forbiddenAttributeList as $attribute) { + unset($data->$attribute); + } + + $this->filterInputForeignAttributes($data); + } + + private function filterInputForeignAttributes(stdClass $data): void + { + $entityDefs = $this->entityManager->getDefs()->tryGetEntity($this->entityType); + + if (!$entityDefs) { + return; + } + + foreach ($entityDefs->getAttributeList() as $attributeDefs) { + if ( + $attributeDefs->getType() !== AttributeType::FOREIGN && + !$attributeDefs->getParam(AttributeParam::IS_LINK_MULTIPLE_NAME_MAP) + ) { + continue; + } + + if ( + // link-one + $attributeDefs->getType() === AttributeType::FOREIGN && + $attributeDefs->getParam('attributeRole') === 'id' + ) { + continue; + } + + $attribute = $attributeDefs->getName(); + + unset($data->$attribute); + } + } + + private function filterInputSystemAttributes(stdClass $data): void + { + unset($data->deleted); + unset($data->id); + unset($data->modifiedById); + unset($data->modifiedByName); + unset($data->modifiedAt); + unset($data->createdById); + unset($data->createdByName); + unset($data->createdAt); + unset($data->versionNumber); + } + + public function filterCreateInput(stdClass $data): void + { + $this->filterInputSystemAttributes($data); + $this->filterInput($data); + + $wrappedData = new Data($data); + + foreach ($this->getCreateFilterList() as $filter) { + $filter->filter($wrappedData); + } + } + + public function filterUpdateInput(stdClass $data): void + { + $this->filterInputSystemAttributes($data); + $this->filterInput($data); + $this->filterReadOnlyAfterCreate($data); + + $wrappedData = new Data($data); + + foreach ($this->getUpdateFilterList() as $filter) { + $filter->filter($wrappedData); + } + } + + private function createFilterProvider(): FilterProvider + { + return $this->injectableFactory->createWithBinding(FilterProvider::class, $this->createBinding()); + } + + /** + * @return Filter[] + */ + private function getCreateFilterList(): array + { + if ($this->createFilterList === null) { + $this->createFilterList = $this->createFilterProvider()->getForCreate($this->entityType); + } + + return $this->createFilterList; + } + + /** + * @return Filter[] + */ + private function getUpdateFilterList(): array + { + if ($this->updateFilterList === null) { + $this->updateFilterList = $this->createFilterProvider()->getForUpdate($this->entityType); + } + + return $this->updateFilterList; + } + + private function filterReadOnlyAfterCreate(stdClass $data): void + { + $fieldDefsList = $this->entityManager + ->getDefs() + ->getEntity($this->entityType) + ->getFieldList(); + + foreach ($fieldDefsList as $fieldDefs) { + if (!$fieldDefs->getParam(FieldParam::READ_ONLY_AFTER_CREATE)) { + continue; + } + + $attributeList = $this->fieldUtil->getAttributeList($this->entityType, $fieldDefs->getName()); + + foreach ($attributeList as $attribute) { + unset($data->$attribute); + } + } + } + + /** + * @param TEntity $entity + * @throws Conflict + */ + private function processConcurrencyControl(Entity $entity, int $versionNumber): void + { + // @todo Use a bound interface. + $processor = $this->injectableFactory->create(OptimisticProcessor::class); + + $result = $processor->process($entity, $versionNumber); + + if (!$result) { + return; + } + + $responseData = (object) [ + 'values' => $result->values, + 'versionNumber' => $result->versionNumber, + ]; + + throw ConflictSilent::createWithBody('modified', Json::encode($responseData)); + } + + /** + * @param TEntity $entity + * @throws DuplicateConflict + */ + protected function processDuplicateCheck(Entity $entity): void + { + $duplicates = $this->findDuplicates($entity); + + if (!$duplicates) { + return; + } + + foreach ($duplicates as $e) { + $this->prepareEntityForOutput($e); + } + + throw DuplicateConflict::create($duplicates); + } + + /** + * @param TEntity $entity + * @noinspection PhpUnusedParameterInspection + */ + public function populateDefaults(Entity $entity, stdClass $data): void + { + $this->getDefaultsPopulator()->populate($entity); + } + + /** + * @return DefaultsPopulator + */ + private function getDefaultsPopulator(): DefaultsPopulator + { + if (!$this->defaultsPopulator) { + $this->defaultsPopulator = $this->injectableFactory + ->create(DefaultsPopulatorFactory::class) + ->create($this->entityType); + } + + return $this->defaultsPopulator; + } + + /** + * Create a record. + * + * Is not supposed to be directly used in customizations. + * + * @return CreateResult + * @throws BadRequest + * @throws Forbidden If no create access. + * @throws Conflict + * @throws DuplicateConflict + */ + public function create(stdClass $data, CreateParams $params = new CreateParams()): CreateResult + { + if (!$this->acl->check($this->entityType, AclTable::ACTION_CREATE)) { + throw new ForbiddenSilent("No create access."); + } + + $entity = $this->getRepository()->getNew(); + + $this->filterCreateInput($data); + $this->sanitizeInput($data); + + $entity->setMultiple($data); + $this->populateDefaults($entity, $data); + + $this->getRecordHookManager()->processEarlyBeforeCreate($entity, $params); + + $this->processValidation($entity, $data); + $this->checkEntityCreateAccess($entity); + $this->processAssignmentCheck($entity); + $this->getLinkCheck()->processFields($entity); + + if (!$params->skipDuplicateCheck()) { + $this->processDuplicateCheck($entity); + } + + $this->processApiBeforeCreateApiScript($entity, $params); + $this->getRecordHookManager()->processBeforeCreate($entity, $params); + $this->beforeCreateEntity($entity, $data); + + $this->entityManager->saveEntity($entity, [ + SaveOption::API => true, + SaveOption::KEEP_NEW => true, + SaveOption::DUPLICATE_SOURCE_ID => $params->getDuplicateSourceId(), + ]); + + $this->getRecordHookManager()->processAfterCreate($entity, $params); + + $entity->setAsNotNew(); + $entity->updateFetchedValues(); + + $this->afterCreateEntity($entity, $data); + $this->afterCreateProcessDuplicating($entity, $params); + + $this->loadAdditionalFields($entity); + + $this->prepareEntityForOutput($entity); + $this->processActionHistoryRecord(Action::CREATE, $entity); + + return new CreateResult($entity); + } + + /** + * Update a record. + * + * Is not supposed to be directly used in customizations. + * + * @return UpdateResult + * @throws NotFound If record not found. + * @throws Forbidden If no access. + * @throws Conflict + * @throws BadRequest + * @throws DuplicateConflict + */ + public function update(string $id, stdClass $data, UpdateParams $params = new UpdateParams()): UpdateResult + { + if (!$this->acl->check($this->entityType, AclTable::ACTION_EDIT)) { + throw new ForbiddenSilent("No edit access."); + } + + if (!$id) { + throw new BadRequest("ID is empty."); + } + + $this->filterUpdateInput($data); + $this->sanitizeInput($data); + + $entity = $this->getEntityBeforeUpdate ? + $this->getEntity($id) : + $this->getRepository()->getById($id); + + if (!$entity) { + throw new NotFound("Record $id not found."); + } + + if (!$this->getEntityBeforeUpdate) { + $this->loadAdditionalFields($entity); + } + + $this->filterInputReadOnlySaved($entity, $data); + + if (!$this->acl->check($entity, AclTable::ACTION_EDIT)) { + throw new ForbiddenSilent("No edit access."); + } + + $entity->setMultiple($data); + + if ($params->getVersionNumber() !== null) { + $this->processConcurrencyControl($entity, $params->getVersionNumber()); + } + + $this->getRecordHookManager()->processEarlyBeforeUpdate($entity, $params); + + $this->processValidation($entity, $data); + $this->processAssignmentCheck($entity); + $this->getLinkCheck()->processFields($entity); + + $checkForDuplicates = + $this->metadata->get(['recordDefs', $this->entityType, 'updateDuplicateCheck']) ?? false; + + if ($checkForDuplicates && !$params->skipDuplicateCheck()) { + $this->processDuplicateCheck($entity); + } + + $this->processApiBeforeUpdateApiScript($entity, $params); + $this->getRecordHookManager()->processBeforeUpdate($entity, $params); + $this->beforeUpdateEntity($entity, $data); + + $context = new SaveContext(); + + $this->entityManager->saveEntity($entity, [ + SaveOption::API => true, + SaveOption::KEEP_DIRTY => true, + SaveContext::NAME => $context, + ]); + + $this->getRecordHookManager()->processAfterUpdate($entity, $params); + $entity->updateFetchedValues(); + $this->afterUpdateEntity($entity, $data); + + if ($this->metadata->get(['recordDefs', $this->entityType, 'loadAdditionalFieldsAfterUpdate'])) { + $this->loadAdditionalFields($entity); + } + + $this->prepareEntityForOutput($entity); + $this->processActionHistoryRecord(Action::UPDATE, $entity); + + return new UpdateResult( + entity: $entity, + linkUpdated: $context->isLinkUpdated(), + ); + } + + /** + * Delete a record. + * + * Is not supposed to be directly used in customizations. + * + * @throws Forbidden + * @throws BadRequest + * @throws NotFound + * @throws Conflict + */ + public function delete(string $id, DeleteParams $params = new DeleteParams()): DeleteResult + { + if (!$this->acl->check($this->entityType, AclTable::ACTION_DELETE)) { + throw new ForbiddenSilent("No delete access."); + } + + if (!$id) { + throw new BadRequest("ID is empty."); + } + + $entity = $this->getRepository()->getById($id); + + if (!$entity) { + throw new NotFound("Record $id not found."); + } + + if (!$this->acl->check($entity, AclTable::ACTION_DELETE)) { + throw new ForbiddenSilent("No delete access."); + } + + $this->getRecordHookManager()->processBeforeDelete($entity, $params); + $this->beforeDeleteEntity($entity); + + $this->getRepository()->remove($entity, [RemoveOption::API => true]); + + $this->afterDeleteEntity($entity); + $this->getRecordHookManager()->processAfterDelete($entity, $params); + $this->processActionHistoryRecord(Action::DELETE, $entity); + + return new DeleteResult(); + } + + /** + * Find records. + * + * @return RecordCollection + * @throws Forbidden + * @throws BadRequest + */ + public function find(SearchParams $searchParams, ?FindParams $params = null): RecordCollection + { + if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) { + throw new ForbiddenSilent("No read access."); + } + + if (!$params) { + $params = FindParams::create(); + } + + $disableCount = $params->noTotal() || + $this->metadata->get(['entityDefs', $this->entityType, 'collection', 'countDisabled']); + + $maxSize = $searchParams->getMaxSize(); + + if ($disableCount && $maxSize) { + $searchParams = $searchParams->withMaxSize($maxSize + 1); + } + + $preparedSearchParams = $this->prepareSearchParams($searchParams); + + try { + $query = $this->selectBuilderFactory->create() + ->from($this->entityType) + ->withStrictAccessControl() + ->withSearchParams($preparedSearchParams) + ->withAdditionalApplierClassNameList( + $this->createSelectApplierClassNameListProvider()->get($this->entityType) + ) + ->build(); + } catch (Forbidden $e) { + throw new BadRequest($e->getMessage(), 400, $e); + } + + $collection = $this->getRepository() + ->clone($query) + ->find(); + + foreach ($collection as $entity) { + $this->loadListAdditionalFields($entity, $preparedSearchParams); + $this->prepareEntityForOutput($entity); + } + + if ($disableCount) { + return RecordCollection::createNoCount($collection, $maxSize); + } + + $total = $this->getRepository() + ->clone($query) + ->count(); + + return RecordCollection::create($collection, $total); + } + + private function createSelectApplierClassNameListProvider(): ApplierClassNameListProvider + { + return $this->injectableFactory->create(ApplierClassNameListProvider::class); + } + + /** + * @return ?TEntity + */ + private function getEntityEvenDeleted(string $id): ?Entity + { + $query = $this->entityManager + ->getQueryBuilder() + ->select() + ->from($this->entityType) + ->where([ + Attribute::ID => $id, + ]) + ->withDeleted() + ->build(); + + return $this->getRepository() + ->clone($query) + ->findOne(); + } + + /** + * Restore a deleted record. + * + * @throws NotFound If not found. + * @throws Forbidden If no access. + * @throws Conflict If a conflict occurred. + */ + public function restoreDeleted(string $id): void + { + if (!$this->user->isEffectiveAdmin()) { + throw new Forbidden("Only admin can restore."); + } + + $entity = $this->getEntityEvenDeleted($id); + + if (!$entity) { + throw new NotFound(); + } + + if (!$entity->hasAttribute(Attribute::DELETED)) { + throw new Forbidden("Record type is not restorable."); + } + + 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); + } + + public function getMaxSelectTextAttributeLength(): ?int + { + if ($this->maxSelectTextAttributeLengthDisabled) { + return null; + } + + if ($this->maxSelectTextAttributeLength) { + return $this->maxSelectTextAttributeLength; + } + + return $this->config->get('maxSelectTextAttributeLengthForList') ?? + self::MAX_SELECT_TEXT_ATTRIBUTE_LENGTH; + } + + /** + * Find linked records. + * + * @param non-empty-string $link + * @return RecordCollection + * @throws NotFound If a record not found. + * @throws Forbidden If no access. + * @throws BadRequest + */ + public function findLinked(string $id, string $link, SearchParams $searchParams): RecordCollection + { + if ($link === '') { + throw new InvalidArgumentException(); + } + + if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) { + throw new ForbiddenSilent("No access."); + } + + $entity = $this->getRepository()->getById($id); + + if (!$entity) { + throw new NotFound(); + } + + if (!$this->acl->check($entity, AclTable::ACTION_READ)) { + throw new ForbiddenSilent("No read access."); + } + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($this->entityType); + + if (!$entityDefs->hasRelation($link)) { + throw new NotFound("Link does not exist."); + } + + $this->processForbiddenLinkReadCheck($link); + + $foreignEntityType = $entityDefs + ->getRelation($link) + ->getForeignEntityType(); + + $skipAcl = $this->metadata + ->get("recordDefs.$this->entityType.relationships.$link.selectAccessControlDisabled") ?? false; + + if (!$skipAcl && !$this->acl->check($foreignEntityType, AclTable::ACTION_READ)) { + throw new Forbidden(); + } + + $recordService = $this->recordServiceContainer->get($foreignEntityType); + + $disableCount = $this->metadata + ->get("recordDefs.$this->entityType.relationships.$link.countDisabled") ?? false; + + $maxSize = $searchParams->getMaxSize(); + + if ($disableCount && $maxSize) { + $searchParams = $searchParams->withMaxSize($maxSize + 1); + } + + $preparedSearchParams = $this->prepareLinkSearchParams( + $recordService->prepareSearchParams($searchParams), + $link + ); + + $selectBuilder = $this->selectBuilderFactory->create(); + + $selectBuilder + ->from($foreignEntityType) + ->withSearchParams($preparedSearchParams) + ->withAdditionalApplierClassNameList( + $this->createSelectApplierClassNameListProvider()->get($foreignEntityType) + ); + + if (!$skipAcl) { + $selectBuilder->withStrictAccessControl(); + } else { + $selectBuilder->withComplexExpressionsForbidden(); + $selectBuilder->withWherePermissionCheck(); + } + + try { + $query = $selectBuilder->build(); + } catch (Forbidden $e) { + throw new BadRequest($e->getMessage(), 400, $e); + } + + $collection = $this->entityManager + ->getRDBRepository($this->entityType) + ->getRelation($entity, $link) + ->clone($query) + ->find(); + + foreach ($collection as $itemEntity) { + $this->loadListAdditionalFields($itemEntity, $preparedSearchParams); + + $recordService->prepareEntityForOutput($itemEntity); + } + + if ($disableCount) { + return RecordCollection::createNoCount($collection, $maxSize); + } + + $total = $this->entityManager + ->getRDBRepository($this->entityType) + ->getRelation($entity, $link) + ->clone($query) + ->count(); + + return RecordCollection::create($collection, $total); + } + + /** + * Link records. + * + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function link(string $id, string $link, string $foreignId): LinkResult + { + if (!$this->acl->check($this->entityType)) { + throw new Forbidden(); + } + + if (empty($id) || empty($link) || empty($foreignId)) { + throw new BadRequest(); + } + + $this->processForbiddenLinkEditCheck($link); + + $entity = $this->getRepository()->getById($id); + + if (!$entity) { + throw new NotFound(); + } + + if (!$entity instanceof CoreEntity) { + throw new LogicException("Only core entities are supported."); + } + + $this->getLinkCheck()->processLink($entity, $link); + + $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY); + + if (!$foreignEntityType) { + throw new NotFound("Entity $this->entityType does not have link $link."); + } + + $foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $foreignId); + + if (!$foreignEntity) { + throw new NotFound(); + } + + $this->getLinkCheck()->processLinkForeign($entity, $link, $foreignEntity); + + $this->getRecordHookManager()->processBeforeLink($entity, $link, $foreignEntity); + + $this->getRepository() + ->getRelation($entity, $link) + ->relate($foreignEntity, null, [SaveOption::API => true]); + + $this->getRecordHookManager()->processAfterLink($entity, $link, $foreignEntity); + + return new LinkResult(); + } + + /** + * Unlink records. + * + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function unlink(string $id, string $link, string $foreignId): UnlinkResult + { + if (!$this->acl->check($this->entityType)) { + throw new Forbidden(); + } + + if (empty($id) || empty($link) || empty($foreignId)) { + throw new BadRequest(); + } + + $this->processForbiddenLinkEditCheck($link); + + $entity = $this->getRepository()->getById($id); + + if (!$entity) { + throw new NotFound(); + } + + if (!$entity instanceof CoreEntity) { + throw new LogicException("Only core entities are supported."); + } + + $this->getLinkCheck()->processUnlink($entity, $link); + + $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY); + + if (!$foreignEntityType) { + throw new NotFound("Entity $this->entityType does not have link $link."); + } + + $foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $foreignId); + + if (!$foreignEntity) { + throw new NotFound(); + } + + $this->getLinkCheck()->processUnlinkForeign($entity, $link, $foreignEntity); + + $this->getRecordHookManager()->processBeforeUnlink($entity, $link, $foreignEntity); + + $this->getRepository() + ->getRelation($entity, $link) + ->unrelate($foreignEntity, [SaveOption::API => true]); + + $this->getRecordHookManager()->processAfterUnlink($entity, $link, $foreignEntity); + + return new UnlinkResult(); + } + + + /** + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + */ + public function massLink(string $id, string $link, SearchParams $searchParams): MassLinkResult + { + if (!$this->acl->check($this->entityType, AclTable::ACTION_EDIT)) { + throw new Forbidden(); + } + + if (!$this->metadata->get("recordDefs.$this->entityType.relationships.$link.massLink")) { + throw new Forbidden("Mass link is not allowed."); + } + + $this->processForbiddenLinkEditCheck($link); + + $entity = $this->getRepository()->getById($id); + + if (!$entity) { + throw new NotFound(); + } + + $this->getLinkCheck()->processLink($entity, $link); + + // Not used link-check deliberately. Only edit access. + if (!$this->acl->check($entity, AclTable::ACTION_EDIT)) { + throw new Forbidden(); + } + + if (!$entity instanceof CoreEntity) { + throw new LogicException("Only core entities are supported."); + } + + $foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY); + + if (!$foreignEntityType) { + throw new LogicException("Link '$link' has no 'entity'."); + } + + $accessActionRequired = $this->metadata + ->get("recordDefs.$this->entityType.relationships.$link.linkRequiredForeignAccess") ?? + AclTable::ACTION_EDIT; + + if (!$this->acl->check($foreignEntityType, $accessActionRequired)) { + throw new Forbidden(); + } + + $query = $this->selectBuilderFactory->create() + ->from($foreignEntityType) + ->withStrictAccessControl() + ->withSearchParams($searchParams->withSelect(null)) + ->build(); + + if ($this->acl->getLevel($foreignEntityType, $accessActionRequired) === AclTable::LEVEL_ALL) { + $this->getRepository() + ->getRelation($entity, $link) + ->massRelate($query, [SaveOption::API => true]); + + return new MassLinkResult( + affectedCount: null, + ); + } + + // @todo Apply access control filter if $accessActionRequired === 'read'. For better performance. + + $countRelated = 0; + + $foreignCollection = $this->entityManager + ->getRDBRepository($foreignEntityType) + ->clone($query) + ->sth() + ->find(); + + foreach ($foreignCollection as $foreignEntity) { + if (!$this->acl->check($foreignEntity, $accessActionRequired)) { + continue; + } + + $this->getRepository() + ->getRelation($entity, $link) + ->relate($foreignEntity, null, [SaveOption::API => true]); + + $countRelated++; + } + + return new MassLinkResult( + affectedCount: $countRelated, + ); + } + + /** + * @throws Forbidden + */ + protected function processForbiddenLinkReadCheck(string $link): void + { + $forbiddenLinkList = $this->acl + ->getScopeForbiddenLinkList($this->entityType); + + if (in_array($link, $forbiddenLinkList)) { + throw new Forbidden(); + } + } + + /** + * @throws Forbidden + */ + protected function processForbiddenLinkEditCheck(string $link): void + { + $type = $this->entityManager + ->getDefs() + ->getEntity($this->entityType) + ->tryGetRelation($link) + ?->getType(); + + if ( + $type && + !in_array($type, [ + Entity::MANY_MANY, + Entity::HAS_MANY, + Entity::HAS_CHILDREN, + ]) + ) { + throw new Forbidden("Only manyMany, hasMany & hasChildren relations are allowed."); + } + + $forbiddenLinkList = $this->acl->getScopeForbiddenLinkList($this->entityType, AclTable::ACTION_EDIT); + + if (in_array($link, $forbiddenLinkList)) { + throw new Forbidden(); + } + } + + /** + * Follow a record. + * + * @param string $id A record ID. + * @param string|null $userId A user ID. If not specified then a current user will be used. + * + * @throws NotFoundSilent + * @throws Forbidden + */ + public function follow(string $id, ?string $userId = null): void + { + if (!$this->acl->check($this->entityType, AclTable::ACTION_STREAM)) { + throw new Forbidden(); + } + + $entity = $this->getRepository()->getById($id); + + if (!$entity) { + throw new NotFoundSilent(); + } + + if (!$this->acl->check($entity, AclTable::ACTION_STREAM)) { + throw new Forbidden(); + } + + if (empty($userId)) { + $userId = $this->user->getId(); + } + + $this->getStreamService()->followEntity($entity, $userId); + } + + /** + * Unfollow a record. + * + * @param string $id A record ID. + * @param string|null $userId A user ID. If not specified then a current user will be used. + * + * @throws NotFoundSilent + */ + public function unfollow(string $id, ?string $userId = null): void + { + $entity = $this->getRepository()->getById($id); + + if (!$entity) { + throw new NotFoundSilent(); + } + + if (empty($userId)) { + $userId = $this->user->getId(); + } + + $this->getStreamService()->unfollowEntity($entity, $userId); + } + + private function getDuplicateFinder(): DuplicateFinder + { + if (!$this->duplicateFinder) { + $this->duplicateFinder = $this->injectableFactory->create(DuplicateFinder::class); + } + + return $this->duplicateFinder; + } + + /** + * Check whether an entity has a duplicate. + * + * @param TEntity $entity + */ + public function checkIsDuplicate(Entity $entity): bool + { + $finder = $this->getDuplicateFinder(); + + // For backward compatibility. + if (method_exists($this, 'getDuplicateWhereClause')) { + $whereClause = $this->getDuplicateWhereClause($entity, (object) []); + + if (!$whereClause) { + return false; + } + + return $finder->checkByWhere($entity, WhereClause::fromRaw($whereClause)); + } + + return $finder->check($entity); + } + + /** + * Find duplicates for an entity. + * + * @return ?Collection + */ + public function findDuplicates(Entity $entity): ?Collection + { + $finder = $this->getDuplicateFinder(); + + // For backward compatibility. + if (method_exists($this, 'getDuplicateWhereClause')) { + $whereClause = $this->getDuplicateWhereClause($entity, (object) []); + + if (!$whereClause) { + return null; + } + + /** @var ?Collection */ + return $finder->findByWhere($entity, WhereClause::fromRaw($whereClause)); + } + + /** @var ?Collection */ + return $finder->find($entity); + } + + /** + * Prepare an entity for output. Clears not allowed attributes. + * + * Do not extend. Prefer metadata recordDefs > outputFilterClassNameList. + * + * @param TEntity $entity + */ + public function prepareEntityForOutput(Entity $entity): void + { + $forbiddenAttributeList = $this->acl->getScopeForbiddenAttributeList($entity->getEntityType()); + + foreach ($forbiddenAttributeList as $attribute) { + $entity->clear($attribute); + } + + foreach ($this->getOutputFilterList() as $filter) { + $filter->filter($entity); + } + } + + /** + * @return Output\Filter[] + */ + private function getOutputFilterList(): array + { + if ($this->outputFilterList === null) { + $this->outputFilterList = + $this->injectableFactory->create(Output\FilterProvider::class)->get($this->entityType); + } + + return $this->outputFilterList; + } + + private function createEntityDuplicator(): EntityDuplicator + { + return $this->injectableFactory->createWithBinding(EntityDuplicator::class, $this->createBinding()); + } + + /** + * @throws BadRequest + * @throws Forbidden + * @throws ForbiddenSilent + * @throws NotFound + */ + public function getDuplicateAttributes(string $id): stdClass + { + if (!$id) { + throw new BadRequest("No ID."); + } + + if (!$this->acl->check($this->entityType, AclTable::ACTION_CREATE)) { + throw new Forbidden("No 'create' access."); + } + + if (!$this->acl->check($this->entityType, AclTable::ACTION_READ)) { + throw new Forbidden("No 'read' access."); + } + + $entity = $this->getEntity($id); + + if (!$entity) { + throw new NotFound("Record not found."); + } + + $attributes = $this->createEntityDuplicator()->duplicate($entity); + + if ($this->acl->getPermissionLevel(Acl\Permission::ASSIGNMENT) === AclTable::LEVEL_NO) { + unset($attributes->assignedUserId); + unset($attributes->assignedUserName); + unset($attributes->assignedUsersIds); + } + + return $attributes; + } + + /** + * @param TEntity $entity + */ + private function afterCreateProcessDuplicating(Entity $entity, CreateParams $params): void + { + $duplicatingEntityId = $params->getDuplicateSourceId(); + + if (!$duplicatingEntityId) { + return; + } + + /** @var ?TEntity $duplicatingEntity */ + $duplicatingEntity = $this->entityManager->getEntityById($entity->getEntityType(), $duplicatingEntityId); + + if (!$duplicatingEntity) { + return; + } + + if (!$this->acl->check($duplicatingEntity, AclTable::ACTION_READ)) { + return; + } + + $this->duplicateLinks($entity, $duplicatingEntity); + } + + /** + * @param TEntity $entity + * @param TEntity $duplicatingEntity + */ + private function duplicateLinks(Entity $entity, Entity $duplicatingEntity): void + { + $linkList = $this->metadata->get("recordDefs.$this->entityType.duplicateLinkList") ?? []; + + foreach ($linkList as $link) { + $linkedList = $this->getRepository() + ->getRelation($duplicatingEntity, $link) + ->find(); + + foreach ($linkedList as $linked) { + $this->getRepository() + ->getRelation($entity, $link) + ->relate($linked); + } + } + } + + public function prepareSearchParams(SearchParams $searchParams): SearchParams + { + $searchParams = $this->prepareSearchParamsSelect($searchParams); + + if ($searchParams->getSelect() === null) { + $searchParams = $searchParams->withSelect(['*']); + } + + return $searchParams + ->withMaxTextAttributeLength( + $this->getMaxSelectTextAttributeLength() + ); + } + + protected function prepareSearchParamsSelect(SearchParams $searchParams): SearchParams + { + if ($this->metadata->get("recordDefs.$this->entityType.forceSelectAllAttributes")) { + return $searchParams->withSelect(null); + } + + if ($searchParams->getSelect() === null) { + return $searchParams; + } + + /** @var string[] $mandatoryAttributeList */ + $mandatoryAttributeList = $this->metadata->get("recordDefs.$this->entityType.mandatoryAttributeList") ?? []; + + if ($mandatoryAttributeList === []) { + return $searchParams; + } + + $select = array_unique( + array_merge( + $searchParams->getSelect(), + $mandatoryAttributeList + ) + ); + + return $searchParams->withSelect($select); + } + + /** + * Do not extend. + * + * @internal + */ + protected function prepareLinkSearchParams(SearchParams $searchParams, string $link): SearchParams + { + if ($searchParams->getSelect() === null) { + return $searchParams; + } + + $list = $this->metadata->get("recordDefs.$this->entityType.relationships.$link.mandatoryAttributeList") ?? []; + + if ($list === []) { + return $searchParams; + } + + $select = array_unique(array_merge($searchParams->getSelect(), $list)); + + return $searchParams->withSelect($select); + } + + /** + * @param TEntity $entity + * @throws Conflict + * @throws BadRequest + * @throws Forbidden + */ + private function processApiBeforeCreateApiScript(Entity $entity, CreateParams $params): void + { + $processor = $this->injectableFactory->create(FormulaProcessor::class); + + $processor->processBeforeCreate($entity, $params); + } + + /** + * @param TEntity $entity + * @throws Conflict + * @throws BadRequest + * @throws Forbidden + */ + private function processApiBeforeUpdateApiScript(Entity $entity, UpdateParams $params): void + { + $processor = $this->injectableFactory->create(FormulaProcessor::class); + + $processor->processBeforeUpdate($entity, $params); + } + + /** + * Use a recordDefs hook instead. + * + * @param TEntity $entity + * @internal + */ + protected function beforeCreateEntity(Entity $entity, stdClass $data): void + {} + + /** + * Use a recordDefs hook instead. + * + * @param TEntity $entity + * @internal + */ + protected function afterCreateEntity(Entity $entity, stdClass $data): void + {} + + /** + * Use a recordDefs hook instead. + * + * @param TEntity $entity + * @internal + */ + protected function beforeUpdateEntity(Entity $entity, stdClass $data): void + {} + + /** + * Use a recordDefs hook instead. + * + * @param TEntity $entity + * @internal + */ + protected function afterUpdateEntity(Entity $entity, stdClass $data): void + {} + + /** + * Use a recordDefs hook instead. + * + * @param TEntity $entity + * @internal + */ + protected function beforeDeleteEntity(Entity $entity): void + {} + + /** + * Use a recordDefs hook instead. + * + * @param TEntity $entity + * @internal + */ + protected function afterDeleteEntity(Entity $entity): void + {} + + private function createBinding(): BindingContainer + { + return BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->bindInstance(Acl::class, $this->acl) + ->build(); + } + + private function getRecordHookManager(): HookManager + { + if (!$this->recordHookManager) { + $this->recordHookManager = + $this->injectableFactory->createWithBinding(HookManager::class, $this->createBinding()); + } + + return $this->recordHookManager; + } + + /** + * @throws Forbidden + */ + private function checkEntityCreateAccess(Entity $entity): void + { + if (!$this->acl->check($entity, AclTable::ACTION_CREATE)) { + throw new ForbiddenSilent("No create access."); + } + } + + /** + * Filter input by the read-only-pre-save dynamic logic. + * + * @since 9.1.0 + * @internal + */ + public function filterInputReadOnlySaved(Entity $entity, stdClass $data): void + { + $processor = $this->injectableFactory->create(InputFilterProcessor::class); + + $processor->process($entity, $data); + } +} diff --git a/application/Espo/Core/Record/ServiceContainer.php b/application/Espo/Core/Record/ServiceContainer.php new file mode 100644 index 0000000..07d930d --- /dev/null +++ b/application/Espo/Core/Record/ServiceContainer.php @@ -0,0 +1,84 @@ +. + * + * 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; + +use Espo\ORM\Entity; +use Espo\ORM\Repository\Util as RepositoryUtil; + +/** + * Container for record services. Lazy loading is used. + * Usually there's no need to have multiple record service instances of the same entity type. + * Use this container instead of serviceFactory to get record services. + * + * Important. Returns record services for the current user. + * Use the service-factory to create services for a specific user. + */ +class ServiceContainer +{ + /** @var array> */ + private $data = []; + + public function __construct(private ServiceFactory $serviceFactory) + {} + + /** + * Get a record service by an entity class name. + * + * @template T of Entity + * @param class-string $className An entity class name. + * @return Service + */ + public function getByClass(string $className): Service + { + $entityType = RepositoryUtil::getEntityTypeByClass($className); + + /** @var Service */ + return $this->get($entityType); + } + + /** + * Get a record service by an entity type. + * + * @return Service + */ + public function get(string $entityType): Service + { + if (!array_key_exists($entityType, $this->data)) { + $this->load($entityType); + } + + return $this->data[$entityType]; + } + + private function load(string $entityType): void + { + $this->data[$entityType] = $this->serviceFactory->create($entityType); + } +} diff --git a/application/Espo/Core/Record/ServiceFactory.php b/application/Espo/Core/Record/ServiceFactory.php new file mode 100644 index 0000000..84e13cf --- /dev/null +++ b/application/Espo/Core/Record/ServiceFactory.php @@ -0,0 +1,168 @@ +. + * + * 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; + +use Espo\Core\ServiceFactory as Factory; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\Core\Acl; +use Espo\Core\AclManager; +use Espo\ORM\Entity; +use Espo\ORM\Repository\Util as RepositoryUtil; + +use RuntimeException; + +/** + * Create a service for a specific user. + */ +class ServiceFactory +{ + private const RECORD_SERVICE_NAME = 'Record'; + private const RECORD_TREE_SERVICE_NAME = 'RecordTree'; + + /** @var array */ + private $defaultTypeMap = [ + 'CategoryTree' => self::RECORD_TREE_SERVICE_NAME, + ]; + + public function __construct( + private Factory $serviceFactory, + private Metadata $metadata, + private User $user, + private Acl $acl, + private AclManager $aclManager + ) {} + + /** + * Create a record service by an entity class name. + * + * @template T of Entity + * @param class-string $className An entity class name. + * @return Service + */ + public function createByClass(string $className): Service + { + $entityType = RepositoryUtil::getEntityTypeByClass($className); + + /** @var Service */ + return $this->create($entityType); + } + + /** + * Create a record service for a user by an entity class name. + * + * @template T of Entity + * @param class-string $className An entity class name. + * @return Service + */ + public function createByClassForUser(string $className, User $user): Service + { + $entityType = RepositoryUtil::getEntityTypeByClass($className); + + /** @var Service */ + return $this->createForUser($entityType, $user); + } + + /** + * Create a record service by an entity type. + * + * @return Service + */ + public function create(string $entityType): Service + { + $obj = $this->createInternal($entityType); + + $obj->setUser($this->user); + $obj->setAcl($this->acl); + + return $obj; + } + + /** + * Create a record service for a user. + * + * @return Service + */ + public function createForUser(string $entityType, User $user): Service + { + $obj = $this->createInternal($entityType); + + $acl = $this->aclManager->createUserAcl($user); + + $obj->setUser($user); + $obj->setAcl($acl); + + return $obj; + } + + /** + * @return Service + */ + private function createInternal(string $entityType): Service + { + if (!$this->metadata->get(['scopes', $entityType, 'entity'])) { + throw new RuntimeException("Can't create record service '{$entityType}', there's no such entity type."); + } + + if (!$this->serviceFactory->checkExists($entityType)) { + return $this->createDefault($entityType); + } + + $service = $this->serviceFactory->createWith($entityType, ['entityType' => $entityType]); + + if (!$service instanceof Service) { + return $this->createDefault($entityType); + } + + return $service; + } + + /** + * @return Service + */ + private function createDefault(string $entityType): Service + { + $default = self::RECORD_SERVICE_NAME; + + $type = $this->metadata->get(['scopes', $entityType, 'type']); + + if ($type) { + $default = $this->defaultTypeMap[$type] ?? $default; + } + + $obj = $this->serviceFactory->createWith($default, ['entityType' => $entityType]); + + if (!$obj instanceof Service) { + throw new RuntimeException("Service class {$default} is not instance of Record."); + } + + return $obj; + } +} diff --git a/application/Espo/Core/Record/UnlinkResult.php b/application/Espo/Core/Record/UnlinkResult.php new file mode 100644 index 0000000..f8d24da --- /dev/null +++ b/application/Espo/Core/Record/UnlinkResult.php @@ -0,0 +1,36 @@ +. + * + * 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; + +/** + * @since 10.0.0 + */ +class UnlinkResult +{} diff --git a/application/Espo/Core/Record/UpdateParams.php b/application/Espo/Core/Record/UpdateParams.php new file mode 100644 index 0000000..f481fb4 --- /dev/null +++ b/application/Espo/Core/Record/UpdateParams.php @@ -0,0 +1,72 @@ +. + * + * 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; + +/** + * Immutable. + */ +class UpdateParams +{ + private bool $skipDuplicateCheck = false; + private ?int $versionNumber = null; + + public function __construct() {} + + public function withSkipDuplicateCheck(bool $skipDuplicateCheck = true): self + { + $obj = clone $this; + $obj->skipDuplicateCheck = $skipDuplicateCheck; + + return $obj; + } + + public function withVersionNumber(?int $versionNumber): self + { + $obj = clone $this; + $obj->versionNumber = $versionNumber; + + return $obj; + } + + public function skipDuplicateCheck(): bool + { + return $this->skipDuplicateCheck; + } + + public function getVersionNumber(): ?int + { + return $this->versionNumber; + } + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Record/UpdateParamsFetcher.php b/application/Espo/Core/Record/UpdateParamsFetcher.php new file mode 100644 index 0000000..7b45dce --- /dev/null +++ b/application/Espo/Core/Record/UpdateParamsFetcher.php @@ -0,0 +1,57 @@ +. + * + * 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; + +use Espo\Core\Api\Request; + +class UpdateParamsFetcher +{ + public function __construct() {} + + public function fetch(Request $request): UpdateParams + { + $data = $request->getParsedBody(); + + $skipDuplicateCheck = $request->hasHeader('X-Skip-Duplicate-Check') ? + strtolower($request->getHeader('X-Skip-Duplicate-Check') ?? '') === 'true' : + $data->_skipDuplicateCheck ?? // legacy + false; + + $versionNumber = $request->getHeader('X-Version-Number'); + + if ($versionNumber !== null) { + $versionNumber = intval($versionNumber); + } + + return UpdateParams::create() + ->withSkipDuplicateCheck($skipDuplicateCheck) + ->withVersionNumber($versionNumber); + } +} diff --git a/application/Espo/Core/Record/UpdateResult.php b/application/Espo/Core/Record/UpdateResult.php new file mode 100644 index 0000000..2182b53 --- /dev/null +++ b/application/Espo/Core/Record/UpdateResult.php @@ -0,0 +1,75 @@ +. + * + * 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; + +use Espo\ORM\Entity; +use stdClass; + +/** + * @template TEntity of Entity = Entity + * @since 10.0.0 + */ +class UpdateResult +{ + /** + * @param TEntity $entity An entity. + * @param bool $linkUpdated Whether the update caused link updates. + */ + public function __construct( + private Entity $entity, + private bool $linkUpdated = false, + ) {} + + /** + * @return TEntity + */ + public function getEntity(): Entity + { + return $this->entity; + } + + public function getValueMap(): stdClass + { + return $this->entity->getValueMap(); + } + + /** + * @deprecated Since v10.0.0. For bc. Use entity->get(...). + */ + public function get(string $name): mixed + { + return $this->entity->get($name); + } + + public function isLinkUpdated(): bool + { + return $this->linkUpdated; + } +} diff --git a/application/Espo/Core/Repositories/CategoryTree.php b/application/Espo/Core/Repositories/CategoryTree.php new file mode 100644 index 0000000..6fe03f9 --- /dev/null +++ b/application/Espo/Core/Repositories/CategoryTree.php @@ -0,0 +1,272 @@ +. + * + * 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\Repositories; + +use Espo\ORM\Entity; +use Espo\ORM\Mapper\BaseMapper; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Order; + +/** + * @template TEntity of Entity + * @extends Database + */ +class CategoryTree extends Database +{ + 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 = []) + { + if ($entity->get(self::ATTR_ORDER) === null && $entity->hasAttribute(self::ATTR_ORDER)) { + $this->setOrderToEnd($entity); + } + + parent::beforeSave($entity, $options); + } + + /** + * @param array $options + * @return void + */ + protected function afterSave(Entity $entity, array $options = []) + { + parent::afterSave($entity, $options); + + $parentId = $entity->get(self::ATTR_PARENT_ID); + + $em = $this->entityManager; + + $pathEntityType = $this->getPathEntityType(); + + if ($entity->isNew()) { + if ($parentId) { + $subSelect1 = $em->getQueryBuilder() + ->select() + ->from($pathEntityType) + ->select(['ascendorId', "'" . $entity->getId() . "'"]) + ->where([ + 'descendorId' => $parentId, + ]) + ->build(); + + $subSelect2 = $em->getQueryBuilder() + ->select() + ->select(["'" . $entity->getId() . "'", "'" . $entity->getId() . "'"]) + ->build(); + + $select = $em->getQueryBuilder() + ->union() + ->all() + ->query($subSelect1) + ->query($subSelect2) + ->build(); + + $insert = $em->getQueryBuilder() + ->insert() + ->into($pathEntityType) + ->columns(['ascendorId', 'descendorId']) + ->valuesQuery($select) + ->build(); + + $em->getQueryExecutor()->execute($insert); + + return; + } + + $insert = $em->getQueryBuilder() + ->insert() + ->into($pathEntityType) + ->columns(['ascendorId', 'descendorId']) + ->values([ + 'ascendorId' => $entity->getId(), + 'descendorId' => $entity->getId(), + ]) + ->build(); + + $em->getQueryExecutor()->execute($insert); + + return; + } + + if (!$entity->isAttributeChanged(self::ATTR_PARENT_ID)) { + return; + } + + $delete = $em->getQueryBuilder() + ->delete() + ->from($pathEntityType, 'a') + ->join( + $pathEntityType, + 'd', + [ + 'd.descendorId:' => 'a.descendorId', + ] + ) + ->leftJoin( + $pathEntityType, + 'x', + [ + 'x.ascendorId:' => 'd.descendorId', + 'x.descendorId:' => 'a.ascendorId', + ] + ) + ->where([ + 'd.descendorId' => $entity->getId(), + 'x.ascendorId' => null, + ]) + ->build(); + + $em->getQueryExecutor()->execute($delete); + + if (!empty($parentId)) { + $select = $em->getQueryBuilder() + ->select() + ->from($pathEntityType) + ->select(['ascendorId', 's.descendorId']) + ->join($pathEntityType, 's') + ->where([ + 's.ascendorId' => $entity->getId(), + 'descendorId' => $parentId, + ]) + ->build(); + + $insert = $em->getQueryBuilder() + ->insert() + ->into($pathEntityType) + ->columns(['ascendorId', 'descendorId']) + ->valuesQuery($select) + ->build(); + + $em->getQueryExecutor()->execute($insert); + } + } + + protected function afterRemove(Entity $entity, array $options = []) + { + parent::afterRemove($entity, $options); + + $pathEntityType = $this->getPathEntityType(); + + $em = $this->entityManager; + + $delete = $em->getQueryBuilder() + ->delete() + ->from($pathEntityType) + ->where([ + 'descendorId' => $entity->getId(), + ]) + ->build(); + + $em->getQueryExecutor()->execute($delete); + + $mapper = $em->getMapper(); + + if (!$mapper instanceof BaseMapper) { + return; + } + + $mapper->deleteFromDb($entity->getEntityType(), $entity->getId()); + } + + private function setOrderToEnd(Entity $entity): void + { + $parentId = $entity->get(self::ATTR_PARENT_ID); + + $where = [self::ATTR_PARENT_ID => $parentId]; + + if (!$entity->isNew()) { + $where[Attribute::ID . '!='] = $entity->getId(); + } + + $last = $this + ->where($where) + ->order(self::ATTR_ORDER, Order::DESC) + ->findOne(); + + $order = $last ? ($last->get(self::ATTR_ORDER) + 1) : 1; + + $entity->set(self::ATTR_ORDER, $order); + } + + private function getPathEntityType(): string + { + return $this->entityType . 'Path'; + } +} diff --git a/application/Espo/Core/Repositories/Database.php b/application/Espo/Core/Repositories/Database.php new file mode 100644 index 0000000..a640037 --- /dev/null +++ b/application/Espo/Core/Repositories/Database.php @@ -0,0 +1,521 @@ +. + * + * 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\Repositories; + +use Espo\Core\Name\Field; +use Espo\Core\ORM\Repository\Option\SaveOption; +use Espo\Core\Utils\SystemUser; +use Espo\ORM\BaseEntity; +use Espo\ORM\Defs\Params\AttributeParam; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Entity; +use Espo\ORM\Relation\RelationsMap; +use Espo\ORM\Repository\RDBRepository; +use Espo\Core\ORM\EntityFactory; +use Espo\Core\ORM\EntityManager; +use Espo\Core\ORM\Repository\HookMediator; +use Espo\Core\ApplicationState; +use Espo\Core\HookManager; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Core\Utils\Id\RecordIdGenerator; +use Espo\Core\Utils\Metadata; +use stdClass; + +/** + * A database repository. Extending is not recommended. Use hooks, field saver framework instead. + * + * @template TEntity of Entity + * @extends RDBRepository + */ +class Database extends RDBRepository +{ + private const string ATTR_ID = 'id'; + private const string ATTR_CREATED_BY_ID = Field::CREATED_BY . 'Id'; + private const string ATTR_MODIFIED_BY_ID = Field::MODIFIED_BY . 'Id'; + private const string ATTR_MODIFIED_BY_NAME = Field::MODIFIED_BY . 'Name'; + private const string ATTR_CREATED_AT = Field::CREATED_AT; + private const string ATTR_MODIFIED_AT = Field::MODIFIED_AT; + + /** + * Disables hook processing. + * @var bool + */ + protected $hooksDisabled = false; + + /** @var ?array */ + private $restoreData = null; + /** @var Metadata */ + protected $metadata; + /** @var HookManager */ + protected $hookManager; + /** @var ApplicationState */ + protected $applicationState; + /** @var RecordIdGenerator */ + protected $recordIdGenerator; + + public function __construct( + string $entityType, + EntityManager $entityManager, + EntityFactory $entityFactory, + Metadata $metadata, + HookManager $hookManager, + ApplicationState $applicationState, + RecordIdGenerator $recordIdGenerator, + private SystemUser $systemUser, + ?RelationsMap $relationsMap, + ) { + $this->metadata = $metadata; + $this->hookManager = $hookManager; + $this->applicationState = $applicationState; + $this->recordIdGenerator = $recordIdGenerator; + + $this->hooksDisabled = $this->hooksDisabled || $metadata->get("entityDefs.$entityType.hooksDisabled"); + $this->transactionalSave = $this->transactionalSave || + $metadata->get("entityDefs.$entityType.transactionalSave"); + + $hookMediator = null; + + if (!$this->hooksDisabled) { + $hookMediator = new HookMediator($hookManager); + } + + parent::__construct($entityType, $entityManager, $entityFactory, $hookMediator, $relationsMap); + } + + /** + * @deprecated Use `$this->metadata`. + */ + protected function getMetadata() /** @phpstan-ignore-line */ + { + return $this->metadata; + } + + /** + * @param TEntity $entity + * @param array $options + */ + public function save(Entity $entity, array $options = []): void + { + $this->prepareSaveInternal($entity, $options); + + parent::save($entity, $options); + } + + /** + * @param TEntity $entity + * @param array $options + */ + private function prepareSaveInternal(Entity $entity, array $options = []): void + { + if ( + $entity->isNew() && + !$entity->has(self::ATTR_ID) && + !$this->getAttributeParam($entity, self::ATTR_ID, AttributeParam::AUTOINCREMENT) + ) { + $entity->set(self::ATTR_ID, $this->recordIdGenerator->generate()); + } + + if (empty($options[SaveOption::SKIP_ALL])) { + $this->processCreatedAndModifiedFieldsSave($entity, $options); + } + + $this->restoreData = []; + } + + /** + * @param TEntity $entity + * @param array $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 $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); + } + } + + /** + * @deprecated Do not extend. Use hooks. + * + * @param TEntity $entity + * @param array $options + * @return void + * + * @todo Add final in v11.0. + */ + protected function beforeRemove(Entity $entity, array $options = []) + { + parent::beforeRemove($entity, $options); + + if (!$this->hooksDisabled && empty($options[SaveOption::SKIP_HOOKS])) { + $this->hookManager->process($this->entityType, 'beforeRemove', $entity, $options); + } + + $nowString = DateTimeUtil::getSystemNowString(); + + if ($entity->hasAttribute(self::ATTR_MODIFIED_AT)) { + $entity->set(self::ATTR_MODIFIED_AT, $nowString); + } + + if ($entity->hasAttribute(self::ATTR_MODIFIED_BY_ID)) { + $modifiedById = $options[SaveOption::MODIFIED_BY_ID] ?? null; + + if ($modifiedById === SystemUser::NAME) { + // For bc. + $modifiedById = $this->systemUser->getId(); + } + + if (!$modifiedById && $this->applicationState->hasUser()) { + $modifiedById = $this->applicationState->getUser()->getId(); + } + + if ($modifiedById) { + $entity->set(self::ATTR_MODIFIED_BY_ID, $modifiedById); + } + } + } + + /** + * @deprecated Do not extend. Use hooks. + * + * @param TEntity $entity + * @param array $options + * @return void + * + * @todo Add final in v11.0. + */ + protected function afterRemove(Entity $entity, array $options = []) + { + parent::afterRemove($entity, $options); + + if (!$this->hooksDisabled && empty($options[SaveOption::SKIP_HOOKS])) { + $this->hookManager->process($this->entityType, 'afterRemove', $entity, $options); + } + } + + /** + * @deprecated Do not extend. + * + * @param TEntity $entity + * @param string $relationName + * @param array $params + * @param array $options + * @return void + * + * @todo Add final in v11.0. + */ + protected function afterMassRelate(Entity $entity, $relationName, array $params = [], array $options = []) + { + if ($this->hooksDisabled || !empty($options[SaveOption::SKIP_HOOKS])) { + return; + } + + $hookData = [ + 'relationName' => $relationName, + 'relationParams' => $params, + ]; + + $this->hookManager->process( + $this->entityType, + 'afterMassRelate', + $entity, + $options, + $hookData + ); + } + + /** + * @deprecated Do not extend. Use hooks. + * + * @param TEntity $entity + * @param string $relationName + * @param Entity|string $foreign + * @param stdClass|array|null $data + * @param array $options + * @return void + */ + protected function afterRelate(Entity $entity, $relationName, $foreign, $data = null, array $options = []) + { + if ($this->hooksDisabled || !empty($options[SaveOption::SKIP_HOOKS])) { + return; + } + + if (is_string($foreign)) { + $foreignId = $foreign; + + $foreignEntityType = $this->getRelationParam($entity, $relationName, RelationParam::ENTITY); + + if ($foreignEntityType) { + $foreign = $this->entityManager->getNewEntity($foreignEntityType); + + $foreign->set(self::ATTR_ID, $foreignId); + $foreign->setAsFetched(); + + if ($foreign instanceof BaseEntity) { + $foreign->setAsPartiallyLoaded(); + } + } + } + + if ($foreign instanceof Entity) { + if (is_object($data)) { + $data = (array) $data; + } + + $this->hookMediator->afterRelate($entity, $relationName, $foreign, $data, $options); + } + } + + /** + * @deprecated Do not extend. Use hooks. + * + * @param TEntity $entity + * @param string $relationName + * @param Entity|string $foreign + * @param array $options + * @return void + */ + protected function afterUnrelate(Entity $entity, $relationName, $foreign, array $options = []) + { + if ($this->hooksDisabled || !empty($options[SaveOption::SKIP_HOOKS])) { + return; + } + + if (is_string($foreign)) { + $foreignId = $foreign; + + $foreignEntityType = $this->getRelationParam($entity, $relationName, RelationParam::ENTITY); + + if ($foreignEntityType) { + $foreign = $this->entityManager->getNewEntity($foreignEntityType); + + $foreign->set(self::ATTR_ID, $foreignId); + $foreign->setAsFetched(); + + if ($foreign instanceof BaseEntity) { + $foreign->setAsPartiallyLoaded(); + } + } + } + + if ($foreign instanceof Entity) { + $this->hookMediator->afterUnrelate($entity, $relationName, $foreign, $options); + } + } + + /** + * @deprecated Do not extend. Use hooks. + * + * @param TEntity $entity + * @param array $options + * @return void + */ + protected function beforeSave(Entity $entity, array $options = []) + { + parent::beforeSave($entity, $options); + + if (!$this->hooksDisabled && empty($options[SaveOption::SKIP_HOOKS])) { + $this->hookManager->process($this->entityType, 'beforeSave', $entity, $options); + } + } + + /** + * @deprecated Do not extend. Use hooks. + * + * @param TEntity $entity + * @param array $options + * @return void + */ + protected function afterSave(Entity $entity, array $options = []) + { + if (!empty($this->restoreData)) { + $entity->setMultiple($this->restoreData); + + $this->restoreData = null; + } + + parent::afterSave($entity, $options); + + if (!$this->hooksDisabled && empty($options[SaveOption::SKIP_HOOKS])) { + $this->hookManager->process($this->entityType, 'afterSave', $entity, $options); + } + } + + /** + * @param TEntity $entity + * @param array $options + */ + private function processCreatedAndModifiedFieldsSave(Entity $entity, array $options): void + { + if ($entity->isNew()) { + $this->processCreatedAndModifiedFieldsSaveNew($entity, $options); + + return; + } + + $nowString = DateTimeUtil::getSystemNowString(); + + if ( + !empty($options[SaveOption::SILENT]) || + !empty($options[SaveOption::SKIP_MODIFIED_BY]) + ) { + return; + } + + $isChanged = false; + + foreach ($entity->getAttributeList() as $attribute) { + if ($entity->isAttributeChanged($attribute)) { + $isChanged = true; + + break; + } + } + + if (!$isChanged && empty($options[SaveOption::MODIFIED_BY_ID])) { + return; + } + + if ($entity->hasAttribute(self::ATTR_MODIFIED_AT)) { + $entity->set(self::ATTR_MODIFIED_AT, $nowString); + } + + if ($entity->hasAttribute(self::ATTR_MODIFIED_BY_ID)) { + $modifiedById = $options[SaveOption::MODIFIED_BY_ID] ?? null; + + if ($modifiedById === SystemUser::NAME) { + // For bc. + $modifiedById = $this->systemUser->getId(); + } + + if ($modifiedById) { + $entity->set(self::ATTR_MODIFIED_BY_ID, $modifiedById); + } else if ($this->applicationState->hasUser()) { + $user = $this->applicationState->getUser(); + + $entity->set(self::ATTR_MODIFIED_BY_ID, $user->getId()); + $entity->set(self::ATTR_MODIFIED_BY_NAME, $user->getName()); + } + } + } + + /** + * @param TEntity $entity + * @param array $options + */ + private function processCreatedAndModifiedFieldsSaveNew(Entity $entity, array $options): void + { + $nowString = DateTimeUtil::getSystemNowString(); + + if ( + $entity->hasAttribute(self::ATTR_CREATED_AT) && + (empty($options[SaveOption::IMPORT]) || !$entity->has(self::ATTR_CREATED_AT)) + ) { + $entity->set(self::ATTR_CREATED_AT, $nowString); + } + + if ($entity->hasAttribute(self::ATTR_MODIFIED_AT)) { + $entity->set(self::ATTR_MODIFIED_AT, $nowString); + } + + if ($entity->hasAttribute(self::ATTR_CREATED_BY_ID)) { + $createdById = $options[SaveOption::CREATED_BY_ID] ?? null; + + if ($createdById) { + if ($createdById === SystemUser::NAME) { + // For bc. + $createdById = $this->systemUser->getId(); + } + + $entity->set(self::ATTR_CREATED_BY_ID, $createdById); + } else if ( + empty($options[SaveOption::SKIP_CREATED_BY]) && + (empty($options[SaveOption::IMPORT]) || !$entity->has(self::ATTR_CREATED_BY_ID)) && + $this->applicationState->hasUser() + ) { + $entity->set(self::ATTR_CREATED_BY_ID, $this->applicationState->getUser()->getId()); + } + } + } + + /** + * @pparam TEntity $entity + * @return mixed + * @noinspection PhpSameParameterValueInspection + */ + private function getAttributeParam(Entity $entity, string $attribute, string $param) + { + if ($entity instanceof BaseEntity) { + return $entity->getAttributeParam($attribute, $param); + } + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()); + + if (!$entityDefs->hasAttribute($attribute)) { + return null; + } + + return $entityDefs->getAttribute($attribute)->getParam($param); + } + + /** + * @param TEntity $entity + * @return mixed + * @noinspection PhpSameParameterValueInspection + */ + private function getRelationParam(Entity $entity, string $relation, string $param) + { + if ($entity instanceof BaseEntity) { + return $entity->getRelationParam($relation, $param); + } + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()); + + if (!$entityDefs->hasRelation($relation)) { + return null; + } + + return $entityDefs->getRelation($relation)->getParam($param); + } +} diff --git a/application/Espo/Core/Repositories/Event.php b/application/Espo/Core/Repositories/Event.php new file mode 100644 index 0000000..86ee3d4 --- /dev/null +++ b/application/Espo/Core/Repositories/Event.php @@ -0,0 +1,200 @@ +. + * + * 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\Repositories; + +use Espo\Core\Field\Date; +use Espo\Core\Field\DateTime as DateTimeField; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Modules\Crm\Entities\Meeting; +use Espo\Modules\Crm\Entities\Reminder; +use Espo\ORM\Entity; +use Espo\Core\Di; +use Espo\Core\Utils\DateTime as DateTimeUtil; + +use DateTime; +use DateTimeZone; +use RuntimeException; +use Exception; + +/** + * @extends Database + */ +class Event extends Database implements + + Di\DateTimeAware, + Di\ConfigAware +{ + use Di\DateTimeSetter; + use Di\ConfigSetter; + + private const string FIELD_DURATION = 'duration'; + + /** + * @param array $options + * @return void + */ + protected function beforeSave(Entity $entity, array $options = []) + { + if ( + $entity->isAttributeChanged(Meeting::FIELD_STATUS) && + in_array($entity->get(Meeting::FIELD_STATUS), $this->getNotActualStatuses()) + ) { + $entity->set('reminders', []); + } + + if ( + $entity->hasAttribute(Meeting::FIELD_IS_ALL_DAY) && + $entity->get(Meeting::FIELD_IS_ALL_DAY) === false + ) { + $entity->setMultiple([ + Meeting::FIELD_DATE_START_DATE => null, + Meeting::FIELD_DATE_END_DATE => null, + ]); + } + + if ($entity->has(Meeting::FIELD_DATE_START_DATE)) { + $dateStartDate = $entity->get(Meeting::FIELD_DATE_START_DATE); + + if (!empty($dateStartDate)) { + $dateStart = $dateStartDate . ' 00:00:00'; + + $dateStart = $this->convertDateTimeToDefaultTimezone($dateStart); + + $entity->set(Meeting::FIELD_DATE_START, $dateStart); + } else { + $entity->set(Meeting::FIELD_DATE_START_DATE, null); + } + } + + if ($entity->has(Meeting::FIELD_DATE_END_DATE)) { + $dateEndDate = $entity->get(Meeting::FIELD_DATE_END_DATE); + + if (!empty($dateEndDate)) { + try { + $dt = new DateTime( + $this->convertDateTimeToDefaultTimezone($dateEndDate . ' 00:00:00') + ); + } catch (Exception) { + throw new RuntimeException("Bad date-time."); + } + + $dt->modify('+1 day'); + + $dateEnd = $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + $entity->set(Meeting::FIELD_DATE_END, $dateEnd); + } else { + $entity->set(Meeting::FIELD_DATE_END_DATE, null); + } + } + + if ($entity->hasAttribute(self::FIELD_DURATION)) { + if ($entity->get(Meeting::FIELD_DATE_START) && !$entity->get(Meeting::FIELD_DATE_END)) { + $start = DateTimeField::fromString($entity->get(Meeting::FIELD_DATE_START)); + $duration = $entity->get(self::FIELD_DURATION) ?? 0; + + $end = $start->addSeconds($duration); + + $entity->set(Meeting::FIELD_DATE_END, $end->toString()); + } + + if ($entity->get(Meeting::FIELD_DATE_START_DATE) && !$entity->get(Meeting::FIELD_DATE_END_DATE)) { + $start = Date::fromString($entity->get(Meeting::FIELD_DATE_START_DATE)); + $duration = $entity->get(self::FIELD_DURATION) ?? 0; + + $days = (int) floor($duration / (3600 * 24)); + + $end = $start->addDays($days - 1); + + $entity->set(Meeting::FIELD_DATE_END_DATE, $end->toString()); + } + } + + + parent::beforeSave($entity, $options); + } + + /** + * @param array $options + * @return void + */ + protected function afterRemove(Entity $entity, array $options = []) + { + parent::afterRemove($entity, $options); + + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from(Reminder::ENTITY_TYPE) + ->where([ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + } + + /** + * @param string $string + * @return string + */ + protected function convertDateTimeToDefaultTimezone($string) + { + $timeZone = $this->config->get('timeZone') ?? 'UTC'; + + try { + $tz = new DateTimeZone($timeZone); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); + } + + $dt = DateTime::createFromFormat(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT, $string, $tz); + + if ($dt === false) { + throw new RuntimeException("Could not parse date-time `$string`."); + } + + $utcTz = new DateTimeZone('UTC'); + + return $dt + ->setTimezone($utcTz) + ->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT); + } + + /** + * @return string[] + */ + private function getNotActualStatuses(): array + { + return array_merge( + $this->metadata->get("scopes.$this->entityType.completedStatusList") ?? [], + $this->metadata->get("scopes.$this->entityType.canceledStatusList") ?? [], + ); + } +} diff --git a/application/Espo/Core/Select/AccessControl/Applier.php b/application/Espo/Core/Select/AccessControl/Applier.php new file mode 100644 index 0000000..6288e94 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Applier.php @@ -0,0 +1,79 @@ +. + * + * 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\Select\AccessControl; + +use Espo\Core\Select\AccessControl\FilterFactory as AccessControlFilterFactory; +use Espo\Core\Select\AccessControl\FilterResolverFactory as AccessControlFilterResolverFactory; +use Espo\Entities\User; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; +use RuntimeException; + +class Applier +{ + public function __construct( + private string $entityType, + private User $user, + private AccessControlFilterFactory $accessControlFilterFactory, + private AccessControlFilterResolverFactory $accessControlFilterResolverFactory, + ) {} + + public function apply(QueryBuilder $queryBuilder): void + { + $this->applyMandatoryFilter($queryBuilder); + + $accessControlFilterResolver = $this->accessControlFilterResolverFactory + ->create($this->entityType, $this->user); + + $filterName = $accessControlFilterResolver->resolve(); + + if (!$filterName) { + return; + } + + if ($this->accessControlFilterFactory->has($this->entityType, $filterName)) { + $filter = $this->accessControlFilterFactory + ->create($this->entityType, $this->user, $filterName); + + $filter->apply($queryBuilder); + + return; + } + + throw new RuntimeException("No access filter '{$filterName}' for '{$this->entityType}'."); + } + + private function applyMandatoryFilter(QueryBuilder $queryBuilder): void + { + $filter = $this->accessControlFilterFactory + ->create($this->entityType, $this->user, 'mandatory'); + + $filter->apply($queryBuilder); + } +} diff --git a/application/Espo/Core/Select/AccessControl/DefaultFilterResolver.php b/application/Espo/Core/Select/AccessControl/DefaultFilterResolver.php new file mode 100644 index 0000000..8dee603 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/DefaultFilterResolver.php @@ -0,0 +1,59 @@ +. + * + * 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\Select\AccessControl; + +use Espo\Core\Acl; + +class DefaultFilterResolver implements FilterResolver +{ + public function __construct(private string $entityType, private Acl $acl) + {} + + public function resolve(): ?string + { + if ($this->acl->checkReadNo($this->entityType)) { + return 'no'; + } + + if ($this->acl->checkReadOnlyOwn($this->entityType)) { + return 'onlyOwn'; + } + + if ($this->acl->checkReadOnlyTeam($this->entityType)) { + return 'onlyTeam'; + } + + if ($this->acl->checkReadAll($this->entityType)) { + return 'all'; + } + + return 'no'; + } +} diff --git a/application/Espo/Core/Select/AccessControl/DefaultPortalFilterResolver.php b/application/Espo/Core/Select/AccessControl/DefaultPortalFilterResolver.php new file mode 100644 index 0000000..e18493d --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/DefaultPortalFilterResolver.php @@ -0,0 +1,63 @@ +. + * + * 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\Select\AccessControl; + +use Espo\Core\Portal\Acl; + +class DefaultPortalFilterResolver implements FilterResolver +{ + public function __construct(private string $entityType, private Acl $acl) + {} + + public function resolve(): ?string + { + if ($this->acl->checkReadNo($this->entityType)) { + return 'no'; + } + + if ($this->acl->checkReadOnlyOwn($this->entityType)) { + return 'portalOnlyOwn'; + } + + if ($this->acl->checkReadOnlyAccount($this->entityType)) { + return 'portalOnlyAccount'; + } + + if ($this->acl->checkReadOnlyContact($this->entityType)) { + return 'portalOnlyContact'; + } + + if ($this->acl->checkReadAll($this->entityType)) { + return 'portalAll'; + } + + return 'no'; + } +} diff --git a/application/Espo/Core/Select/AccessControl/Filter.php b/application/Espo/Core/Select/AccessControl/Filter.php new file mode 100644 index 0000000..fa38ac8 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filter.php @@ -0,0 +1,46 @@ +. + * + * 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\Select\AccessControl; + +use Espo\ORM\Query\SelectBuilder; + +/** + * An access filter. + * + * Bindings: + * - `$entityType` + * - `Espo\Entities\User` + * - `Espo\Core\AclManager` – as of v9.1. + * - `Espo\Core\Acl` + */ +interface Filter +{ + public function apply(SelectBuilder $queryBuilder): void; +} diff --git a/application/Espo/Core/Select/AccessControl/FilterFactory.php b/application/Espo/Core/Select/AccessControl/FilterFactory.php new file mode 100644 index 0000000..574bbc4 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/FilterFactory.php @@ -0,0 +1,134 @@ +. + * + * 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\Select\AccessControl; + +use Espo\Core\Acl; +use Espo\Core\AclManager; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Portal\Acl as PortalAcl; +use Espo\Core\Portal\AclManager as PortalAclManager; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use RuntimeException; + +class FilterFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private AclManager $aclManager, + private Acl $acl, + ) {} + + public function create(string $entityType, User $user, string $name): Filter + { + $className = $this->getClassName($entityType, $name); + + if (!$className) { + throw new RuntimeException("Access control filter '$name' for '$entityType' does not exist."); + } + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(User::class, $user) + ->bindInstance(AclManager::class, $this->aclManager) + ->bindInstance(Acl::class, $this->acl); + + if ($user->isPortal()) { + $binder->bindInstance(PortalAcl::class, $this->acl); + $binder->bindInstance(PortalAclManager::class, $this->aclManager); + } + + $binder + ->for($className) + ->bindValue('$entityType', $entityType); + + $binder + ->for(FieldHelper::class) + ->bindValue('$entityType', $entityType); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + public function has(string $entityType, string $name): bool + { + return (bool) $this->getClassName($entityType, $name); + } + + /** + * @return class-string + */ + private function getClassName(string $entityType, string $name): ?string + { + if (!$name) { + throw new RuntimeException("Empty access control filter name."); + } + + /** @var ?class-string $className */ + $className = $this->metadata->get( + [ + 'selectDefs', + $entityType, + 'accessControlFilterClassNameMap', + $name, + ] + ); + + if ($className) { + return $className; + } + + return $this->getDefaultClassName($name); + } + + /** + * @return class-string + */ + private function getDefaultClassName(string $name): ?string + { + $className = 'Espo\\Core\\Select\\AccessControl\\Filters\\' . ucfirst($name); + + if (!class_exists($className)) { + return null; + } + + /** @var class-string */ + return $className; + } +} diff --git a/application/Espo/Core/Select/AccessControl/FilterResolver.php b/application/Espo/Core/Select/AccessControl/FilterResolver.php new file mode 100644 index 0000000..f837f79 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/FilterResolver.php @@ -0,0 +1,44 @@ +. + * + * 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\Select\AccessControl; + +/** + * Resolves an access filter. An entity type, acl and user to be passed to the constructor. + * + * Bindings: + * - `$entityType` + * - `Espo\Entities\User` + * - `Espo\Core\AclManager` – as of v9.1. + * - `Espo\Core\Acl` + */ +interface FilterResolver +{ + public function resolve(): ?string; +} diff --git a/application/Espo/Core/Select/AccessControl/FilterResolverFactory.php b/application/Espo/Core/Select/AccessControl/FilterResolverFactory.php new file mode 100644 index 0000000..9de466e --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/FilterResolverFactory.php @@ -0,0 +1,100 @@ +. + * + * 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\Select\AccessControl; + +use Espo\Core\AclManager; +use Espo\Core\InjectableFactory; +use Espo\Core\Acl; +use Espo\Core\Portal\Acl as PortalAcl; +use Espo\Core\Portal\AclManager as PortalAclManager; +use Espo\Core\Utils\Metadata; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingData; +use Espo\Entities\User; + +class FilterResolverFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private AclManager $aclManager, + private Acl $acl, + ) {} + + public function create(string $entityType, User $user): FilterResolver + { + $className = !$user->isPortal() ? + $this->getClassName($entityType) : + $this->getPortalClassName($entityType); + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(User::class, $user) + ->bindInstance(AclManager::class, $this->aclManager) + ->bindInstance(Acl::class, $this->acl); + + if ($user->isPortal()) { + $binder->bindInstance(PortalAcl::class, $this->acl); + $binder->bindInstance(PortalAclManager::class, $this->aclManager); + } + + $binder + ->for($className) + ->bindValue('$entityType', $entityType); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + /** + * @return class-string + */ + private function getClassName(string $entityType): string + { + /** @var class-string */ + return $this->metadata->get(['selectDefs', $entityType, 'accessControlFilterResolverClassName']) ?? + DefaultFilterResolver::class; + } + + /** + * @return class-string + */ + private function getPortalClassName(string $entityType): string + { + /** @var class-string */ + return $this->metadata->get(['selectDefs', $entityType, 'portalAccessControlFilterResolverClassName']) ?? + DefaultPortalFilterResolver::class; + } +} diff --git a/application/Espo/Core/Select/AccessControl/FilterResolvers/Boolean.php b/application/Espo/Core/Select/AccessControl/FilterResolvers/Boolean.php new file mode 100644 index 0000000..0c72610 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/FilterResolvers/Boolean.php @@ -0,0 +1,55 @@ +. + * + * 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\Select\AccessControl\FilterResolvers; + +use Espo\Core\Acl; +use Espo\Core\Select\AccessControl\FilterResolver; + +class Boolean implements FilterResolver +{ + private string $entityType; + + private Acl $acl; + + public function __construct(string $entityType, Acl $acl) + { + $this->entityType = $entityType; + $this->acl = $acl; + } + + public function resolve(): ?string + { + if ($this->acl->checkScope($this->entityType)) { + return 'all'; + } + + return 'no'; + } +} diff --git a/application/Espo/Core/Select/AccessControl/FilterResolvers/BooleanOwn.php b/application/Espo/Core/Select/AccessControl/FilterResolvers/BooleanOwn.php new file mode 100644 index 0000000..34473de --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/FilterResolvers/BooleanOwn.php @@ -0,0 +1,61 @@ +. + * + * 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\Select\AccessControl\FilterResolvers; + +use Espo\Core\Acl; +use Espo\Core\Select\AccessControl\FilterResolver; +use Espo\Entities\User; + +class BooleanOwn implements FilterResolver +{ + private string $entityType; + private Acl $acl; + private User $user; + + public function __construct(string $entityType, Acl $acl, User $user) + { + $this->entityType = $entityType; + $this->acl = $acl; + $this->user = $user; + } + + public function resolve(): ?string + { + if (!$this->acl->checkScope($this->entityType)) { + return 'no'; + } + + if ($this->user->isEffectiveAdmin()) { + return 'all'; + } + + return 'onlyOwn'; + } +} diff --git a/application/Espo/Core/Select/AccessControl/FilterResolvers/Bypass.php b/application/Espo/Core/Select/AccessControl/FilterResolvers/Bypass.php new file mode 100644 index 0000000..5dcd752 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/FilterResolvers/Bypass.php @@ -0,0 +1,40 @@ +. + * + * 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\Select\AccessControl\FilterResolvers; + +use Espo\Core\Select\AccessControl\FilterResolver; + +class Bypass implements FilterResolver +{ + public function resolve(): ?string + { + return null; + } +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/All.php b/application/Espo/Core/Select/AccessControl/Filters/All.php new file mode 100644 index 0000000..72e22d7 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/All.php @@ -0,0 +1,39 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class All implements Filter +{ + public function apply(QueryBuilder $queryBuilder): void + {} +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/ForeignOnlyOwn.php b/application/Espo/Core/Select/AccessControl/Filters/ForeignOnlyOwn.php new file mode 100644 index 0000000..1612155 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/ForeignOnlyOwn.php @@ -0,0 +1,157 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Acl\AssignmentChecker\Helper; +use Espo\Core\Name\Field; +use Espo\Core\Select\AccessControl\Filter; +use Espo\ORM\Defs; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\SelectBuilder; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; + +use LogicException; + +class ForeignOnlyOwn implements Filter +{ + public function __construct( + private string $entityType, + private User $user, + private Metadata $metadata, + private Defs $defs, + private Helper $helper, + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + $link = $this->metadata->get(['aclDefs', $this->entityType, 'link']); + + if (!$link) { + throw new LogicException("No `link` in aclDefs for $this->entityType."); + } + + $alias = $link . 'Access'; + + $queryBuilder->leftJoin($link, $alias); + + $foreignEntityType = $this->defs + ->getEntity($this->entityType) + ->getRelation($link) + ->getForeignEntityType(); + + $foreignEntityDefs = $this->defs->getEntity($foreignEntityType); + + $orBuilder = OrGroup::createBuilder(); + + if ($this->helper->hasCollaboratorsField($foreignEntityType)) { + $orBuilder->add( + Cond::equal( + Expr::column("$alias." . Attribute::ID), + SelectBuilder::create() + ->from(User::RELATIONSHIP_ENTITY_COLLABORATOR, 's') + ->select('s.entityId') + ->where( + Cond::and( + Cond::equal( + Expr::column('s.entityType'), + $foreignEntityType, + ), + Cond::equal( + Expr::column('s.userId'), + $this->user->getId(), + ) + ) + ) + ->build() + ) + ); + } + + if ($this->helper->hasAssignedUsersField($foreignEntityType)) { + $orBuilder->add( + Cond::equal( + Expr::column("$alias." . Attribute::ID), + SelectBuilder::create() + ->from(User::RELATIONSHIP_ENTITY_USER, 's') + ->select('s.entityId') + ->where( + Cond::and( + Cond::equal( + Expr::column('s.entityType'), + $foreignEntityType, + ), + Cond::equal( + Expr::column('s.userId'), + $this->user->getId(), + ) + ) + ) + ->build() + ) + ); + + $queryBuilder->where($orBuilder->build()); + + return; + } + + if ($foreignEntityDefs->hasField(Field::ASSIGNED_USER)) { + $orBuilder->add( + Cond::equal( + Expr::column("$alias.assignedUserId"), + $this->user->getId() + ) + ); + + $queryBuilder->where($orBuilder->build()); + + return; + } + + if ($foreignEntityDefs->hasField(Field::CREATED_BY)) { + $orBuilder->add( + Cond::equal( + Expr::column("$alias.createdById"), + $this->user->getId() + ) + ); + + $queryBuilder->where($orBuilder->build()); + + return; + } + + $queryBuilder->where([Attribute::ID => null]); + } +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/ForeignOnlyTeam.php b/application/Espo/Core/Select/AccessControl/Filters/ForeignOnlyTeam.php new file mode 100644 index 0000000..3d955c1 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/ForeignOnlyTeam.php @@ -0,0 +1,225 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Acl\AssignmentChecker\Helper; +use Espo\Core\Name\Field; +use Espo\Core\Select\AccessControl\Filter; +use Espo\Entities\Team; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\SelectBuilder; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs; +use Espo\Entities\User; + +use LogicException; + +/** + * @noinspection PhpUnused + */ +class ForeignOnlyTeam implements Filter +{ + public function __construct( + private string $entityType, + private User $user, + private Metadata $metadata, + private Defs $defs, + private Helper $helper, + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + $link = $this->metadata->get(['aclDefs', $this->entityType, 'link']); + + if (!$link) { + throw new LogicException("No `link` in aclDefs for $this->entityType."); + } + + $alias = "{$link}Access"; + + $foreignEntityType = $this->getForeignEntityType($link); + + $queryBuilder->leftJoin($link, $alias); + + $orBuilder = OrGroup::createBuilder(); + + $this->applyCollaborators($foreignEntityType, $orBuilder, $alias); + $this->applyAssignedUsers($foreignEntityType, $orBuilder, $alias); + $this->applyAssignedUser($foreignEntityType, $orBuilder, $alias); + $this->applyCreatedBy($foreignEntityType, $orBuilder, $alias); + $this->applyTeams($foreignEntityType, $orBuilder, $alias); + + if ($orBuilder->build()->getItemCount() === 0) { + $queryBuilder->where([Attribute::ID => null]); + + return; + } + + $queryBuilder + ->where($orBuilder->build()) + ->where(["$alias.id!=" => null]); + } + + private function getForeignEntityType(string $link): string + { + return $this->defs + ->getEntity($this->entityType) + ->getRelation($link) + ->getForeignEntityType(); + } + + private function applyCollaborators(string $foreignEntityType, OrGroupBuilder $orBuilder, string $alias): void + { + if (!$this->helper->hasCollaboratorsField($foreignEntityType)) { + return; + } + + $orBuilder->add( + Cond::equal( + Expr::column("$alias." . Attribute::ID), + SelectBuilder::create() + ->from(User::RELATIONSHIP_ENTITY_COLLABORATOR, 's') + ->select('s.entityId') + ->where( + Cond::and( + Cond::equal( + Expr::column('s.entityType'), + $foreignEntityType, + ), + Cond::equal( + Expr::column('s.userId'), + $this->user->getId(), + ) + ) + ) + ->build() + ) + ); + } + + private function applyAssignedUsers( + string $foreignEntityType, + OrGroupBuilder $orBuilder, + string $alias, + ): void { + + if (!$this->helper->hasAssignedUsersField($foreignEntityType)) { + return; + } + + $orBuilder->add( + Cond::equal( + Expr::column("$alias." . Attribute::ID), + SelectBuilder::create() + ->from(User::RELATIONSHIP_ENTITY_USER, 's') + ->select('s.entityId') + ->where( + Cond::and( + Cond::equal( + Expr::column('s.entityType'), + $foreignEntityType, + ), + Cond::equal( + Expr::column('s.userId'), + $this->user->getId(), + ) + ) + ) + ->build() + ) + ); + } + + private function applyAssignedUser(string $foreignEntityType, OrGroupBuilder $orBuilder, string $alias): void + { + $foreignEntityDefs = $this->defs->getEntity($foreignEntityType); + + if ( + $this->helper->hasAssignedUsersField($foreignEntityType) || + !$foreignEntityDefs->hasField(Field::ASSIGNED_USER) + ) { + return; + } + + $orBuilder->add( + Cond::equal( + Expr::column("$alias.assignedUserId"), + $this->user->getId() + ) + ); + } + + private function applyCreatedBy(string $foreignEntityType, OrGroupBuilder $orBuilder, string $alias): void + { + $foreignEntityDefs = $this->defs->getEntity($foreignEntityType); + + if ( + $this->helper->hasAssignedUsersField($foreignEntityType) || + $foreignEntityDefs->hasField(Field::ASSIGNED_USER) || + !$foreignEntityDefs->hasField(Field::CREATED_BY) + ) { + return; + } + + $orBuilder->add( + Cond::equal( + Expr::column("$alias.createdById"), + $this->user->getId() + ) + ); + } + + private function applyTeams(string $foreignEntityType, OrGroupBuilder $orBuilder, string $alias): void + { + $teamIdList = $this->user->getTeamIdList(); + + if (count($teamIdList) === 0) { + return; + } + + $orBuilder->add( + Cond::in( + Expr::column("$alias.id"), + SelectBuilder::create() + ->from(Team::RELATIONSHIP_ENTITY_TEAM) + ->select('entityId') + ->where([ + 'teamId' => $teamIdList, + 'entityType' => $foreignEntityType, + ]) + ->build() + ) + ); + } +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/Mandatory.php b/application/Espo/Core/Select/AccessControl/Filters/Mandatory.php new file mode 100644 index 0000000..f7c29d6 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/Mandatory.php @@ -0,0 +1,39 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\ORM\Query\SelectBuilder; + +class Mandatory implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + {} +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/No.php b/application/Espo/Core/Select/AccessControl/Filters/No.php new file mode 100644 index 0000000..a36d039 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/No.php @@ -0,0 +1,42 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; + +class No implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + { + $queryBuilder->where([Attribute::ID => null]); + } +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/OnlyOwn.php b/application/Espo/Core/Select/AccessControl/Filters/OnlyOwn.php new file mode 100644 index 0000000..b681a64 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/OnlyOwn.php @@ -0,0 +1,101 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\Core\Select\Helpers\RelationQueryHelper; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem; +use Espo\ORM\Query\SelectBuilder; + +class OnlyOwn implements Filter +{ + public function __construct( + private User $user, + private FieldHelper $fieldHelper, + private string $entityType, + private RelationQueryHelper $relationQueryHelper, + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + $ownItem = $this->getOwnWhereItem(); + + if ($this->fieldHelper->hasCollaboratorsField()) { + $this->applyCollaborators($queryBuilder, $ownItem); + + return; + } + + if (!$ownItem) { + $queryBuilder->where([Attribute::ID => null]); + + return; + } + + $queryBuilder->where($ownItem); + } + + private function applyCollaborators(SelectBuilder $queryBuilder, ?WhereItem $ownItem): void + { + $sharedItem = $this->relationQueryHelper->prepareCollaboratorsWhere($this->entityType, $this->user->getId()); + + $orBuilder = OrGroup::createBuilder(); + + if ($ownItem) { + $orBuilder->add($ownItem); + } + + $orBuilder->add($sharedItem); + + $queryBuilder->where($orBuilder->build()); + } + + private function getOwnWhereItem(): ?WhereItem + { + if ($this->fieldHelper->hasAssignedUsersField()) { + return $this->relationQueryHelper->prepareAssignedUsersWhere($this->entityType, $this->user->getId()); + } + + if ($this->fieldHelper->hasAssignedUserField()) { + return WhereClause::fromRaw(['assignedUserId' => $this->user->getId()]); + } + + if ($this->fieldHelper->hasCreatedByField()) { + return WhereClause::fromRaw(['createdById' => $this->user->getId()]); + } + + return null; + } +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/OnlyTeam.php b/application/Espo/Core/Select/AccessControl/Filters/OnlyTeam.php new file mode 100644 index 0000000..0d75649 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/OnlyTeam.php @@ -0,0 +1,129 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Name\Field; +use Espo\Core\Select\AccessControl\Filter; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Defs; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; + +/** + * @noinspection PhpUnused + */ +class OnlyTeam implements Filter +{ + public function __construct( + private User $user, + private FieldHelper $fieldHelper, + private string $entityType, + private Defs $defs + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + if (!$this->fieldHelper->hasTeamsField()) { + $queryBuilder->where([Attribute::ID => null]); + + return; + } + + $subQueryBuilder = SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType) + ->leftJoin(Team::RELATIONSHIP_ENTITY_TEAM, 'entityTeam', [ + 'entityTeam.entityId:' => Attribute::ID, + 'entityTeam.entityType' => $this->entityType, + 'entityTeam.deleted' => false, + ]); + + // Empty list is converted to false statement by ORM. + $orGroup = ['entityTeam.teamId' => $this->user->getTeamIdList()]; + + if ($this->fieldHelper->hasAssignedUsersField()) { + $relationDefs = $this->defs + ->getEntity($this->entityType) + ->getRelation(Field::ASSIGNED_USERS); + + $middleEntityType = ucfirst($relationDefs->getRelationshipName()); + $key1 = $relationDefs->getMidKey(); + $key2 = $relationDefs->getForeignMidKey(); + + $midConditions = [ + "assignedUsersMiddle.$key1:" => Attribute::ID, + 'assignedUsersMiddle.deleted' => false, + ]; + + foreach ($relationDefs->getConditions() as $key => $value) { + $midConditions["assignedUsersMiddle.$key"] = $value; + } + + $subQueryBuilder->leftJoin($middleEntityType, 'assignedUsersMiddle', $midConditions); + + $orGroup["assignedUsersMiddle.$key2"] = $this->user->getId(); + } else if ($this->fieldHelper->hasAssignedUserField()) { + $orGroup['assignedUserId'] = $this->user->getId(); + } else if ($this->fieldHelper->hasCreatedByField()) { + $orGroup['createdById'] = $this->user->getId(); + } + + if ($this->fieldHelper->hasCollaboratorsField()) { + $relationDefs = $this->defs + ->getEntity($this->entityType) + ->getRelation(Field::COLLABORATORS); + + $middleEntityType = ucfirst($relationDefs->getRelationshipName()); + $key1 = $relationDefs->getMidKey(); + $key2 = $relationDefs->getForeignMidKey(); + + $midConditions = [ + "collaboratorsMiddle.$key1:" => Attribute::ID, + 'collaboratorsMiddle.deleted' => false, + ]; + + foreach ($relationDefs->getConditions() as $key => $value) { + $midConditions["collaboratorsMiddle.$key"] = $value; + } + + $subQueryBuilder->leftJoin($middleEntityType, 'collaboratorsMiddle', $midConditions); + + $orGroup["collaboratorsMiddle.$key2"] = $this->user->getId(); + } + + $subQuery = $subQueryBuilder + ->where(['OR' => $orGroup]) + ->build(); + + $queryBuilder->where(['id=s' => $subQuery]); + } +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/PortalAll.php b/application/Espo/Core/Select/AccessControl/Filters/PortalAll.php new file mode 100644 index 0000000..364dc2d --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/PortalAll.php @@ -0,0 +1,39 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\ORM\Query\SelectBuilder; + +class PortalAll implements Filter +{ + public function apply(SelectBuilder $queryBuilder): void + {} +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/PortalOnlyAccount.php b/application/Espo/Core/Select/AccessControl/Filters/PortalOnlyAccount.php new file mode 100644 index 0000000..6b92c18 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/PortalOnlyAccount.php @@ -0,0 +1,120 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Name\Field; +use Espo\Core\Portal\Acl\OwnershipChecker\MetadataProvider; +use Espo\Core\Select\AccessControl\Filter; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Core\Select\Helpers\RelationQueryHelper; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem; +use Espo\ORM\Query\SelectBuilder; + +class PortalOnlyAccount implements Filter +{ + public function __construct( + private string $entityType, + private User $user, + private FieldHelper $fieldHelper, + private MetadataProvider $metadataProvider, + private RelationQueryHelper $relationQueryHelper, + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + $orBuilder = OrGroup::createBuilder(); + + $accountIds = $this->user->getAccounts()->getIdList(); + $contactId = $this->user->getContactId(); + + if ($accountIds !== []) { + $or = $this->prepareAccountWhere($queryBuilder, $accountIds); + + if ($or) { + $orBuilder->add($or); + } + } + + if ($contactId) { + $or = $this->prepareContactWhere($queryBuilder, $contactId); + + if ($or) { + $orBuilder->add($or); + } + } + + if ($this->fieldHelper->hasCreatedByField()) { + $orBuilder->add( + WhereClause::fromRaw([Field::CREATED_BY . 'Id' => $this->user->getId()]) + ); + } + + $orGroup = $orBuilder->build(); + + if ($orGroup->getItemCount() === 0) { + $queryBuilder->where([Attribute::ID => null]); + + return; + } + + $queryBuilder->where($orGroup); + } + + /** + * @param string[] $ids + */ + private function prepareAccountWhere(SelectBuilder $queryBuilder, array $ids): ?WhereItem + { + $defs = $this->metadataProvider->getAccountLink($this->entityType); + + if (!$defs) { + return null; + } + + return $this->relationQueryHelper->prepareLinkWhere($defs, Account::ENTITY_TYPE, $ids, $queryBuilder); + } + + private function prepareContactWhere(SelectBuilder $queryBuilder, string $id): ?WhereItem + { + $defs = $this->metadataProvider->getContactLink($this->entityType); + + if (!$defs) { + return null; + } + + return $this->relationQueryHelper->prepareLinkWhere($defs, Contact::ENTITY_TYPE, $id, $queryBuilder); + } +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/PortalOnlyContact.php b/application/Espo/Core/Select/AccessControl/Filters/PortalOnlyContact.php new file mode 100644 index 0000000..29dbd07 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/PortalOnlyContact.php @@ -0,0 +1,96 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Name\Field; +use Espo\Core\Portal\Acl\OwnershipChecker\MetadataProvider; +use Espo\Core\Select\AccessControl\Filter; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Core\Select\Helpers\RelationQueryHelper; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem; +use Espo\ORM\Query\SelectBuilder; + +class PortalOnlyContact implements Filter +{ + public function __construct( + private string $entityType, + private User $user, + private FieldHelper $fieldHelper, + private MetadataProvider $metadataProvider, + private RelationQueryHelper $relationQueryHelper, + ) {} + + public function apply(SelectBuilder $queryBuilder): void + { + $orBuilder = OrGroup::createBuilder(); + + $contactId = $this->user->getContactId(); + + if ($contactId) { + $or = $this->prepareContactWhere($queryBuilder, $contactId); + + if ($or) { + $orBuilder->add($or); + } + } + + if ($this->fieldHelper->hasCreatedByField()) { + $orBuilder->add( + WhereClause::fromRaw([Field::CREATED_BY . 'Id' => $this->user->getId()]) + ); + } + + $orGroup = $orBuilder->build(); + + if ($orGroup->getItemCount() === 0) { + $queryBuilder->where([Attribute::ID => null]); + + return; + } + + $queryBuilder->where($orGroup); + } + + private function prepareContactWhere(SelectBuilder $queryBuilder, string $id): ?WhereItem + { + $defs = $this->metadataProvider->getContactLink($this->entityType); + + if (!$defs) { + return null; + } + + return $this->relationQueryHelper->prepareLinkWhere($defs, Contact::ENTITY_TYPE, $id, $queryBuilder); + } +} diff --git a/application/Espo/Core/Select/AccessControl/Filters/PortalOnlyOwn.php b/application/Espo/Core/Select/AccessControl/Filters/PortalOnlyOwn.php new file mode 100644 index 0000000..28f0f45 --- /dev/null +++ b/application/Espo/Core/Select/AccessControl/Filters/PortalOnlyOwn.php @@ -0,0 +1,55 @@ +. + * + * 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\Select\AccessControl\Filters; + +use Espo\Core\Select\AccessControl\Filter; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; + +class PortalOnlyOwn implements Filter +{ + public function __construct(private User $user, private FieldHelper $fieldHelper) + {} + + public function apply(SelectBuilder $queryBuilder): void + { + if ($this->fieldHelper->hasCreatedByField()) { + $queryBuilder->where([ + 'createdById' => $this->user->getId(), + ]); + + return; + } + + $queryBuilder->where([Attribute::ID => null]); + } +} diff --git a/application/Espo/Core/Select/Applier/AdditionalApplier.php b/application/Espo/Core/Select/Applier/AdditionalApplier.php new file mode 100644 index 0000000..25064a4 --- /dev/null +++ b/application/Espo/Core/Select/Applier/AdditionalApplier.php @@ -0,0 +1,38 @@ +. + * + * 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\Select\Applier; + +use Espo\ORM\Query\SelectBuilder; +use Espo\Core\Select\SearchParams; + +interface AdditionalApplier +{ + public function apply(SelectBuilder $queryBuilder, SearchParams $searchParams): void; +} diff --git a/application/Espo/Core/Select/Applier/AdditionalAppliers/IsStarred.php b/application/Espo/Core/Select/Applier/AdditionalAppliers/IsStarred.php new file mode 100644 index 0000000..96eade9 --- /dev/null +++ b/application/Espo/Core/Select/Applier/AdditionalAppliers/IsStarred.php @@ -0,0 +1,66 @@ +. + * + * 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\Select\Applier\AdditionalAppliers; + +use Espo\Core\Name\Field; +use Espo\Core\Select\Applier\AdditionalApplier; +use Espo\Core\Select\SearchParams; +use Espo\Core\Utils\Metadata; +use Espo\Entities\StarSubscription; +use Espo\Entities\User; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\SelectBuilder; + +/** + * @noinspection PhpUnused + */ +class IsStarred implements AdditionalApplier +{ + public function __construct( + private string $entityType, + private User $user, + private Metadata $metadata, + ) {} + + public function apply(SelectBuilder $queryBuilder, SearchParams $searchParams): void + { + if (!$this->metadata->get("scopes.$this->entityType.stars")) { + return; + } + + $queryBuilder + ->select(Expr::isNotNull(Expr::column('starSubscription.id')), Field::IS_STARRED) + ->leftJoin(StarSubscription::ENTITY_TYPE, 'starSubscription', [ + 'userId' => $this->user->getId(), + 'entityType' => $this->entityType, + 'entityId:' => 'id', + ]); + } +} diff --git a/application/Espo/Core/Select/Applier/Appliers/Additional.php b/application/Espo/Core/Select/Applier/Appliers/Additional.php new file mode 100644 index 0000000..2498f89 --- /dev/null +++ b/application/Espo/Core/Select/Applier/Appliers/Additional.php @@ -0,0 +1,88 @@ +. + * + * 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\Select\Applier\Appliers; + +use Espo\Core\Binding\ContextualBinder; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; +use Espo\Core\Select\SearchParams; +use Espo\Core\InjectableFactory; +use Espo\Core\Select\Applier\AdditionalApplier; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Entities\User; + +class Additional +{ + public function __construct( + private User $user, + private InjectableFactory $injectableFactory, + private string $entityType, + private Metadata $metadata, + ) {} + + /** + * @param class-string[] $classNameList + */ + public function apply(array $classNameList, QueryBuilder $queryBuilder, SearchParams $searchParams): void + { + $classNameList = array_merge($this->getMandatoryClassNameList(), $classNameList); + + foreach ($classNameList as $className) { + $applier = $this->createApplier($className); + + $applier->apply($queryBuilder, $searchParams); + } + } + + /** + * @param class-string $className + */ + private function createApplier(string $className): AdditionalApplier + { + return $this->injectableFactory->createWithBinding( + $className, + BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->inContext($className, function (ContextualBinder $binder) { + $binder->bindValue('$entityType', $this->entityType); + }) + ->build() + ); + } + + /** + * @return class-string[] + */ + private function getMandatoryClassNameList(): array + { + /** @var class-string[] */ + return $this->metadata->get("selectDefs.$this->entityType.additionalApplierClassNameList") ?? []; + } +} diff --git a/application/Espo/Core/Select/Applier/Appliers/Limit.php b/application/Espo/Core/Select/Applier/Appliers/Limit.php new file mode 100644 index 0000000..5ef1bbc --- /dev/null +++ b/application/Espo/Core/Select/Applier/Appliers/Limit.php @@ -0,0 +1,40 @@ +. + * + * 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\Select\Applier\Appliers; + +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class Limit +{ + public function apply(QueryBuilder $queryBuilder, ?int $offset, ?int $maxSize): void + { + $queryBuilder->limit($offset, $maxSize); + } +} diff --git a/application/Espo/Core/Select/Applier/Factory.php b/application/Espo/Core/Select/Applier/Factory.php new file mode 100644 index 0000000..4a1cc15 --- /dev/null +++ b/application/Espo/Core/Select/Applier/Factory.php @@ -0,0 +1,174 @@ +. + * + * 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\Select\Applier; + +use Espo\Core\Acl; +use Espo\Core\AclManager; +use Espo\Core\Select\Text\Applier as TextFilterApplier; +use Espo\Core\Select\AccessControl\Applier as AccessControlFilterApplier; +use Espo\Core\Select\Where\Applier as WhereApplier; +use Espo\Core\Select\Select\Applier as SelectApplier; +use Espo\Core\Select\Primary\Applier as PrimaryFilterApplier; +use Espo\Core\Select\Order\Applier as OrderApplier; +use Espo\Core\Select\Bool\Applier as BoolFilterListApplier; +use Espo\Core\Select\Applier\Appliers\Additional as AdditionalApplier; +use Espo\Core\Select\Applier\Appliers\Limit as LimitApplier; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; + +use Espo\Core\Utils\Acl\UserAclManagerProvider; +use Espo\Entities\User; +use RuntimeException; + +class Factory +{ + public const SELECT = 'select'; + public const WHERE = 'where'; + public const ORDER = 'order'; + public const LIMIT = 'limit'; + public const ACCESS_CONTROL_FILTER = 'accessControlFilter'; + public const TEXT_FILTER = 'textFilter'; + public const PRIMARY_FILTER = 'primaryFilter'; + public const BOOL_FILTER_LIST = 'boolFilterList'; + public const ADDITIONAL = 'additional'; + + /** + * @var array> + */ + private array $defaultClassNameMap = [ + self::TEXT_FILTER => TextFilterApplier::class, + self::ACCESS_CONTROL_FILTER => AccessControlFilterApplier::class, + self::WHERE => WhereApplier::class, + self::SELECT => SelectApplier::class, + self::PRIMARY_FILTER => PrimaryFilterApplier::class, + self::ORDER => OrderApplier::class, + self::BOOL_FILTER_LIST => BoolFilterListApplier::class, + self::ADDITIONAL => AdditionalApplier::class, + self::LIMIT => LimitApplier::class, + ]; + + public function __construct( + private InjectableFactory $injectableFactory, + private UserAclManagerProvider $userAclManagerProvider, + ) {} + + private function create(string $entityType, User $user, string $type): object + { + $className = $this->getDefaultClassName($type); + + $aclManager = $this->userAclManagerProvider->get($user); + $acl = $aclManager->createUserAcl($user); + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(User::class, $user) + ->bindInstance(AclManager::class, $aclManager) + ->bindInstance(Acl::class, $acl); + + $binder + ->for($className) + ->bindValue('$entityType', $entityType); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + public function createWhere(string $entityType, User $user): WhereApplier + { + /** @var WhereApplier */ + return $this->create($entityType, $user, self::WHERE); + } + + public function createSelect(string $entityType, User $user): SelectApplier + { + /** @var SelectApplier */ + return $this->create($entityType, $user, self::SELECT); + } + + public function createOrder(string $entityType, User $user): OrderApplier + { + /** @var OrderApplier */ + return $this->create($entityType, $user, self::ORDER); + } + + public function createLimit(string $entityType, User $user): LimitApplier + { + /** @var LimitApplier */ + return $this->create($entityType, $user, self::LIMIT); + } + + public function createAccessControlFilter(string $entityType, User $user): AccessControlFilterApplier + { + /** @var AccessControlFilterApplier */ + return $this->create($entityType, $user, self::ACCESS_CONTROL_FILTER); + } + + public function createTextFilter(string $entityType, User $user): TextFilterApplier + { + /** @var TextFilterApplier */ + return $this->create($entityType, $user, self::TEXT_FILTER); + } + + public function createPrimaryFilter(string $entityType, User $user): PrimaryFilterApplier + { + /** @var PrimaryFilterApplier */ + return $this->create($entityType, $user, self::PRIMARY_FILTER); + } + + public function createBoolFilterList(string $entityType, User $user): BoolFilterListApplier + { + /** @var BoolFilterListApplier */ + return $this->create($entityType, $user, self::BOOL_FILTER_LIST); + } + + public function createAdditional(string $entityType, User $user): AdditionalApplier + { + /** @var AdditionalApplier */ + return $this->create($entityType, $user, self::ADDITIONAL); + } + + /** + * @return class-string + */ + private function getDefaultClassName(string $type): string + { + if (array_key_exists($type, $this->defaultClassNameMap)) { + return $this->defaultClassNameMap[$type]; + } + + throw new RuntimeException("Applier `$type` does not exist."); + } +} diff --git a/application/Espo/Core/Select/Bool/Applier.php b/application/Espo/Core/Select/Bool/Applier.php new file mode 100644 index 0000000..3f78aca --- /dev/null +++ b/application/Espo/Core/Select/Bool/Applier.php @@ -0,0 +1,106 @@ +. + * + * 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\Select\Bool; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Select\Bool\FilterFactory as BoolFilterFactory; +use Espo\ORM\Query\Select; +use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; + +use Espo\Entities\User; + +class Applier +{ + public function __construct( + private string $entityType, + private User $user, + private BoolFilterFactory $boolFilterFactory, + ) {} + + /** + * @param string[] $boolFilterNameList + * @throws BadRequest + */ + public function apply(SelectBuilder $queryBuilder, array $boolFilterNameList): void + { + $orGroupBuilder = new OrGroupBuilder(); + + $isMultiple = count($boolFilterNameList) > 1; + + if ($isMultiple) { + $queryBefore = $queryBuilder->build(); + } + + foreach ($boolFilterNameList as $filterName) { + $this->applyBoolFilter($queryBuilder, $orGroupBuilder, $filterName); + } + + if ($isMultiple) { + $this->handleMultiple($queryBefore, $queryBuilder); + } + + $queryBuilder->where( + $orGroupBuilder->build() + ); + } + + /** + * @throws BadRequest + */ + private function applyBoolFilter( + SelectBuilder $queryBuilder, + OrGroupBuilder $orGroupBuilder, + string $filterName + ): void { + + if ($this->boolFilterFactory->has($this->entityType, $filterName)) { + $filter = $this->boolFilterFactory->create($this->entityType, $this->user, $filterName); + + $filter->apply($queryBuilder, $orGroupBuilder); + + return; + } + + throw new BadRequest("No bool filter '$filterName' for '$this->entityType'."); + } + + private function handleMultiple(Select $queryBefore, SelectBuilder $queryBuilder): void + { + $queryAfter = $queryBuilder->build(); + + $joinCountBefore = count($queryBefore->getJoins()); + $joinCountAfter = count($queryAfter->getJoins()); + + if ($joinCountBefore < $joinCountAfter) { + $queryBuilder->distinct(); + } + } +} diff --git a/application/Espo/Core/Select/Bool/Filter.php b/application/Espo/Core/Select/Bool/Filter.php new file mode 100644 index 0000000..4dfaa5b --- /dev/null +++ b/application/Espo/Core/Select/Bool/Filter.php @@ -0,0 +1,41 @@ +. + * + * 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\Select\Bool; + +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\SelectBuilder; + +/** + * Applies a bool filter. A where item should be added to OrGroupBuilder. + */ +interface Filter +{ + public function apply(SelectBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void; +} diff --git a/application/Espo/Core/Select/Bool/FilterFactory.php b/application/Espo/Core/Select/Bool/FilterFactory.php new file mode 100644 index 0000000..d5bbfc3 --- /dev/null +++ b/application/Espo/Core/Select/Bool/FilterFactory.php @@ -0,0 +1,127 @@ +. + * + * 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\Select\Bool; + +use Espo\Core\InjectableFactory; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Core\Utils\Metadata; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingData; +use Espo\Entities\User; + +use RuntimeException; + +class FilterFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata + ) {} + + public function create(string $entityType, User $user, string $name): Filter + { + $className = $this->getClassName($entityType, $name); + + if (!$className) { + throw new RuntimeException("Bool filter '$name' for '$entityType' does not exist."); + } + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(User::class, $user) + ->for($className) + ->bindValue('$entityType', $entityType); + + $binder + ->for(FieldHelper::class) + ->bindValue('$entityType', $entityType); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + public function has(string $entityType, string $name): bool + { + return (bool) $this->getClassName($entityType, $name); + } + + /** + * @return ?class-string + */ + protected function getClassName(string $entityType, string $name): ?string + { + if (!$name) { + throw new RuntimeException("Empty bool filter name."); + } + + $className = $this->metadata->get( + [ + 'selectDefs', + $entityType, + 'boolFilterClassNameMap', + $name, + ] + ); + + if ($className) { + /** @var ?class-string */ + return $className; + } + + return $this->getDefaultClassName($name); + } + + /** + * @return ?class-string + */ + protected function getDefaultClassName(string $name): ?string + { + $className1 = $this->metadata->get(['app', 'select', 'boolFilterClassNameMap', $name]); + + if ($className1) { + /** @var class-string */ + return $className1; + } + + $className = 'Espo\\Core\\Select\\Bool\\Filters\\' . ucfirst($name); + + if (!class_exists($className)) { + return null; + } + + /** @var class-string */ + return $className; + } +} diff --git a/application/Espo/Core/Select/Bool/Filters/Followed.php b/application/Espo/Core/Select/Bool/Filters/Followed.php new file mode 100644 index 0000000..650adb3 --- /dev/null +++ b/application/Espo/Core/Select/Bool/Filters/Followed.php @@ -0,0 +1,65 @@ +. + * + * 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\Select\Bool\Filters; + +use Espo\Core\Select\Bool\Filter; +use Espo\Entities\StreamSubscription; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\SelectBuilder; + +class Followed implements Filter +{ + public function __construct(private string $entityType, private User $user) + {} + + public function apply(SelectBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + $alias = 'subscriptionFollowedBoolFilter'; + + $queryBuilder->leftJoin( + StreamSubscription::ENTITY_TYPE, + $alias, + [ + $alias . '.entityType' => $this->entityType, + $alias . '.entityId=:' => Attribute::ID, + $alias . '.userId' => $this->user->getId(), + ] + ); + + $orGroupBuilder->add( + WhereClause::fromRaw([ + $alias . '.id!=' => null, + ]) + ); + } +} diff --git a/application/Espo/Core/Select/Bool/Filters/OnlyMy.php b/application/Espo/Core/Select/Bool/Filters/OnlyMy.php new file mode 100644 index 0000000..017ef27 --- /dev/null +++ b/application/Espo/Core/Select/Bool/Filters/OnlyMy.php @@ -0,0 +1,122 @@ +. + * + * 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\Select\Bool\Filters; + +use Espo\Core\Name\Field; +use Espo\Core\Select\Bool\Filter; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Entities\User; +use Espo\ORM\Defs; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\SelectBuilder; + +/** + * @noinspection PhpUnused + */ +class OnlyMy implements Filter +{ + public const NAME = 'onlyMy'; + + public function __construct( + private string $entityType, + private User $user, + private FieldHelper $fieldHelper, + private Defs $defs + ) {} + + public function apply(SelectBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + if ($this->user->isPortal()) { + $orGroupBuilder->add( + Cond::equal( + Cond::column('createdById'), + $this->user->getId() + ) + ); + + return; + } + + if ($this->fieldHelper->hasAssignedUsersField()) { + $relationDefs = $this->defs + ->getEntity($this->entityType) + ->getRelation(Field::ASSIGNED_USERS); + + $middleEntityType = ucfirst($relationDefs->getRelationshipName()); + $key1 = $relationDefs->getMidKey(); + $key2 = $relationDefs->getForeignMidKey(); + + $midConditions = [ + "assignedUsersMiddle.$key1:" => Attribute::ID, + 'assignedUsersMiddle.deleted' => false, + ]; + + foreach ($relationDefs->getConditions() as $key => $value) { + $midConditions["assignedUsersMiddle.$key"] = $value; + } + + $subQuery = SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType) + ->leftJoin($middleEntityType, 'assignedUsersMiddle', $midConditions) + ->where(["assignedUsersMiddle.$key2" => $this->user->getId()]) + ->build(); + + $orGroupBuilder->add( + Cond::in( + Cond::column(Attribute::ID), + $subQuery + ) + ); + + return; + } + + if ($this->fieldHelper->hasAssignedUserField()) { + $orGroupBuilder->add( + Cond::equal( + Cond::column('assignedUserId'), + $this->user->getId() + ) + ); + + return; + } + + $orGroupBuilder->add( + Cond::equal( + Cond::column('createdById'), + $this->user->getId() + ) + ); + } +} diff --git a/application/Espo/Core/Select/Bool/Filters/Shared.php b/application/Espo/Core/Select/Bool/Filters/Shared.php new file mode 100644 index 0000000..3bdf5f3 --- /dev/null +++ b/application/Espo/Core/Select/Bool/Filters/Shared.php @@ -0,0 +1,93 @@ +. + * + * 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\Select\Bool\Filters; + +use Espo\Core\Name\Field; +use Espo\Core\Select\Bool\Filter; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Entities\User; +use Espo\ORM\Defs; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\SelectBuilder; + +/** + * @noinspection PhpUnused + */ +class Shared implements Filter +{ + public const NAME = 'shared'; + + public function __construct( + private string $entityType, + private User $user, + private FieldHelper $fieldHelper, + private Defs $defs + ) {} + + public function apply(SelectBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + if (!$this->fieldHelper->hasCollaboratorsField()) { + return; + } + + $relationDefs = $this->defs + ->getEntity($this->entityType) + ->getRelation(Field::COLLABORATORS); + + $middleEntityType = ucfirst($relationDefs->getRelationshipName()); + $key1 = $relationDefs->getMidKey(); + $key2 = $relationDefs->getForeignMidKey(); + + $midConditions = [ + "collaboratorsMiddle.$key1:" => Attribute::ID, + 'collaboratorsMiddle.deleted' => false, + ]; + + foreach ($relationDefs->getConditions() as $key => $value) { + $midConditions["collaboratorsMiddle.$key"] = $value; + } + + $subQuery = SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType) + ->leftJoin($middleEntityType, 'collaboratorsMiddle', $midConditions) + ->where(["collaboratorsMiddle.$key2" => $this->user->getId()]) + ->build(); + + $orGroupBuilder->add( + Cond::in( + Cond::column('id'), + $subQuery + ) + ); + } +} diff --git a/application/Espo/Core/Select/Helpers/EntityHelper.php b/application/Espo/Core/Select/Helpers/EntityHelper.php new file mode 100644 index 0000000..8408863 --- /dev/null +++ b/application/Espo/Core/Select/Helpers/EntityHelper.php @@ -0,0 +1,65 @@ +. + * + * 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\Select\Helpers; + +use Espo\ORM\BaseEntity; +use Espo\ORM\Defs; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Entity; + +/** + * @internal + */ +class EntityHelper +{ + public function __construct( + private Defs $defs, + ) {} + + /** + * @internal + */ + public function getRelationEntityType(Entity $entity, string $relation): ?string + { + if ($entity instanceof BaseEntity) { + return $entity->getRelationParam($relation, RelationParam::ENTITY); + } + + $entityDefs = $this->defs->getEntity($entity->getEntityType()); + + if (!$entityDefs->hasRelation($relation)) { + return null; + } + + return $entityDefs + ->getRelation($relation) + ->tryGetForeignEntityType(); + } +} diff --git a/application/Espo/Core/Select/Helpers/FieldHelper.php b/application/Espo/Core/Select/Helpers/FieldHelper.php new file mode 100644 index 0000000..3a494e6 --- /dev/null +++ b/application/Espo/Core/Select/Helpers/FieldHelper.php @@ -0,0 +1,210 @@ +. + * + * 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\Select\Helpers; + +use Espo\Core\Name\Field; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Defs\Params\RelationParam; +use Espo\ORM\Defs\RelationDefs; +use Espo\ORM\EntityManager; +use Espo\ORM\Entity; +use Espo\ORM\BaseEntity; +use Espo\ORM\Type\RelationType; + +/** + * @todo Rewrite using EntityDefs class. Then write unit tests. + */ +class FieldHelper +{ + private ?Entity $seed = null; + + private const LINK_CONTACTS = 'contacts'; + private const LINK_CONTACT = 'contact'; + private const LINK_ACCOUNTS = 'accounts'; + private const LINK_ACCOUNT = 'account'; + private const LINK_PARENT = Field::PARENT; + private const LINK_TEAMS = Field::TEAMS; + private const LINK_ASSIGNED_USERS = Field::ASSIGNED_USERS; + private const LINK_ASSIGNED_USER = Field::ASSIGNED_USER; + private const LINK_CREATED_BY = Field::CREATED_BY; + private const LINK_COLLABORATORS = Field::COLLABORATORS; + + public function __construct( + private string $entityType, + private EntityManager $entityManager, + private Metadata $metadata, + ) {} + + private function getSeed(): Entity + { + $this->seed ??= $this->entityManager->getNewEntity($this->entityType); + + return $this->seed; + } + + public function hasAssignedUsersField(): bool + { + if ( + $this->getSeed()->hasRelation(self::LINK_ASSIGNED_USERS) && + $this->getSeed()->hasAttribute(self::LINK_ASSIGNED_USERS . 'Ids') && + $this->getRelationEntityType(self::LINK_ASSIGNED_USERS) === User::ENTITY_TYPE + ) { + return true; + } + + return false; + } + + public function hasCollaboratorsField(): bool + { + if ( + $this->metadata->get("scopes.$this->entityType.collaborators") && + $this->getSeed()->hasRelation(self::LINK_COLLABORATORS) && + $this->getSeed()->hasAttribute(self::LINK_COLLABORATORS . 'Ids') && + $this->getRelationEntityType(self::LINK_COLLABORATORS) === User::ENTITY_TYPE + ) { + return true; + } + + return false; + } + + public function hasAssignedUserField(): bool + { + if ( + $this->getSeed()->hasAttribute(self::LINK_ASSIGNED_USER . 'Id') && + $this->getSeed()->hasRelation(self::LINK_ASSIGNED_USER) && + $this->getRelationEntityType(self::LINK_ASSIGNED_USER) === User::ENTITY_TYPE + ) { + return true; + } + + return false; + } + + public function hasCreatedByField(): bool + { + if ( + $this->getSeed()->hasAttribute(self::LINK_CREATED_BY . 'Id') && + $this->getSeed()->hasRelation(self::LINK_CREATED_BY) && + $this->getRelationEntityType(self::LINK_CREATED_BY) === User::ENTITY_TYPE + ) { + return true; + } + + return false; + } + + public function hasTeamsField(): bool + { + if ( + $this->getSeed()->hasRelation(self::LINK_TEAMS) && + $this->getSeed()->hasAttribute(self::LINK_TEAMS . 'Ids') && + $this->getRelationEntityType(self::LINK_TEAMS) === Team::ENTITY_TYPE + ) { + return true; + } + + return false; + } + + public function hasContactField(): bool + { + return + $this->getSeed()->hasAttribute(self::LINK_CONTACT . 'Id') && + $this->getRelationEntityType(self::LINK_CONTACT) === Contact::ENTITY_TYPE; + } + + public function hasContactsRelation(): bool + { + return + $this->getSeed()->hasRelation(self::LINK_CONTACTS) && + $this->getRelationEntityType(self::LINK_CONTACTS) === Contact::ENTITY_TYPE; + } + + public function hasParentField(): bool + { + return + $this->getSeed()->hasAttribute(self::LINK_PARENT . 'Id') && + $this->getSeed()->hasRelation(self::LINK_PARENT) && + $this->getSeed()->getRelationType(self::LINK_PARENT) === RelationType::BELONGS_TO_PARENT; + } + + public function hasAccountField(): bool + { + return + $this->getSeed()->hasAttribute(self::LINK_ACCOUNT . 'Id') && + $this->getRelationEntityType(self::LINK_ACCOUNT) === Account::ENTITY_TYPE; + } + + public function hasAccountsRelation(): bool + { + return + $this->getSeed()->hasRelation(self::LINK_ACCOUNTS) && + $this->getRelationEntityType(self::LINK_ACCOUNTS) === Account::ENTITY_TYPE; + } + + public function getRelationDefs(string $name): RelationDefs + { + return $this->entityManager + ->getDefs() + ->getEntity($this->entityType) + ->getRelation($name); + } + + /** + * @noinspection PhpSameParameterValueInspection + */ + private function getRelationParam(Entity $entity, string $relation, string $param): mixed + { + if ($entity instanceof BaseEntity) { + return $entity->getRelationParam($relation, $param); + } + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()); + + if (!$entityDefs->hasRelation($relation)) { + return null; + } + + return $entityDefs->getRelation($relation)->getParam($param); + } + + private function getRelationEntityType(string $relation): ?string + { + return $this->getRelationParam($this->getSeed(), $relation, RelationParam::ENTITY); + } +} diff --git a/application/Espo/Core/Select/Helpers/RandomStringGenerator.php b/application/Espo/Core/Select/Helpers/RandomStringGenerator.php new file mode 100644 index 0000000..92d4b65 --- /dev/null +++ b/application/Espo/Core/Select/Helpers/RandomStringGenerator.php @@ -0,0 +1,38 @@ +. + * + * 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\Select\Helpers; + +class RandomStringGenerator +{ + public function generate(): string + { + return strval(rand(10000, 99999)); + } +} diff --git a/application/Espo/Core/Select/Helpers/RelationQueryHelper.php b/application/Espo/Core/Select/Helpers/RelationQueryHelper.php new file mode 100644 index 0000000..d3a7f33 --- /dev/null +++ b/application/Espo/Core/Select/Helpers/RelationQueryHelper.php @@ -0,0 +1,197 @@ +. + * + * 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\Select\Helpers; + +use Espo\Core\Name\Field; +use Espo\Entities\User; +use Espo\ORM\Defs; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem; +use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; +use Espo\ORM\Type\RelationType; +use LogicException; +use RuntimeException; + +/** + * @since 9.0.0 + */ +class RelationQueryHelper +{ + public function __construct( + private Defs $defs, + ) {} + + public function prepareAssignedUsersWhere(string $entityType, string $userId): WhereItem + { + return $this->prepareRelatedUsersWhere( + $entityType, + $userId, + Field::ASSIGNED_USERS, + User::RELATIONSHIP_ENTITY_USER + ); + } + + public function prepareCollaboratorsWhere(string $entityType, string $userId): WhereItem + { + return $this->prepareRelatedUsersWhere( + $entityType, + $userId, + Field::COLLABORATORS, + User::RELATIONSHIP_ENTITY_COLLABORATOR + ); + } + + private function prepareRelatedUsersWhere( + string $entityType, + string $userId, + string $field, + string $relationship + ): WhereItem { + + $relationDefs = $this->defs + ->getEntity($entityType) + ->getRelation($field); + + $middleEntityType = ucfirst($relationDefs->getRelationshipName()); + $key1 = $relationDefs->getMidKey(); + $key2 = $relationDefs->getForeignMidKey(); + + $joinWhere = [ + "m.$key1:" => Attribute::ID, + 'm.deleted' => false, + ]; + + if ($middleEntityType === $relationship) { + $joinWhere['m.entityType'] = $entityType; + } + + $subQuery = QueryBuilder::create() + ->select(Attribute::ID) + ->from($entityType) + ->leftJoin($middleEntityType, 'm', $joinWhere) + ->where(["m.$key2" => $userId]) + ->build(); + + return Condition::in( + Expression::column('id'), + $subQuery + ); + } + + /** + * @param string|string[] $id + * + * @since 9.1.6 + */ + public function prepareLinkWhereMany(string $entityType, string $link, string|array $id): WhereItem + { + $defs = $this->defs + ->getEntity($entityType) + ->getRelation($link); + + if (!in_array($defs->getType(), [RelationType::HAS_MANY, RelationType::MANY_MANY])) { + throw new LogicException("Only many-many and has-many allowed."); + } + + $builder = SelectBuilder::create()->from($entityType); + + $whereItem = $this->prepareLinkWhere($defs, $entityType, $id, $builder); + + if (!$whereItem) { + throw new RuntimeException("Not supported relationship."); + } + + return $whereItem; + } + + /** + * @internal Signature can be changed in future. + * + * @param string|string[] $id + */ + public function prepareLinkWhere( + Defs\RelationDefs $defs, + string $entityType, + string|array $id, + QueryBuilder $queryBuilder + ): ?WhereItem { + + $type = $defs->getType(); + $link = $defs->getName(); + + if ( + $type === RelationType::BELONGS_TO || + $type === RelationType::HAS_ONE + ) { + if ($type === RelationType::HAS_ONE) { + $queryBuilder->leftJoin($link); + } + + return WhereClause::fromRaw([$link . 'Id' => $id]); + } + + if ($type === RelationType::BELONGS_TO_PARENT) { + return WhereClause::fromRaw([ + 'parentType' => $entityType, + 'parentId' => $id, + ]); + } + + if ($type === RelationType::MANY_MANY) { + return Cond::in( + Expr::column(Attribute::ID), + QueryBuilder::create() + ->from(ucfirst($defs->getRelationshipName()), 'm') + ->select($defs->getMidKey()) + ->where([$defs->getForeignMidKey() => $id]) + ->build() + ); + } + + if ($type === RelationType::HAS_MANY) { + return Cond::in( + Expr::column(Attribute::ID), + QueryBuilder::create() + ->from($entityType, 's') + ->select($defs->getForeignKey()) + ->where([Attribute::ID => $id]) + ->build() + ); + } + + return null; + } +} diff --git a/application/Espo/Core/Select/Helpers/UserTimeZoneProvider.php b/application/Espo/Core/Select/Helpers/UserTimeZoneProvider.php new file mode 100644 index 0000000..e4b24a8 --- /dev/null +++ b/application/Espo/Core/Select/Helpers/UserTimeZoneProvider.php @@ -0,0 +1,59 @@ +. + * + * 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\Select\Helpers; + +use Espo\Core\Utils\Config\ApplicationConfig; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\Entities\Preferences; + +class UserTimeZoneProvider +{ + public function __construct( + private User $user, + private EntityManager $entityManager, + private ApplicationConfig $applicationConfig, + ) {} + + public function get(): string + { + $preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $this->user->getId()); + + if (!$preferences) { + return 'UTC'; + } + + if ($preferences->get('timeZone') === null || $preferences->get('timeZone') === '') { + return $this->applicationConfig->getTimeZone(); + } + + return $preferences->get('timeZone'); + } +} diff --git a/application/Espo/Core/Select/Order/Applier.php b/application/Espo/Core/Select/Order/Applier.php new file mode 100644 index 0000000..563756d --- /dev/null +++ b/application/Espo/Core/Select/Order/Applier.php @@ -0,0 +1,241 @@ +. + * + * 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\Select\Order; + +use Espo\Core\Acl; +use Espo\Core\AclManager; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\ORM\Type\FieldType; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\OrderList; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Select\Order\Item as OrderItem; +use Espo\Core\Select\Order\Params as OrderParams; +use Espo\Core\Select\SearchParams; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +use RuntimeException; + +class Applier +{ + public function __construct( + private string $entityType, + private MetadataProvider $metadataProvider, + private ItemConverterFactory $itemConverterFactory, + private OrdererFactory $ordererFactory, + private AclManager $aclManager, + private User $user, + private Acl\SystemRestriction $systemRestriction, + ) {} + + /** + * @throws Forbidden + * @throws BadRequest + */ + public function apply(QueryBuilder $queryBuilder, OrderParams $params): void + { + if ($params->forceDefault()) { + $this->applyDefaultOrder($queryBuilder, $params->getOrder()); + + return; + } + + $orderBy = $params->getOrderBy(); + + if ($orderBy) { + if ( + str_contains($orderBy, '.') || + str_contains($orderBy, ':') + ) { + throw new Forbidden("Complex expressions are forbidden in 'orderBy'."); + } + + if ($this->metadataProvider->isFieldOrderDisabled($this->entityType, $orderBy)) { + throw new Forbidden("Order by the field '$orderBy' is disabled."); + } + + if ($this->metadataProvider->getFieldType($this->entityType, $orderBy) === FieldType::PASSWORD) { + throw new Forbidden("Order by field '$orderBy' is not allowed."); + } + + if ( + $params->applyPermissionCheck() && + !$this->aclManager->checkField($this->user, $this->entityType, $orderBy) + ) { + throw new Forbidden("Not access to order by field '$orderBy'."); + } + + if ( + !$params->applyPermissionCheck() && + !$this->systemRestriction->checkFieldRead($this->entityType, $orderBy) + ) { + throw new Forbidden("Cannot order by restricted field '$orderBy'."); + } + } + + if ($orderBy === null) { + $orderBy = $this->metadataProvider->getDefaultOrderBy($this->entityType); + } + + if (!$orderBy) { + return; + } + + $this->applyOrder($queryBuilder, $orderBy, $params->getOrder()); + } + + /** + * @param SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null $order + * @throws BadRequest + */ + private function applyDefaultOrder(QueryBuilder $queryBuilder, ?string $order): void + { + $orderBy = $this->metadataProvider->getDefaultOrderBy($this->entityType); + + if (!$orderBy) { + $queryBuilder->order(Attribute::ID, $order); + + return; + } + + if (!$order) { + $order = $this->metadataProvider->getDefaultOrder($this->entityType); + + if ($order && strtolower($order) === 'desc') { + $order = SearchParams::ORDER_DESC; + } else if ($order && strtolower($order) === 'asc') { + $order = SearchParams::ORDER_ASC; + } else if ($order !== null) { + throw new RuntimeException("Bad default order."); + } + } + + /** @var SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null $order */ + + $this->applyOrder($queryBuilder, $orderBy, $order); + } + + /** + * @param SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null $order + * @throws BadRequest + */ + private function applyOrder(QueryBuilder $queryBuilder, string $orderBy, ?string $order): void + { + if (!$orderBy) { + throw new RuntimeException("Could not apply empty order."); + } + + if ($order === null) { + $order = SearchParams::ORDER_ASC; + } + + $hasOrderer = $this->ordererFactory->has($this->entityType, $orderBy); + + if ($hasOrderer) { + $orderer = $this->ordererFactory->create($this->entityType, $orderBy); + + $orderer->apply( + $queryBuilder, + OrderItem::create($orderBy, $order) + ); + + if ($order !== Attribute::ID) { + $queryBuilder->order(Attribute::ID, $order); + } + + return; + } + + $resultOrderBy = $orderBy; + + $type = $this->metadataProvider->getFieldType($this->entityType, $orderBy); + + $hasItemConverter = $this->itemConverterFactory->has($this->entityType, $orderBy); + + if ($hasItemConverter) { + $converter = $this->itemConverterFactory->create($this->entityType, $orderBy); + + $resultOrderBy = $this->orderListToArray( + $converter->convert( + OrderItem::create($orderBy, $order) + ) + ); + } else if (in_array($type, [FieldType::LINK, FieldType::FILE, FieldType::IMAGE, FieldType::LINK_ONE])) { + $resultOrderBy .= 'Name'; + } else if ($type === FieldType::LINK_PARENT) { + $resultOrderBy .= 'Type'; + } else if ( + !$this->metadataProvider->hasAttribute($this->entityType, $orderBy) + ) { + throw new BadRequest("Order by non-existing field '$orderBy'."); + } + + $orderByAttribute = null; + + if (!is_array($resultOrderBy)) { + $orderByAttribute = $resultOrderBy; + + $resultOrderBy = [ + [$resultOrderBy, $order] + ]; + } + + if ( + $orderBy !== Attribute::ID && + ( + !$orderByAttribute || + !$this->metadataProvider->isAttributeParamUniqueTrue($this->entityType, $orderByAttribute) + ) && + $this->metadataProvider->hasAttribute($this->entityType, Attribute::ID) + ) { + $resultOrderBy[] = [Attribute::ID, $order]; + } + + $queryBuilder->order($resultOrderBy); + } + + /** + * @return array + */ + private function orderListToArray(OrderList $orderList): array + { + $list = []; + + foreach ($orderList as $order) { + $list[] = [ + $order->getExpression()->getValue(), + $order->getDirection(), + ]; + } + + return $list; + } +} diff --git a/application/Espo/Core/Select/Order/Item.php b/application/Espo/Core/Select/Order/Item.php new file mode 100644 index 0000000..0e75caf --- /dev/null +++ b/application/Espo/Core/Select/Order/Item.php @@ -0,0 +1,90 @@ +. + * + * 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\Select\Order; + +use Espo\Core\Select\SearchParams; + +use InvalidArgumentException; + +/** + * Immutable. + */ +class Item +{ + private string $orderBy; + /** @var SearchParams::ORDER_ASC|SearchParams::ORDER_DESC */ + private string $order; + + /** + * @param SearchParams::ORDER_ASC|SearchParams::ORDER_DESC $order + */ + private function __construct(string $orderBy, string $order) + { + if ( + $order !== SearchParams::ORDER_ASC && + $order !== SearchParams::ORDER_DESC + ) { + throw new InvalidArgumentException("Bad order."); + } + + $this->orderBy = $orderBy; + $this->order = $order; + } + + /** + * @param SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null $order + */ + public static function create(string $orderBy, ?string $order = null): self + { + if ($order === null) { + $order = SearchParams::ORDER_ASC; + } + + return new self($orderBy, $order); + } + + /** + * Get a field. + */ + public function getOrderBy(): string + { + return $this->orderBy; + } + + /** + * Get a direction. + * + * @return SearchParams::ORDER_ASC|SearchParams::ORDER_DESC + */ + public function getOrder(): string + { + return $this->order; + } +} diff --git a/application/Espo/Core/Select/Order/ItemConverter.php b/application/Espo/Core/Select/Order/ItemConverter.php new file mode 100644 index 0000000..e9da719 --- /dev/null +++ b/application/Espo/Core/Select/Order/ItemConverter.php @@ -0,0 +1,40 @@ +. + * + * 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\Select\Order; + +use Espo\ORM\Query\Part\OrderList; + +/** + * Converts an order item (passed from front-end) to an ORM order list. + */ +interface ItemConverter +{ + public function convert(Item $item): OrderList; +} diff --git a/application/Espo/Core/Select/Order/ItemConverterFactory.php b/application/Espo/Core/Select/Order/ItemConverterFactory.php new file mode 100644 index 0000000..f2f2566 --- /dev/null +++ b/application/Espo/Core/Select/Order/ItemConverterFactory.php @@ -0,0 +1,113 @@ +. + * + * 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\Select\Order; + +use Espo\Entities\User; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Binding\ContextualBinder; + +use Espo\ORM\Defs\Params\FieldParam; +use RuntimeException; + +class ItemConverterFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private User $user + ) {} + + public function has(string $entityType, string $field): bool + { + return (bool) $this->getClassName($entityType, $field); + } + + public function create(string $entityType, string $field): ItemConverter + { + $className = $this->getClassName($entityType, $field); + + if (!$className) { + throw new RuntimeException("Order item converter class name is not defined."); + } + + $container = BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->inContext($className, function (ContextualBinder $binder) use ($entityType) { + $binder->bindValue('$entityType', $entityType); + }) + ->build(); + + return $this->injectableFactory->createWithBinding($className, $container); + } + + /** + * @return ?class-string + */ + private function getClassName(string $entityType, string $field): ?string + { + /** @var ?class-string $className1 */ + $className1 = $this->metadata->get([ + 'selectDefs', $entityType, 'orderItemConverterClassNameMap', $field + ]); + + if ($className1) { + return $className1; + } + + $type = $this->metadata->get([ + 'entityDefs', $entityType, 'fields', $field, FieldParam::TYPE + ]); + + if (!$type) { + return null; + } + + /** @var ?class-string $className2 */ + $className2 = $this->metadata->get([ + 'app', 'select', 'orderItemConverterClassNameMap', $type + ]); + + if ($className2) { + return $className2; + } + + $className3 = 'Espo\\Core\\Select\\Order\\ItemConverters\\' . ucfirst($type) . 'Type'; + + if (class_exists($className3)) { + /** @var class-string */ + return $className3; + } + + return null; + } +} diff --git a/application/Espo/Core/Select/Order/ItemConverters/AddressType.php b/application/Espo/Core/Select/Order/ItemConverters/AddressType.php new file mode 100644 index 0000000..4f595c5 --- /dev/null +++ b/application/Espo/Core/Select/Order/ItemConverters/AddressType.php @@ -0,0 +1,51 @@ +. + * + * 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\Select\Order\ItemConverters; + +use Espo\ORM\Query\Part\OrderList; +use Espo\ORM\Query\Part\Order; + +use Espo\Core\Select\Order\Item; +use Espo\Core\Select\Order\ItemConverter; + +class AddressType implements ItemConverter +{ + public function convert(Item $item): OrderList + { + $orderBy = $item->getOrderBy(); + $order = $item->getOrder(); + + return OrderList::create([ + Order::fromString($orderBy . 'Country')->withDirection($order), + Order::fromString($orderBy . 'City')->withDirection($order), + Order::fromString($orderBy . 'Street')->withDirection($order), + ]); + } +} diff --git a/application/Espo/Core/Select/Order/ItemConverters/EnumType.php b/application/Espo/Core/Select/Order/ItemConverters/EnumType.php new file mode 100644 index 0000000..8071f78 --- /dev/null +++ b/application/Espo/Core/Select/Order/ItemConverters/EnumType.php @@ -0,0 +1,79 @@ +. + * + * 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\Select\Order\ItemConverters; + +use Espo\ORM\Query\Part\OrderList; +use Espo\ORM\Query\Part\Order; +use Espo\ORM\Query\Part\Expression; +use Espo\Core\Select\Order\Item; +use Espo\Core\Select\Order\ItemConverter; +use Espo\Core\Select\SearchParams; +use Espo\Core\Utils\Metadata; + +class EnumType implements ItemConverter +{ + public function __construct( + private string $entityType, + private Metadata $metadata + ) {} + + public function convert(Item $item): OrderList + { + $orderBy = $item->getOrderBy(); + $order = $item->getOrder(); + + /** @var ?string[] $list */ + $list = $this->metadata->get([ + 'entityDefs', $this->entityType, 'fields', $orderBy, 'options' + ]); + + if (!is_array($list) || !count($list)) { + return OrderList::create([ + Order::fromString($orderBy)->withDirection($order) + ]); + } + + $isSorted = $this->metadata->get([ + 'entityDefs', $this->entityType, 'fields', $orderBy, 'isSorted' + ]); + + if ($isSorted) { + asort($list); + } + + if ($order === SearchParams::ORDER_DESC) { + $list = array_reverse($list); + } + + return OrderList::create([ + Order::createByPositionInList(Expression::column($orderBy), $list), + ]); + } +} diff --git a/application/Espo/Core/Select/Order/MetadataProvider.php b/application/Espo/Core/Select/Order/MetadataProvider.php new file mode 100644 index 0000000..6c9e0ee --- /dev/null +++ b/application/Espo/Core/Select/Order/MetadataProvider.php @@ -0,0 +1,85 @@ +. + * + * 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\Select\Order; + +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\ORM\EntityManager; + +class MetadataProvider +{ + public function __construct(private Metadata $metadata, private EntityManager $entityManager) + {} + + public function getFieldType(string $entityType, string $field): ?string + { + return $this->metadata->get([ + 'entityDefs', $entityType, 'fields', $field, FieldParam::TYPE + ]) ?? null; + } + + public function getDefaultOrderBy(string $entityType): ?string + { + return $this->metadata->get([ + 'entityDefs', $entityType, 'collection', 'orderBy' + ]) ?? null; + } + + public function getDefaultOrder(string $entityType): ?string + { + return $this->metadata->get([ + 'entityDefs', $entityType, 'collection', 'order' + ]) ?? null; + } + + public function isFieldOrderDisabled(string $entityType, string $field): bool + { + return $this->metadata->get("entityDefs.$entityType.fields.$field.orderDisabled") ?? false; + } + + public function hasAttribute(string $entityType, string $attribute): bool + { + return $this->entityManager + ->getMetadata() + ->getDefs() + ->getEntity($entityType) + ->hasAttribute($attribute); + } + + public function isAttributeParamUniqueTrue(string $entityType, string $attribute): bool + { + return (bool) $this->entityManager + ->getMetadata() + ->getDefs() + ->getEntity($entityType) + ->getAttribute($attribute) + ->getParam('unique'); + } +} diff --git a/application/Espo/Core/Select/Order/Orderer.php b/application/Espo/Core/Select/Order/Orderer.php new file mode 100644 index 0000000..8efa00d --- /dev/null +++ b/application/Espo/Core/Select/Order/Orderer.php @@ -0,0 +1,40 @@ +. + * + * 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\Select\Order; + +use Espo\ORM\Query\SelectBuilder; + +/** + * A custom orderer. Should call SelectBuilder::order. + */ +interface Orderer +{ + public function apply(SelectBuilder $queryBuilder, Item $item): void; +} diff --git a/application/Espo/Core/Select/Order/OrdererFactory.php b/application/Espo/Core/Select/Order/OrdererFactory.php new file mode 100644 index 0000000..219b6f9 --- /dev/null +++ b/application/Espo/Core/Select/Order/OrdererFactory.php @@ -0,0 +1,81 @@ +. + * + * 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\Select\Order; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Binding\ContextualBinder; + +use Espo\Entities\User; +use RuntimeException; + +class OrdererFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata, + private User $user + ) {} + + public function has(string $entityType, string $field): bool + { + return (bool) $this->getClassName($entityType, $field); + } + + public function create(string $entityType, string $field): Orderer + { + $className = $this->getClassName($entityType, $field); + + if (!$className) { + throw new RuntimeException("Orderer class name is not defined."); + } + + $container = BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->inContext($className, function (ContextualBinder $binder) use ($entityType) { + $binder->bindValue('$entityType', $entityType); + }) + ->build(); + + return $this->injectableFactory->createWithBinding($className, $container); + } + + /** + * @return ?class-string + */ + private function getClassName(string $entityType, string $field): ?string + { + /** @var ?class-string */ + return $this->metadata->get([ + 'selectDefs', $entityType, 'ordererClassNameMap', $field + ]); + } +} diff --git a/application/Espo/Core/Select/Order/Params.php b/application/Espo/Core/Select/Order/Params.php new file mode 100644 index 0000000..bc9dca0 --- /dev/null +++ b/application/Espo/Core/Select/Order/Params.php @@ -0,0 +1,126 @@ +. + * + * 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\Select\Order; + +use Espo\Core\Select\SearchParams; + +use InvalidArgumentException; + +/** + * Order parameters. + * + * Immutable. + */ +class Params +{ + private bool $forceDefault = false; + private mixed $orderBy = null; + /** @var SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null */ + private $order = null; + private bool $applyPermissionCheck = false; + + private function __construct() {} + + /** + * @param array{ + * forceDefault?: bool, + * orderBy?: ?string, + * order?: SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null, + * applyPermissionCheck?: bool, + * } $params + */ + public static function fromAssoc(array $params): self + { + $object = new self(); + + $object->forceDefault = $params['forceDefault'] ?? false; + $object->orderBy = $params['orderBy'] ?? null; + $object->order = $params['order'] ?? null; + $object->applyPermissionCheck = $params['applyPermissionCheck'] ?? false; + + foreach ($params as $key => $value) { + if (!property_exists($object, $key)) { + throw new InvalidArgumentException("Unknown parameter '{$key}'."); + } + } + + if ($object->orderBy && !is_string($object->orderBy)) { + throw new InvalidArgumentException("Bad orderBy."); + } + + /** @var ?string $order */ + $order = $object->order; + + if ( + $order && + $order !== SearchParams::ORDER_ASC && + $order !== SearchParams::ORDER_DESC + ) { + throw new InvalidArgumentException("Bad order."); + } + + return $object; + } + + /** + * Force default order. + */ + public function forceDefault(): bool + { + return $this->forceDefault; + } + + /** + * An order-By field. + */ + public function getOrderBy(): ?string + { + /** @var ?string */ + return $this->orderBy; + } + + /** + * An order direction. + * + * @return SearchParams::ORDER_ASC|SearchParams::ORDER_DESC|null + */ + public function getOrder(): ?string + { + return $this->order; + } + + /** + * Apply permission check. + */ + public function applyPermissionCheck(): bool + { + return $this->applyPermissionCheck; + } +} diff --git a/application/Espo/Core/Select/OrmSelectBuilder.php b/application/Espo/Core/Select/OrmSelectBuilder.php new file mode 100644 index 0000000..05b8951 --- /dev/null +++ b/application/Espo/Core/Select/OrmSelectBuilder.php @@ -0,0 +1,47 @@ +. + * + * 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\Select; + +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * Need access to raw params for backward compatibility. + * The legacy select manager operates with raw params. + */ +class OrmSelectBuilder extends QueryBuilder +{ + /** + * @param array $params + */ + public function setRawParams(array $params): void + { + $this->params = $params; + } +} diff --git a/application/Espo/Core/Select/Primary/Applier.php b/application/Espo/Core/Select/Primary/Applier.php new file mode 100644 index 0000000..31080ba --- /dev/null +++ b/application/Espo/Core/Select/Primary/Applier.php @@ -0,0 +1,59 @@ +. + * + * 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\Select\Primary; + +use Espo\Core\Exceptions\BadRequest; +use Espo\ORM\Query\SelectBuilder; +use Espo\Entities\User; + +class Applier +{ + public function __construct( + private string $entityType, + private User $user, + private FilterFactory $primaryFilterFactory, + ) {} + + /** + * @throws BadRequest + */ + public function apply(SelectBuilder $queryBuilder, string $filterName): void + { + if ($this->primaryFilterFactory->has($this->entityType, $filterName)) { + $filter = $this->primaryFilterFactory->create($this->entityType, $this->user, $filterName); + + $filter->apply($queryBuilder); + + return; + } + + throw new BadRequest("No primary filter '$filterName' for '$this->entityType'."); + } +} diff --git a/application/Espo/Core/Select/Primary/Filter.php b/application/Espo/Core/Select/Primary/Filter.php new file mode 100644 index 0000000..f5ec87f --- /dev/null +++ b/application/Espo/Core/Select/Primary/Filter.php @@ -0,0 +1,40 @@ +. + * + * 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\Select\Primary; + +use Espo\ORM\Query\SelectBuilder; + +/** + * A primary filter. + */ +interface Filter +{ + public function apply(SelectBuilder $queryBuilder): void; +} diff --git a/application/Espo/Core/Select/Primary/FilterFactory.php b/application/Espo/Core/Select/Primary/FilterFactory.php new file mode 100644 index 0000000..5505e58 --- /dev/null +++ b/application/Espo/Core/Select/Primary/FilterFactory.php @@ -0,0 +1,118 @@ +. + * + * 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\Select\Primary; + +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; + +use RuntimeException; + +class FilterFactory +{ + public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata) + {} + + public function create(string $entityType, User $user, string $name): Filter + { + $className = $this->getClassName($entityType, $name); + + if (!$className) { + throw new RuntimeException("Primary filter '{$name}' for '{$entityType}' does not exist."); + } + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(User::class, $user) + ->for($className) + ->bindValue('$entityType', $entityType) + ->bindValue('$name', $name); + + $binder + ->for(FieldHelper::class) + ->bindValue('$entityType', $entityType); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + public function has(string $entityType, string $name): bool + { + return (bool) $this->getClassName($entityType, $name); + } + + /** + * @return ?class-string + */ + protected function getClassName(string $entityType, string $name): ?string + { + if (!$name) { + throw new RuntimeException("Empty primary filter name."); + } + + $className = $this->metadata->get( + [ + 'selectDefs', + $entityType, + 'primaryFilterClassNameMap', + $name, + ] + ); + + if ($className) { + return $className; + } + + return $this->getDefaultClassName($name); + } + + /** + * @return ?class-string + */ + protected function getDefaultClassName(string $name): ?string + { + $className = 'Espo\\Core\\Select\\Primary\\Filters\\' . ucfirst($name); + + if (!class_exists($className)) { + return null; + } + + /** @var class-string */ + return $className; + } +} diff --git a/application/Espo/Core/Select/Primary/Filters/All.php b/application/Espo/Core/Select/Primary/Filters/All.php new file mode 100644 index 0000000..5b06822 --- /dev/null +++ b/application/Espo/Core/Select/Primary/Filters/All.php @@ -0,0 +1,48 @@ +. + * + * 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\Select\Primary\Filters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * A dummy filter 'all'. Can be detected in a custom AdditionalApplier to instruct that the filter needs to be + * bypassed. Use this filter for special cases from code. Users can pass this filter too. Do not rely on this filter + * when dealing with access control logic. + * + * @since 9.2.0 + */ +class All implements Filter +{ + public const NAME = 'all'; + + public function apply(QueryBuilder $queryBuilder): void + {} +} diff --git a/application/Espo/Core/Select/Primary/Filters/Followed.php b/application/Espo/Core/Select/Primary/Filters/Followed.php new file mode 100644 index 0000000..4531526 --- /dev/null +++ b/application/Espo/Core/Select/Primary/Filters/Followed.php @@ -0,0 +1,57 @@ +. + * + * 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\Select\Primary\Filters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\StreamSubscription; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +class Followed implements Filter +{ + public function __construct(private string $entityType, private User $user) + {} + + public function apply(QueryBuilder $queryBuilder): void + { + $alias = 'subscriptionFollowedPrimaryFilter'; + + $queryBuilder->join( + StreamSubscription::ENTITY_TYPE, + $alias, + [ + $alias . '.entityType' => $this->entityType, + $alias . '.entityId=:' => Attribute::ID, + $alias . '.userId' => $this->user->getId(), + ] + ); + } +} diff --git a/application/Espo/Core/Select/Primary/Filters/One.php b/application/Espo/Core/Select/Primary/Filters/One.php new file mode 100644 index 0000000..4cd67d1 --- /dev/null +++ b/application/Espo/Core/Select/Primary/Filters/One.php @@ -0,0 +1,45 @@ +. + * + * 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\Select\Primary\Filters; + +use Espo\Core\Select\Primary\Filter; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * A dummy filter 'one'. Applied only when reading a single record (from the detail view). + * Can be detected in a custom AdditionalApplier to distinguish a read request from a find request. + */ +class One implements Filter +{ + public const NAME = 'one'; + + public function apply(QueryBuilder $queryBuilder): void + {} +} diff --git a/application/Espo/Core/Select/Primary/Filters/Starred.php b/application/Espo/Core/Select/Primary/Filters/Starred.php new file mode 100644 index 0000000..797be21 --- /dev/null +++ b/application/Espo/Core/Select/Primary/Filters/Starred.php @@ -0,0 +1,60 @@ +. + * + * 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\Select\Primary\Filters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\StarSubscription; +use Espo\Entities\User; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * @noinspection PhpUnused + */ +class Starred implements Filter +{ + public function __construct(private string $entityType, private User $user) + {} + + public function apply(QueryBuilder $queryBuilder): void + { + $alias = 'starredPrimaryFilter'; + + $queryBuilder->join( + StarSubscription::ENTITY_TYPE, + $alias, + [ + "$alias.entityType" => $this->entityType, + "$alias.entityId=:" => Attribute::ID, + "$alias.userId" => $this->user->getId(), + ] + ); + } +} diff --git a/application/Espo/Core/Select/SearchParams.php b/application/Espo/Core/Select/SearchParams.php new file mode 100644 index 0000000..01c6c03 --- /dev/null +++ b/application/Espo/Core/Select/SearchParams.php @@ -0,0 +1,539 @@ +. + * + * 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\Select; + +use Espo\Core\Select\Where\Item as WhereItem; +use Espo\Core\Utils\Json; + +use InvalidArgumentException; +use stdClass; + +/** + * Search parameters. + * + * Immutable. + */ +class SearchParams +{ + /** @var array */ + private array $rawParams = [ + 'select' => null, + 'boolFilterList' => [], + 'orderBy' => null, + 'order' => null, + 'maxSize' => null, + 'where' => null, + 'primaryFilter' => null, + 'textFilter' => null, + ]; + + public const ORDER_ASC = 'ASC'; + public const ORDER_DESC = 'DESC'; + + private function __construct() {} + + /** + * @return array + */ + public function getRaw(): array + { + return $this->rawParams; + } + + /** + * Attributes to be selected. + * + * @return ?string[] + */ + public function getSelect(): ?array + { + return $this->rawParams['select'] ?? null; + } + + /** + * An order-by field. + */ + public function getOrderBy(): ?string + { + return $this->rawParams['orderBy'] ?? null; + } + + /** + * An order direction. + * + * @return self::ORDER_ASC|self::ORDER_DESC + */ + public function getOrder(): ?string + { + return $this->rawParams['order'] ?? null; + } + + /** + * An offset. + */ + public function getOffset(): ?int + { + return $this->rawParams['offset'] ?? null; + } + + /** + * A max-size. + */ + public function getMaxSize(): ?int + { + return $this->rawParams['maxSize'] ?? null; + } + + /** + * A text filter. + */ + public function getTextFilter(): ?string + { + return $this->rawParams['textFilter'] ?? null; + } + + /** + * A primary filter. + */ + public function getPrimaryFilter(): ?string + { + return $this->rawParams['primaryFilter'] ?? null; + } + + /** + * A bool filter list. + * + * @return string[] + */ + public function getBoolFilterList(): array + { + return $this->rawParams['boolFilterList'] ?? []; + } + + /** + * A where. + */ + public function getWhere(): ?WhereItem + { + $raw = $this->rawParams['where'] ?? null; + + if ($raw === null) { + return null; + } + + return WhereItem::fromRaw([ + 'type' => 'and', + 'value' => $raw, + ]); + } + + /** + * A max text attribute length. + */ + public function getMaxTextAttributeLength(): ?int + { + return $this->rawParams['maxTextAttributeLength'] ?? null; + } + + /** + * With attributes to be selected. NULL means to select all attributes. + * + * @param string[]|null $select + */ + public function withSelect(?array $select): self + { + $obj = clone $this; + $obj->rawParams['select'] = $select; + + return $obj; + } + + /** + * With an order-by field. + */ + public function withOrderBy(?string $orderBy): self + { + $obj = clone $this; + $obj->rawParams['orderBy'] = $orderBy; + + return $obj; + } + + /** + * With an order direction. + * + * @param self::ORDER_ASC|self::ORDER_DESC|null $order + */ + public function withOrder(?string $order): self + { + $obj = clone $this; + $obj->rawParams['order'] = $order; + + if ( + $order !== null && + $order !== self::ORDER_ASC && + $order !== self::ORDER_DESC + ) { + throw new InvalidArgumentException("order value is bad."); + } + + return $obj; + } + + /** + * With an offset. + */ + public function withOffset(?int $offset): self + { + $obj = clone $this; + $obj->rawParams['offset'] = $offset; + + return $obj; + } + + /** + * With a mix size. + */ + public function withMaxSize(?int $maxSize): self + { + $obj = clone $this; + $obj->rawParams['maxSize'] = $maxSize; + + return $obj; + } + + /** + * With a text filter. + */ + public function withTextFilter(?string $filter): self + { + $obj = clone $this; + $obj->rawParams['textFilter'] = $filter; + + return $obj; + } + + /** + * With a primary filter. + * + * @param string|null $primaryFilter + */ + public function withPrimaryFilter(?string $primaryFilter): self + { + $obj = clone $this; + $obj->rawParams['primaryFilter'] = $primaryFilter; + + return $obj; + } + + /** + * With a bool filter list. Previously set bool filter will be unset. + * + * @param string[] $boolFilterList + */ + public function withBoolFilterList(array $boolFilterList): self + { + $obj = clone $this; + $obj->rawParams['boolFilterList'] = $boolFilterList; + + return $obj; + } + + public function withBoolFilterAdded(string $boolFilter): self + { + $obj = clone $this; + $obj->rawParams['boolFilterList'] ??= []; + $obj->rawParams['boolFilterList'][] = $boolFilter; + + return $obj; + } + + /** + * With a where. The previously set where will be unset. + */ + public function withWhere(WhereItem $where): self + { + $obj = clone $this; + + if ($where->getType() === WhereItem\Type::AND) { + $obj->rawParams['where'] = $where->getValue() ?? []; + + return $obj; + } + + $obj->rawParams['where'] = [$where->getRaw()]; + + return $obj; + } + + /** + * With a where added. + */ + public function withWhereAdded(WhereItem $whereItem): self + { + $obj = clone $this; + + $rawWhere = $obj->rawParams['where'] ?? []; + + $rawWhere[] = $whereItem->getRaw(); + + $obj->rawParams['where'] = $rawWhere; + + return $obj; + } + + /** + * With max text attribute length (long texts will be cut to avoid fetching too much data). + */ + public function withMaxTextAttributeLength(?int $value): self + { + $obj = clone $this; + + $obj->rawParams['maxTextAttributeLength'] = $value; + + return $obj; + } + + /** + * Create an empty instance. + */ + public static function create(): self + { + return new self(); + } + + /** + * Create an instance from a raw. + * + * @param stdClass|array $params + */ + public static function fromRaw($params): self + { + if (!is_array($params) && !$params instanceof stdClass) { + throw new InvalidArgumentException(); + } + + if ($params instanceof stdClass) { + $params = json_decode(Json::encode($params), true); + } + + $object = new self(); + + $rawParams = []; + + $select = $params['select'] ?? null; + $orderBy = $params['orderBy'] ?? null; + $order = $params['order'] ?? null; + + $offset = $params['offset'] ?? null; + $maxSize = $params['maxSize'] ?? null; + + // For bc. + if (is_string($offset) && is_numeric($offset)) { + $offset = (int) $offset; + } + + // For bc. + if (is_string($maxSize) && is_numeric($maxSize)) { + $maxSize = (int) $maxSize; + } + + $boolFilterList = $params['boolFilterList'] ?? []; + $primaryFilter = $params['primaryFilter'] ?? null; + $textFilter = $params['textFilter'] ?? $params['q'] ?? null; + + $where = $params['where'] ?? null; + + $maxTextAttributeLength = $params['maxTextAttributeLength'] ?? null; + + if ($select !== null && !is_array($select)) { + throw new InvalidArgumentException("select should be array."); + } + + if (is_array($select)) { + foreach ($select as $item) { + if (!is_string($item)) { + throw new InvalidArgumentException("select has non-string item."); + } + } + } + + if ($orderBy !== null && !is_string($orderBy)) { + throw new InvalidArgumentException("orderBy should be string."); + } + + if ($order !== null && !is_string($order)) { + throw new InvalidArgumentException("order should be string."); + } + + if (!is_array($boolFilterList)) { + throw new InvalidArgumentException("boolFilterList should be array."); + } + + foreach ($boolFilterList as $item) { + if (!is_string($item)) { + throw new InvalidArgumentException("boolFilterList has non-string item."); + } + } + + if ($primaryFilter !== null && !is_string($primaryFilter)) { + throw new InvalidArgumentException("primaryFilter should be string."); + } + + if ($textFilter !== null && !is_string($textFilter)) { + throw new InvalidArgumentException("textFilter should be string."); + } + + if ($where !== null && !is_array($where)) { + throw new InvalidArgumentException("where should be array."); + } + + if ($offset !== null && !is_int($offset)) { + throw new InvalidArgumentException("offset should be int."); + } + + if ($maxSize !== null && !is_int($maxSize)) { + throw new InvalidArgumentException("maxSize should be int."); + } + + if ($maxTextAttributeLength && !is_int($maxTextAttributeLength)) { + throw new InvalidArgumentException("maxTextAttributeLength should be int."); + } + + if ($order) { + $order = strtoupper($order); + + if ($order !== self::ORDER_ASC && $order !== self::ORDER_DESC) { + throw new InvalidArgumentException("order value is bad."); + } + } + + $rawParams['select'] = $select; + $rawParams['orderBy'] = $orderBy; + $rawParams['order'] = $order; + $rawParams['offset'] = $offset; + $rawParams['maxSize'] = $maxSize; + $rawParams['boolFilterList'] = $boolFilterList; + $rawParams['primaryFilter'] = $primaryFilter; + $rawParams['textFilter'] = $textFilter; + $rawParams['where'] = $where; + $rawParams['maxTextAttributeLength'] = $maxTextAttributeLength; + + if ($where) { + $object->adjustParams($rawParams); + } + + $object->rawParams = $rawParams; + + return $object; + } + + /** + * Merge two SelectParams instances. + */ + public static function merge(self $searchParams1, self $searchParams2): self + { + $paramList = [ + 'select', + 'orderBy', + 'order', + 'maxSize', + 'primaryFilter', + 'textFilter', + ]; + + $params = $searchParams2->getRaw(); + + $leftParams = $searchParams1->getRaw(); + + foreach ($paramList as $name) { + if (!is_null($leftParams[$name])) { + $params[$name] = $leftParams[$name]; + } + } + + foreach ($leftParams['boolFilterList'] as $item) { + if (in_array($item, $params['boolFilterList'])) { + continue; + } + + $params['boolFilterList'][] = $item; + } + + $params['where'] = $params['where'] ?? []; + + if (!is_null($leftParams['where'])) { + foreach ($leftParams['where'] as $item) { + $params['where'][] = $item; + } + } + + if (count($params['where']) === 0) { + $params['where'] = null; + } + + return self::fromRaw($params); + } + + /** + * For compatibility with the legacy definition. + * + * @param array $params + */ + private function adjustParams(array &$params): void + { + if (!$params['where']) { + return; + } + + $where = $params['where']; + + foreach ($where as $i => $item) { + $type = $item['type'] ?? null; + $value = $item['value'] ?? null; + + if ($type == 'bool' && !empty($value) && is_array($value)) { + $params['boolFilterList'] = $value; + + unset($where[$i]); + } else if ($type === 'textFilter') { + $params['textFilter'] = $value; + + unset($where[$i]); + } else if ($type == 'primary' && $value) { + $params['primaryFilter'] = $value; + + unset($where[$i]); + } + } + + $params['where'] = array_values($where); + } +} diff --git a/application/Espo/Core/Select/Select/Applier.php b/application/Espo/Core/Select/Select/Applier.php new file mode 100644 index 0000000..7208a77 --- /dev/null +++ b/application/Espo/Core/Select/Select/Applier.php @@ -0,0 +1,206 @@ +. + * + * 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\Select\Select; + +use Espo\Core\Select\SearchParams; +use Espo\Core\Utils\FieldUtil; + +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; + +class Applier +{ + /** @var string[] */ + private $aclAttributeList = [ + 'assignedUserId', + 'createdById', + ]; + + /** @var string[] */ + private $aclPortalAttributeList = [ + 'assignedUserId', + 'createdById', + 'contactId', + 'accountId', + ]; + + public function __construct( + private string $entityType, + private User $user, + private FieldUtil $fieldUtil, + private MetadataProvider $metadataProvider + ) {} + + public function apply(SelectBuilder $queryBuilder, SearchParams $searchParams): void + { + $attributeList = $this->getSelectAttributeList($searchParams); + + if ($attributeList) { + $queryBuilder->select( + $this->prepareAttributeList($attributeList, $searchParams) + ); + } + } + + /** + * @param string[] $attributeList + * @return array + */ + private function prepareAttributeList(array $attributeList, SearchParams $searchParams): array + { + $limit = $searchParams->getMaxTextAttributeLength(); + + if ($limit === null) { + return $attributeList; + } + + $resultList = []; + + foreach ($attributeList as $item) { + if ( + $this->metadataProvider->hasAttribute($this->entityType, $item) && + $this->metadataProvider->getAttributeType($this->entityType, $item) === Entity::TEXT && + !$this->metadataProvider->isAttributeNotStorable($this->entityType, $item) + ) { + $resultList[] = [ + "LEFT:($item, $limit)", + $item + ]; + + continue; + } + + $resultList[] = $item; + } + + return $resultList; + } + + /** + * @return ?string[] + */ + private function getSelectAttributeList(SearchParams $searchParams): ?array + { + $passedAttributeList = $searchParams->getSelect(); + + if (!$passedAttributeList) { + return null; + } + + if ($passedAttributeList === ['*']) { + return ['*']; + } + + $attributeList = []; + + if (!in_array(Attribute::ID, $passedAttributeList)) { + $attributeList[] = Attribute::ID; + } + + foreach ($this->getAclAttributeList() as $attribute) { + if (in_array($attribute, $passedAttributeList)) { + continue; + } + + if (!$this->metadataProvider->hasAttribute($this->entityType, $attribute)) { + continue; + } + + $attributeList[] = $attribute; + } + + foreach ($passedAttributeList as $attribute) { + if (in_array($attribute, $attributeList)) { + continue; + } + + if (!$this->metadataProvider->hasAttribute($this->entityType, $attribute)) { + continue; + } + + $attributeList[] = $attribute; + } + + $orderByField = $searchParams->getOrderBy() ?? $this->metadataProvider->getDefaultOrderBy($this->entityType); + + if ($orderByField) { + $sortByAttributeList = $this->fieldUtil->getAttributeList($this->entityType, $orderByField); + + foreach ($sortByAttributeList as $attribute) { + if (in_array($attribute, $attributeList)) { + continue; + } + + if (!$this->metadataProvider->hasAttribute($this->entityType, $attribute)) { + continue; + } + + $attributeList[] = $attribute; + } + } + + $selectAttributesDependencyMap = + $this->metadataProvider->getSelectAttributesDependencyMap($this->entityType) ?? []; + + foreach ($selectAttributesDependencyMap as $attribute => $dependantAttributeList) { + if (!in_array($attribute, $attributeList)) { + continue; + } + + foreach ($dependantAttributeList as $dependantAttribute) { + if (in_array($dependantAttribute, $attributeList)) { + continue; + } + + $attributeList[] = $dependantAttribute; + } + } + + return $attributeList; + } + + /** + * @return string[] + */ + private function getAclAttributeList(): array + { + if ($this->user->isPortal()) { + return + $this->metadataProvider->getAclPortalAttributeList($this->entityType) ?? + $this->aclPortalAttributeList; + } + + return + $this->metadataProvider->getAclAttributeList($this->entityType) ?? + $this->aclAttributeList; + } +} diff --git a/application/Espo/Core/Select/Select/MetadataProvider.php b/application/Espo/Core/Select/Select/MetadataProvider.php new file mode 100644 index 0000000..e8843fd --- /dev/null +++ b/application/Espo/Core/Select/Select/MetadataProvider.php @@ -0,0 +1,105 @@ +. + * + * 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\Select\Select; + +use Espo\Core\Utils\Metadata; +use Espo\ORM\EntityManager; + +class MetadataProvider +{ + public function __construct(private Metadata $metadata, private EntityManager $entityManager) + {} + + public function getDefaultOrderBy(string $entityType): ?string + { + return $this->metadata->get([ + 'entityDefs', $entityType, 'collection', 'orderBy' + ]) ?? null; + } + + /** + * @return ?array + */ + public function getSelectAttributesDependencyMap(string $entityType): ?array + { + return $this->metadata->get([ + 'selectDefs', $entityType, 'selectAttributesDependencyMap' + ]) ?? null; + } + + /** + * @return ?string[] + */ + public function getAclPortalAttributeList(string $entityType): ?array + { + return $this->metadata->get([ + 'selectDefs', $entityType, 'aclPortalAttributeList' + ]) ?? null; + } + + /** + * @return ?string[] + */ + public function getAclAttributeList(string $entityType): ?array + { + return $this->metadata->get([ + 'selectDefs', $entityType, 'aclAttributeList' + ]) ?? null; + } + + public function hasAttribute(string $entityType, string $attribute): bool + { + return $this->entityManager + ->getMetadata() + ->getDefs() + ->getEntity($entityType) + ->hasAttribute($attribute); + } + + public function isAttributeNotStorable(string $entityType, string $attribute): bool + { + return $this->entityManager + ->getMetadata() + ->getDefs() + ->getEntity($entityType) + ->getAttribute($attribute) + ->isNotStorable(); + } + + public function getAttributeType(string $entityType, string $attribute): string + { + return $this->entityManager + ->getMetadata() + ->getDefs() + ->getEntity($entityType) + ->getAttribute($attribute) + ->getType(); + } +} diff --git a/application/Espo/Core/Select/SelectBuilder.php b/application/Espo/Core/Select/SelectBuilder.php new file mode 100644 index 0000000..9f05c21 --- /dev/null +++ b/application/Espo/Core/Select/SelectBuilder.php @@ -0,0 +1,579 @@ +. + * + * 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\Select; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Select\Applier\Factory as ApplierFactory; +use Espo\Core\Select\Where\Params as WhereParams; +use Espo\Core\Select\Where\Item as WhereItem; +use Espo\Core\Select\Order\Params as OrderParams; +use Espo\Core\Select\Text\FilterParams as TextFilterParams; +use Espo\Core\Select\Where\Applier as WhereApplier; +use Espo\Core\Select\Select\Applier as SelectApplier; +use Espo\Core\Select\Order\Applier as OrderApplier; +use Espo\Core\Select\AccessControl\Applier as AccessControlFilterApplier; +use Espo\Core\Select\Primary\Applier as PrimaryFilterApplier; +use Espo\Core\Select\Bool\Applier as BoolFilterListApplier; +use Espo\Core\Select\Text\Applier as TextFilterApplier; +use Espo\Core\Select\Applier\Appliers\Limit as LimitApplier; +use Espo\Core\Select\Applier\Appliers\Additional as AdditionalApplier; + +use Espo\ORM\Query\Select as Query; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +use Espo\Entities\User; + +use LogicException; + +/** + * Builds select queries for ORM. Applies search parameters(passed from front-end), + * ACL restrictions, filters, etc. + */ +class SelectBuilder +{ + private ?string $entityType = null; + private ?OrmSelectBuilder $queryBuilder = null; + private ?Query $sourceQuery = null; + private ?SearchParams $searchParams = null; + private bool $applyAccessControlFilter = false; + private bool $applyDefaultOrder = false; + private ?string $textFilter = null; + private ?string $primaryFilter = null; + /** @var string[] */ + private array $boolFilterList = []; + /** @var WhereItem[] */ + private array $whereItemList = []; + private bool $applyWherePermissionCheck = false; + private bool $applyComplexExpressionsForbidden = false; + /** @var class-string[] */ + private array $additionalApplierClassNameList = []; + + public function __construct( + private User $user, + private ApplierFactory $applierFactory + ) {} + + /** + * Specify an entity type to select from. + */ + public function from(string $entityType): self + { + if ($this->sourceQuery) { + throw new LogicException("Can't call 'from' after 'clone'."); + } + + $this->entityType = $entityType; + + return $this; + } + + /** + * Start building from an existing select query. + */ + public function clone(Query $query): self + { + if ($this->entityType && $this->entityType !== $query->getFrom()) { + throw new LogicException("Not matching entity type."); + } + + $this->entityType = $query->getFrom(); + $this->sourceQuery = $query; + + return $this; + } + + /** + * Build a result query. + * + * @throws Forbidden + * @throws BadRequest + */ + public function build(): Query + { + return $this->buildQueryBuilder()->build(); + } + + /** + * Build an ORM query builder. Used to continue building but by means of ORM. + * + * @throws Forbidden + * @throws BadRequest + */ + public function buildQueryBuilder(): QueryBuilder + { + $this->queryBuilder = new OrmSelectBuilder(); + + if (!$this->entityType) { + throw new LogicException("No entity type."); + } + + if ($this->sourceQuery) { + $this->queryBuilder->clone($this->sourceQuery); + } else { + $this->queryBuilder->from($this->entityType); + } + + $this->applyFromSearchParams(); + + if (count($this->whereItemList)) { + $this->applyWhereItemList(); + } + + if ($this->applyDefaultOrder) { + $this->applyDefaultOrder(); + } + + if ($this->primaryFilter) { + $this->applyPrimaryFilter(); + } + + if (count($this->boolFilterList)) { + $this->applyBoolFilterList(); + } + + if ($this->textFilter) { + $this->applyTextFilter(); + } + + if ($this->applyAccessControlFilter) { + $this->applyAccessControlFilter(); + } + + $this->applyAdditional(); + + return $this->queryBuilder ?? throw new LogicException(); + } + + /** + * Switch a user for whom a select query will be built. + */ + public function forUser(User $user): self + { + $this->user = $user; + + return $this; + } + + /** + * Apply search parameters. + * + * Note: If there's no order set in the search parameters then a default order will be applied. + */ + public function withSearchParams(SearchParams $searchParams): self + { + $this->searchParams = $searchParams; + + $this->withBoolFilterList( + $searchParams->getBoolFilterList() + ); + + $primaryFilter = $searchParams->getPrimaryFilter(); + + if ($primaryFilter) { + $this->withPrimaryFilter($primaryFilter); + } + + $textFilter = $searchParams->getTextFilter(); + + if ($textFilter !== null) { + $this->withTextFilter($textFilter); + } + + return $this; + } + + /** + * Apply maximum restrictions for a user. + */ + public function withStrictAccessControl(): self + { + $this->withAccessControlFilter(); + $this->withWherePermissionCheck(); + $this->withComplexExpressionsForbidden(); + + return $this; + } + + /** + * Apply an access control filter. + */ + public function withAccessControlFilter(): self + { + $this->applyAccessControlFilter = true; + + return $this; + } + + /** + * Apply a default order. + */ + public function withDefaultOrder(): self + { + $this->applyDefaultOrder = true; + + return $this; + } + + /** + * Check permissions to where items. + */ + public function withWherePermissionCheck(): self + { + $this->applyWherePermissionCheck = true; + + return $this; + } + + /** + * Forbid complex expression usage. + */ + public function withComplexExpressionsForbidden(): self + { + $this->applyComplexExpressionsForbidden = true; + + return $this; + } + + /** + * Apply a text filter. + */ + public function withTextFilter(string $textFilter): self + { + $this->textFilter = $textFilter; + + return $this; + } + + /** + * Apply a primary filter. + */ + public function withPrimaryFilter(string $primaryFilter): self + { + $this->primaryFilter = $primaryFilter; + + return $this; + } + + /** + * Apply a bool filter. + */ + public function withBoolFilter(string $boolFilter): self + { + $this->boolFilterList[] = $boolFilter; + + return $this; + } + + /** + * Apply a list of bool filters. + * + * @param string[] $boolFilterList + */ + public function withBoolFilterList(array $boolFilterList): self + { + $this->boolFilterList = array_merge($this->boolFilterList, $boolFilterList); + + return $this; + } + + /** + * Apply a Where Item. + */ + public function withWhere(WhereItem $whereItem): self + { + $this->whereItemList[] = $whereItem; + + return $this; + } + + /** + * Apply a list of additional applier class names. + * + * @param class-string[] $additionalApplierClassNameList + */ + public function withAdditionalApplierClassNameList(array $additionalApplierClassNameList): self + { + $this->additionalApplierClassNameList = array_merge( + $this->additionalApplierClassNameList, + $additionalApplierClassNameList + ); + + return $this; + } + + /** + * @throws BadRequest + */ + private function applyPrimaryFilter(): void + { + assert($this->queryBuilder !== null); + assert($this->primaryFilter !== null); + + $this->createPrimaryFilterApplier() + ->apply( + $this->queryBuilder, + $this->primaryFilter + ); + } + + /** + * @throws BadRequest + */ + private function applyBoolFilterList(): void + { + assert($this->queryBuilder !== null); + + $this->createBoolFilterListApplier() + ->apply( + $this->queryBuilder, + $this->boolFilterList + ); + } + + private function applyTextFilter(): void + { + assert($this->queryBuilder !== null); + assert($this->textFilter !== null); + + $this->createTextFilterApplier() + ->apply( + $this->queryBuilder, + $this->textFilter, + TextFilterParams::create() + ); + } + + private function applyAccessControlFilter(): void + { + assert($this->queryBuilder !== null); + + $this->createAccessControlFilterApplier() + ->apply( + $this->queryBuilder + ); + } + + /** + * @throws Forbidden + * @throws BadRequest + */ + private function applyDefaultOrder(): void + { + assert($this->queryBuilder !== null); + + $order = $this->searchParams?->getOrder(); + + $params = OrderParams::fromAssoc([ + 'forceDefault' => true, + 'order' => $order, + ]); + + $this->createOrderApplier() + ->apply( + $this->queryBuilder, + $params + ); + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function applyWhereItemList(): void + { + foreach ($this->whereItemList as $whereItem) { + $this->applyWhereItem($whereItem); + } + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function applyWhereItem(WhereItem $whereItem): void + { + assert($this->queryBuilder !== null); + + $params = WhereParams::fromAssoc([ + 'applyPermissionCheck' => $this->applyWherePermissionCheck, + 'forbidComplexExpressions' => $this->applyComplexExpressionsForbidden, + ]); + + $this->createWhereApplier() + ->apply( + $this->queryBuilder, + $whereItem, + $params + ); + } + + /** + * @throws Forbidden + * @throws BadRequest + */ + private function applyFromSearchParams(): void + { + if (!$this->searchParams) { + return; + } + + assert($this->queryBuilder !== null); + + if ( + !$this->applyDefaultOrder && + ($this->searchParams->getOrderBy() || $this->searchParams->getOrder()) + ) { + $params = OrderParams::fromAssoc([ + 'orderBy' => $this->searchParams->getOrderBy(), + 'order' => $this->searchParams->getOrder(), + 'applyPermissionCheck' => $this->applyWherePermissionCheck, + ]); + + $this->createOrderApplier() + ->apply( + $this->queryBuilder, + $params + ); + } + + if (!$this->searchParams->getOrderBy() && !$this->searchParams->getOrder()) { + $this->withDefaultOrder(); + } + + if ($this->searchParams->getMaxSize() !== null || $this->searchParams->getOffset() !== null) { + $this->createLimitApplier() + ->apply( + $this->queryBuilder, + $this->searchParams->getOffset(), + $this->searchParams->getMaxSize() + ); + } + + if ($this->searchParams->getSelect()) { + $this->createSelectApplier() + ->apply( + $this->queryBuilder, + $this->searchParams + ); + } + + if ($this->searchParams->getWhere()) { + $this->whereItemList[] = $this->searchParams->getWhere(); + } + } + + private function applyAdditional(): void + { + assert($this->queryBuilder !== null); + + $searchParams = SearchParams::create() + ->withBoolFilterList($this->boolFilterList) + ->withPrimaryFilter($this->primaryFilter) + ->withTextFilter($this->textFilter); + + if ($this->searchParams) { + $searchParams = SearchParams::merge($searchParams, $this->searchParams); + } + + foreach ($this->whereItemList as $it) { + $searchParams = $searchParams->withWhereAdded($it); + } + + $this->createAdditionalApplier()->apply( + $this->additionalApplierClassNameList, + $this->queryBuilder, + $searchParams + ); + } + + private function createWhereApplier(): WhereApplier + { + assert($this->entityType !== null); + + return $this->applierFactory->createWhere($this->entityType, $this->user); + } + + private function createSelectApplier(): SelectApplier + { + assert($this->entityType !== null); + + return $this->applierFactory->createSelect($this->entityType, $this->user); + } + + private function createOrderApplier(): OrderApplier + { + assert($this->entityType !== null); + + return $this->applierFactory->createOrder($this->entityType, $this->user); + } + + private function createLimitApplier(): LimitApplier + { + assert($this->entityType !== null); + + return $this->applierFactory->createLimit($this->entityType, $this->user); + } + + private function createAccessControlFilterApplier(): AccessControlFilterApplier + { + assert($this->entityType !== null); + + return $this->applierFactory->createAccessControlFilter($this->entityType, $this->user); + } + + private function createTextFilterApplier(): TextFilterApplier + { + assert($this->entityType !== null); + + return $this->applierFactory->createTextFilter($this->entityType, $this->user); + } + + private function createPrimaryFilterApplier(): PrimaryFilterApplier + { + assert($this->entityType !== null); + + return $this->applierFactory->createPrimaryFilter($this->entityType, $this->user); + } + + private function createBoolFilterListApplier(): BoolFilterListApplier + { + assert($this->entityType !== null); + + return $this->applierFactory->createBoolFilterList($this->entityType, $this->user); + } + + private function createAdditionalApplier(): AdditionalApplier + { + assert($this->entityType !== null); + + return $this->applierFactory->createAdditional($this->entityType, $this->user); + } +} diff --git a/application/Espo/Core/Select/SelectBuilderFactory.php b/application/Espo/Core/Select/SelectBuilderFactory.php new file mode 100644 index 0000000..ee73ee6 --- /dev/null +++ b/application/Espo/Core/Select/SelectBuilderFactory.php @@ -0,0 +1,55 @@ +. + * + * 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\Select; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\InjectableFactory; +use Espo\Entities\User; + +/** + * Creates instances of Select Builder. + */ +class SelectBuilderFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private User $user, + ) {} + + public function create(): SelectBuilder + { + return $this->injectableFactory->createWithBinding( + SelectBuilder::class, + BindingContainerBuilder::create() + ->bindInstance(User::class, $this->user) + ->build() + ); + } +} diff --git a/application/Espo/Core/Select/Text/Applier.php b/application/Espo/Core/Select/Text/Applier.php new file mode 100644 index 0000000..eb1c5a6 --- /dev/null +++ b/application/Espo/Core/Select/Text/Applier.php @@ -0,0 +1,214 @@ +. + * + * 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\Select\Text; + +use Espo\Core\Select\Text\FullTextSearch\Data as FullTextSearchData; +use Espo\Core\Select\Text\FullTextSearch\DataComposerFactory as FullTextSearchDataComposerFactory; +use Espo\Core\Select\Text\FullTextSearch\DataComposer\Params as FullTextSearchDataComposerParams; +use Espo\Core\Select\Text\Filter\Data as FilterData; +use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Query\Part\Order as OrderExpr; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\Part\WhereItem; +use Espo\Entities\User; + +class Applier +{ + /** @todo Move to metadata. */ + private ?int $fullTextRelevanceThreshold = null; /** @phpstan-ignore-line */ + /** @todo Move to metadata. */ + private int $fullTextOrderRelevanceDivider = 5; /** @phpstan-ignore-line */ + + private const DEFAULT_FT_ORDER = self::FT_ORDER_COMBINED; + private const DEFAULT_ATTRIBUTE_LIST = ['name']; + + private const FT_ORDER_COMBINED = 0; + private const FT_ORDER_RELEVANCE = 1; + private const FT_ORDER_ORIGINAL = 3; + + public function __construct( + private string $entityType, + private User $user, + private MetadataProvider $metadataProvider, + private FullTextSearchDataComposerFactory $fullTextSearchDataComposerFactory, + private FilterFactory $filterFactory, + private ConfigProvider $config + ) {} + + /** + * @noinspection PhpUnusedParameterInspection + */ + public function apply(SelectBuilder $queryBuilder, string $filter, FilterParams $params): void + { + $forceFullText = false; + $skipFullText = false; + + if (mb_strpos($filter, 'ft:') === 0) { + $filter = mb_substr($filter, 3); + $forceFullText = true; + } + + $fullTextData = $this->composeFullTextSearchData($filter); + + if ($fullTextData && !$forceFullText && $this->toSkipFullText($filter)) { + $skipFullText = true; + } + + $fullTextWhere = $fullTextData && !$skipFullText ? + $this->processFullTextSearch($queryBuilder, $fullTextData) : null; + + $fieldList = $this->getFieldList($forceFullText, $fullTextData); + $filterData = $this->prepareFilterData($filter, $fieldList, $forceFullText, $fullTextWhere); + + $this->applyFilter($queryBuilder, $filterData); + } + + private function composeFullTextSearchData(string $filter): ?FullTextSearchData + { + $composer = $this->fullTextSearchDataComposerFactory->create($this->entityType); + + $params = FullTextSearchDataComposerParams::create(); + + return $composer->compose($filter, $params); + } + + private function processFullTextSearch(SelectBuilder $queryBuilder, FullTextSearchData $data): WhereItem + { + $expression = $data->getExpression(); + + $orderType = self::DEFAULT_FT_ORDER; + + $orderTypeMap = [ + 'combined' => self::FT_ORDER_COMBINED, + 'relevance' => self::FT_ORDER_RELEVANCE, + 'original' => self::FT_ORDER_ORIGINAL, + ]; + + $mOrderType = $this->metadataProvider->getFullTextSearchOrderType($this->entityType); + + if ($mOrderType) { + $orderType = $orderTypeMap[$mOrderType]; + } + + $previousOrderBy = $queryBuilder->build()->getOrder(); + + $hasOrderBy = !empty($previousOrderBy); + + if (!$hasOrderBy || $orderType === self::FT_ORDER_RELEVANCE) { + $queryBuilder->order([ + OrderExpr::create($expression)->withDesc() + ]); + } else if ($orderType === self::FT_ORDER_COMBINED) { + $orderExpression = + Expr::round( + Expr::divide($expression, $this->fullTextOrderRelevanceDivider) + ); + + $newOrderBy = array_merge( + [OrderExpr::create($orderExpression)->withDesc()], + $previousOrderBy + ); + + $queryBuilder->order($newOrderBy); + } + + if ($this->fullTextRelevanceThreshold) { + return Expr::greaterOrEqual( + $expression, + $this->fullTextRelevanceThreshold + ); + } + + return Expr::notEqual($expression, 0); + } + + private function toSkipFullText(string $filter): bool + { + $min = $this->config->getFullTextSearchMinLength(); + + if ($min === null || strlen($filter) >= $min) { + return false; + } + + return + !str_contains($filter, '*') && + !str_contains($filter, '"') && + !str_contains($filter, '+') && + !str_contains($filter, '-'); + } + + /** + * @return string[] + */ + private function getFieldList(bool $forceFullTextSearch, ?FullTextSearchData $fullTextData): array + { + if ($forceFullTextSearch) { + return []; + } + + $fullTextFieldList = $fullTextData ? $fullTextData->getFieldList() : []; + + return array_filter( + $this->metadataProvider->getTextFilterAttributeList($this->entityType) ?? self::DEFAULT_ATTRIBUTE_LIST, + fn ($field) => !in_array($field, $fullTextFieldList) + ); + } + + /** + * @param string[] $fieldList + * @param ?WhereItem $fullTextWhere + */ + private function prepareFilterData( + string $filter, + array $fieldList, + bool $forceFullTextSearch, + ?WhereItem $fullTextWhere + ): FilterData { + + $skipWildcards = false; + + if (mb_strpos($filter, '*') !== false) { + $skipWildcards = true; + $filter = str_replace('*', '%', $filter); + } + + return FilterData::create($filter, $fieldList) + ->withSkipWildcards($skipWildcards) + ->withForceFullTextSearch($forceFullTextSearch) + ->withFullTextSearchWhereItem($fullTextWhere); + } + + private function applyFilter(SelectBuilder $queryBuilder, FilterData $filterData): void + { + $filterObj = $this->filterFactory->create($this->entityType, $this->user); + + $filterObj->apply($queryBuilder, $filterData); + } +} diff --git a/application/Espo/Core/Select/Text/ConfigProvider.php b/application/Espo/Core/Select/Text/ConfigProvider.php new file mode 100644 index 0000000..23943c9 --- /dev/null +++ b/application/Espo/Core/Select/Text/ConfigProvider.php @@ -0,0 +1,90 @@ +. + * + * 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\Select\Text; + +use Espo\Core\Utils\Config; +use libphonenumber\PhoneNumberUtil; + +class ConfigProvider +{ + private const MIN_LENGTH_FOR_CONTENT_SEARCH = 4; + + public function __construct(private Config $config) + {} + + /** + * Full-text search min indexed word length. + * + * @internal Do not use. + * @since 8.3.0 + */ + public function getFullTextSearchMinLength(): ?int + { + return $this->config->get('fullTextSearchMinLength'); + } + + public function getMinLengthForContentSearch(): int + { + return $this->config->get('textFilterContainsMinLength') ?? + self::MIN_LENGTH_FOR_CONTENT_SEARCH; + } + + public function useContainsForVarchar(): bool + { + return $this->config->get('textFilterUseContainsForVarchar') ?? false; + } + + public function usePhoneNumberNumericSearch(): bool + { + return $this->config->get('phoneNumberNumericSearch') ?? false; + } + + public function isPhoneNumberInternational(): bool + { + return $this->config->get('phoneNumberInternational') ?? false; + } + + /** + * @return int[] + * @since 10.0.8 + */ + public function getPreferredPhoneNumberCountryCodes(): array + { + $regionCodes = $this->config->get('phoneNumberPreferredCountryList') ?? []; + + $codes = array_map(function ($code) { + return PhoneNumberUtil::getInstance()->getCountryCodeForRegion($code); + }, $regionCodes); + + $codes = array_filter($codes, fn ($it) => $it !== 0); + + return array_values($codes); + } +} diff --git a/application/Espo/Core/Select/Text/DefaultFilter.php b/application/Espo/Core/Select/Text/DefaultFilter.php new file mode 100644 index 0000000..ff72d2c --- /dev/null +++ b/application/Espo/Core/Select/Text/DefaultFilter.php @@ -0,0 +1,237 @@ +. + * + * 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\Select\Text; + +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Select\Text\Filter\Data; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\Part\Where\Comparison as Cmp; +use Espo\ORM\Query\Part\Expression as Expr; + +use Espo\ORM\Entity; +use RuntimeException; + +class DefaultFilter implements Filter +{ + public function __construct( + private string $entityType, + private MetadataProvider $metadataProvider, + private ConfigProvider $config, + ) {} + + public function apply(SelectBuilder $queryBuilder, Data $data): void + { + $orGroupBuilder = OrGroup::createBuilder(); + + foreach ($data->getAttributeList() as $attribute) { + $this->applyAttribute($queryBuilder, $orGroupBuilder, $attribute, $data); + } + + if ($data->getFullTextSearchWhereItem()) { + $orGroupBuilder->add( + $data->getFullTextSearchWhereItem() + ); + } + + $orGroup = $orGroupBuilder->build(); + + if ($orGroup->getItemCount() === 0) { + $queryBuilder->where([Attribute::ID => null]); + + return; + } + + $queryBuilder->where($orGroup); + } + + /** + * @todo AttributeFilterFactory. + */ + private function applyAttribute( + SelectBuilder $queryBuilder, + OrGroupBuilder $orGroupBuilder, + string $attribute, + Data $data + ): void { + + $filter = $data->getFilter(); + $skipWildcards = $data->skipWildcards(); + + $attributeType = $this->getAttributeTypeAndApplyJoin($queryBuilder, $attribute); + + if ($attributeType === Entity::INT) { + if (is_numeric($filter)) { + $orGroupBuilder->add( + Cmp::equal( + Expr::column($attribute), + intval($filter) + ) + ); + } + + return; + } + + if ( + !str_contains($attribute, '.') && + $this->metadataProvider->getFieldType($this->entityType, $attribute) === FieldType::EMAIL && + str_contains($filter, ' ') + ) { + return; + } + + if ( + !str_contains($attribute, '.') && + $this->metadataProvider->getFieldType($this->entityType, $attribute) === FieldType::PHONE + ) { + if (!preg_match("#[0-9()\-+% ]+$#", $filter)) { + return; + } + + $hasPlus = str_starts_with($filter, '+'); + + if ($this->config->usePhoneNumberNumericSearch()) { + $attribute = $attribute . 'Numeric'; + + $filter = preg_replace('/[^0-9%]/', '', $filter); + } + + if (!$filter) { + return; + } + + if ( + $this->config->usePhoneNumberNumericSearch() && + $this->config->isPhoneNumberInternational() && + !$skipWildcards + ) { + $expression = $filter . '%'; + + $orGroupBuilder->add( + Cmp::like(Expr::column($attribute), $expression) + ); + + if (!$hasPlus) { + $preferredCodes = $this->config->getPreferredPhoneNumberCountryCodes(); + + foreach ($preferredCodes as $code) { + $orGroupBuilder->add( + Cmp::like(Expr::column($attribute), $code . $expression) + ); + } + } + + return; + } + } + + $expression = $filter; + + if (!$skipWildcards) { + $expression = $this->checkWhetherToUseContains($attribute, $filter, $attributeType) ? + '%' . $filter . '%' : + $filter . '%'; + } + + $expression = addslashes($expression); + + $orGroupBuilder->add( + Cmp::like( + Expr::column($attribute), + $expression + ) + ); + } + + private function getAttributeTypeAndApplyJoin(SelectBuilder $queryBuilder, string $attribute): string + { + if (str_contains($attribute, '.')) { + [$link, $foreignField] = explode('.', $attribute); + + $foreignEntityType = $this->metadataProvider->getRelationEntityType($this->entityType, $link); + + if (!$foreignEntityType) { + throw new RuntimeException("Bad relation in text filter field '$attribute'."); + } + + if ($this->metadataProvider->getRelationType($this->entityType, $link) === Entity::HAS_MANY) { + $queryBuilder->distinct(); + } + + $queryBuilder->leftJoin($link); + + return $this->metadataProvider->getAttributeType($foreignEntityType, $foreignField); + } + + $attributeType = $this->metadataProvider->getAttributeType($this->entityType, $attribute); + + if ($attributeType === Entity::FOREIGN) { + $link = $this->metadataProvider->getAttributeRelationParam($this->entityType, $attribute); + + if ($link) { + $queryBuilder->leftJoin($link); + } + } + + return $attributeType; + } + + private function checkWhetherToUseContains(string $attribute, string $filter, string $attributeType): bool + { + if (mb_strlen($filter) < $this->config->getMinLengthForContentSearch()) { + return false; + } + + if ($attributeType === Entity::TEXT) { + return true; + } + + if ( + in_array( + $attribute, + $this->metadataProvider->getUseContainsAttributeList($this->entityType) + ) + ) { + return true; + } + + if ( + $attributeType === Entity::VARCHAR && + $this->config->useContainsForVarchar() + ) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Core/Select/Text/Filter.php b/application/Espo/Core/Select/Text/Filter.php new file mode 100644 index 0000000..a444118 --- /dev/null +++ b/application/Espo/Core/Select/Text/Filter.php @@ -0,0 +1,39 @@ +. + * + * 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\Select\Text; + +use Espo\ORM\Query\SelectBuilder; + +use Espo\Core\Select\Text\Filter\Data; + +interface Filter +{ + public function apply(SelectBuilder $queryBuilder, Data $data): void; +} diff --git a/application/Espo/Core/Select/Text/Filter/Data.php b/application/Espo/Core/Select/Text/Filter/Data.php new file mode 100644 index 0000000..349e819 --- /dev/null +++ b/application/Espo/Core/Select/Text/Filter/Data.php @@ -0,0 +1,133 @@ +. + * + * 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\Select\Text\Filter; + +use Espo\ORM\Query\Part\WhereItem; + +/** + * Immutable. + */ +class Data +{ + private string $filter; + /** @var string[] */ + private array $attributeList; + private bool $skipWildcards = false; + private ?WhereItem $fullTextSearchWhereItem = null; + private bool $forceFullTextSearch = false; + + /** + * @param string[] $attributeList + */ + public function __construct(string $filter, array $attributeList) + { + $this->filter = $filter; + $this->attributeList = $attributeList; + } + + /** + * @param string[] $attributeList + */ + public static function create(string $filter, array $attributeList): self + { + return new self($filter, $attributeList); + } + + public function withFilter(string $filter): self + { + $obj = clone $this; + $obj->filter = $filter; + + return $obj; + } + + /** + * @param string[] $attributeList + */ + public function withAttributeList(array $attributeList): self + { + $obj = clone $this; + $obj->attributeList = $attributeList; + + return $obj; + } + + public function withSkipWildcards(bool $skipWildcards = true): self + { + $obj = clone $this; + $obj->skipWildcards = $skipWildcards; + + return $obj; + } + + public function withForceFullTextSearch(bool $forceFullTextSearch = true): self + { + $obj = clone $this; + $obj->forceFullTextSearch = $forceFullTextSearch; + + return $obj; + } + + public function withFullTextSearchWhereItem(?WhereItem $fullTextSearchWhereItem): self + { + $obj = clone $this; + $obj->fullTextSearchWhereItem = $fullTextSearchWhereItem; + + return $obj; + } + + public function getFilter(): string + { + return $this->filter; + } + + /** + * @return string[] + */ + public function getAttributeList(): array + { + return $this->attributeList; + } + + public function skipWildcards(): bool + { + return $this->skipWildcards; + } + + public function forceFullTextSearch(): bool + { + return $this->forceFullTextSearch; + } + + public function getFullTextSearchWhereItem(): ?WhereItem + { + return $this->fullTextSearchWhereItem; + } +} diff --git a/application/Espo/Core/Select/Text/FilterFactory.php b/application/Espo/Core/Select/Text/FilterFactory.php new file mode 100644 index 0000000..8318730 --- /dev/null +++ b/application/Espo/Core/Select/Text/FilterFactory.php @@ -0,0 +1,69 @@ +. + * + * 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\Select\Text; + +use Espo\Core\Utils\Metadata; +use Espo\Core\InjectableFactory; +use Espo\Entities\User; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Binding\ContextualBinder; + +class FilterFactory +{ + public function __construct( + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + public function create(string $entityType, User $user): Filter + { + /** @var class-string $className */ + $className = $this->metadata->get(['selectDefs', $entityType, 'textFilterClassName']) ?? + DefaultFilter::class; + + $bindingContainer = BindingContainerBuilder::create() + ->bindInstance(User::class, $user) + ->inContext( + $className, + function (ContextualBinder $binder) use ($entityType) { + $binder->bindValue('$entityType', $entityType); + } + ) + ->inContext( + DefaultFilter::class, + function (ContextualBinder $binder) use ($entityType) { + $binder->bindValue('$entityType', $entityType); + } + ) + ->build(); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } +} diff --git a/application/Espo/Core/Select/Text/FilterParams.php b/application/Espo/Core/Select/Text/FilterParams.php new file mode 100644 index 0000000..37e4316 --- /dev/null +++ b/application/Espo/Core/Select/Text/FilterParams.php @@ -0,0 +1,58 @@ +. + * + * 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\Select\Text; + +/** + * Immutable. + */ +class FilterParams +{ + private bool $noFullTextSearch = false; + + private function __construct() {} + + public static function create(): self + { + return new self(); + } + + public function withNoFullTextSearch(bool $noFullTextSearch = true): self + { + $obj = clone $this; + $obj->noFullTextSearch = $noFullTextSearch; + + return $obj; + } + + public function noFullTextSearch(): bool + { + return $this->noFullTextSearch; + } +} diff --git a/application/Espo/Core/Select/Text/FullTextSearch/Data.php b/application/Espo/Core/Select/Text/FullTextSearch/Data.php new file mode 100644 index 0000000..88f5182 --- /dev/null +++ b/application/Espo/Core/Select/Text/FullTextSearch/Data.php @@ -0,0 +1,92 @@ +. + * + * 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\Select\Text\FullTextSearch; + +use Espo\ORM\Query\Part\Expression; + +use InvalidArgumentException; + +/** + * Immutable. + */ +class Data +{ + /** @var string[] */ + private array $fieldList; + /** @var string[] */ + private array $columnList; + /** @var Mode::* $mode */ + private string $mode; + + /** + * @param string[] $fieldList + * @param string[] $columnList + * @param Mode::* $mode + */ + public function __construct(private Expression $expression, array $fieldList, array $columnList, string $mode) + { + $this->fieldList = $fieldList; + $this->columnList = $columnList; + $this->mode = $mode; + + if (!in_array($mode, [Mode::NATURAL_LANGUAGE, Mode::BOOLEAN])) { + throw new InvalidArgumentException("Bad mode."); + } + } + + public function getExpression(): Expression + { + return $this->expression; + } + + /** + * @return string[] + */ + public function getFieldList(): array + { + return $this->fieldList; + } + + /** + * @return string[] + */ + public function getColumnList(): array + { + return $this->columnList; + } + + /** + * @return Mode::* + */ + public function getMode(): string + { + return $this->mode; + } +} diff --git a/application/Espo/Core/Select/Text/FullTextSearch/DataComposer.php b/application/Espo/Core/Select/Text/FullTextSearch/DataComposer.php new file mode 100644 index 0000000..7a3eb76 --- /dev/null +++ b/application/Espo/Core/Select/Text/FullTextSearch/DataComposer.php @@ -0,0 +1,37 @@ +. + * + * 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\Select\Text\FullTextSearch; + +use Espo\Core\Select\Text\FullTextSearch\DataComposer\Params; + +interface DataComposer +{ + public function compose(string $filter, Params $params): ?Data; +} diff --git a/application/Espo/Core/Select/Text/FullTextSearch/DataComposer/Params.php b/application/Espo/Core/Select/Text/FullTextSearch/DataComposer/Params.php new file mode 100644 index 0000000..f82d2a9 --- /dev/null +++ b/application/Espo/Core/Select/Text/FullTextSearch/DataComposer/Params.php @@ -0,0 +1,43 @@ +. + * + * 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\Select\Text\FullTextSearch\DataComposer; + +/** + * Immutable. + */ +class Params +{ + private function __construct() {} + + public static function create(): self + { + return new self(); + } +} diff --git a/application/Espo/Core/Select/Text/FullTextSearch/DataComposerFactory.php b/application/Espo/Core/Select/Text/FullTextSearch/DataComposerFactory.php new file mode 100644 index 0000000..4364811 --- /dev/null +++ b/application/Espo/Core/Select/Text/FullTextSearch/DataComposerFactory.php @@ -0,0 +1,62 @@ +. + * + * 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\Select\Text\FullTextSearch; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +class DataComposerFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata + ) {} + + public function create(string $entityType): DataComposer + { + $className = $this->getClassName($entityType); + + return $this->injectableFactory->createWith($className, [ + 'entityType' => $entityType, + ]); + } + + /** + * @return class-string + */ + private function getClassName(string $entityType): string + { + return + $this->metadata->get([ + 'selectDefs', $entityType, 'fullTextSearchDataComposerClassName' + ]) ?? + DefaultDataComposer::class; + } +} diff --git a/application/Espo/Core/Select/Text/FullTextSearch/DefaultDataComposer.php b/application/Espo/Core/Select/Text/FullTextSearch/DefaultDataComposer.php new file mode 100644 index 0000000..11d5748 --- /dev/null +++ b/application/Espo/Core/Select/Text/FullTextSearch/DefaultDataComposer.php @@ -0,0 +1,168 @@ +. + * + * 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\Select\Text\FullTextSearch; + +use Espo\Core\Utils\Config; +use Espo\Core\Select\Text\MetadataProvider; +use Espo\Core\Select\Text\FullTextSearch\DataComposer\Params; +use Espo\ORM\Query\Part\Expression\Util as ExpressionUtil; +use Espo\ORM\Query\Part\Expression; + +class DefaultDataComposer implements DataComposer +{ + /** @var array*/ + private array $functionMap = [ + Mode::BOOLEAN => 'MATCH_BOOLEAN', + Mode::NATURAL_LANGUAGE => 'MATCH_NATURAL_LANGUAGE', + ]; + + public function __construct( + private string $entityType, + private Config $config, + private MetadataProvider $metadataProvider + ) {} + + public function compose(string $filter, Params $params): ?Data + { + if ($this->config->get('fullTextSearchDisabled')) { + return null; + } + + $columnList = $this->metadataProvider->getFullTextSearchColumnList($this->entityType) ?? []; + + if (!count($columnList)) { + return null; + } + + $fieldList = []; + + foreach ($this->getTextFilterFieldList() as $field) { + if (str_contains($field, '.')) { + continue; + } + + if ($this->metadataProvider->isFieldNotStorable($this->entityType, $field)) { + continue; + } + + if (!$this->metadataProvider->isFullTextSearchSupportedForField($this->entityType, $field)) { + continue; + } + + $fieldList[] = $field; + } + + if (!count($fieldList)) { + return null; + } + + $preparedFilter = $this->prepareFilter($filter, $params); + + $mode = Mode::BOOLEAN; + + if ( + mb_strpos($preparedFilter, ' ') === false && + mb_strpos($preparedFilter, '+') === false && + mb_strpos($preparedFilter, ' -') === false && + mb_strpos($preparedFilter, '-') !== 0 && + mb_strpos($preparedFilter, '*') === false + ) { + $mode = Mode::NATURAL_LANGUAGE; + } + + if ($mode === Mode::BOOLEAN) { + $preparedFilter = str_replace('@', '*', $preparedFilter); + } + + $argumentList = array_merge( + array_map(fn ($item) => Expression::column($item), $columnList), + [$preparedFilter] + ); + + $function = $this->functionMap[$mode]; + + $expression = ExpressionUtil::composeFunction($function, ...$argumentList); + + return new Data( + $expression, + $fieldList, + $columnList, + $mode + ); + } + + private function prepareFilter(string $filter, Params $params): string + { + $filter = str_replace('%', '*', $filter); + $filter = str_replace(['(', ')'], '', $filter); + $filter = str_replace('"*', '"', $filter); + $filter = str_replace('*"', '"', $filter); + + while (str_contains($filter, '**')) { + $filter = trim( + str_replace('**', '*', $filter) + ); + } + + while (mb_substr($filter, -2) === ' *') { + $filter = trim( + mb_substr($filter, 0, mb_strlen($filter) - 2) + ); + } + + $filter = str_replace(['+-', '--', '-+', '++', '+*', '-*'], '', $filter); + + while (str_contains($filter, '+ ')) { + $filter = str_replace('+ ', '', $filter); + } + + while (str_contains($filter, '- ')) { + $filter = str_replace('- ', '', $filter); + } + + while (in_array(substr($filter, -1), ['-', '+'])) { + $filter = substr($filter, 0, -1); + } + + if ($filter === '*') { + $filter = ''; + } + + return $filter; + } + + /** + * @return string[] + */ + private function getTextFilterFieldList(): array + { + return $this->metadataProvider->getTextFilterAttributeList($this->entityType) ?? ['name']; + } +} diff --git a/application/Espo/Core/Select/Text/FullTextSearch/Mode.php b/application/Espo/Core/Select/Text/FullTextSearch/Mode.php new file mode 100644 index 0000000..3a04b65 --- /dev/null +++ b/application/Espo/Core/Select/Text/FullTextSearch/Mode.php @@ -0,0 +1,36 @@ +. + * + * 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\Select\Text\FullTextSearch; + +class Mode +{ + public const NATURAL_LANGUAGE = 'NATURAL_LANGUAGE'; + public const BOOLEAN = 'BOOLEAN'; +} diff --git a/application/Espo/Core/Select/Text/MetadataProvider.php b/application/Espo/Core/Select/Text/MetadataProvider.php new file mode 100644 index 0000000..24b9f4e --- /dev/null +++ b/application/Espo/Core/Select/Text/MetadataProvider.php @@ -0,0 +1,156 @@ +. + * + * 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\Select\Text; + +use Espo\Core\Utils\Metadata; + +use Espo\ORM\Defs; +use Espo\ORM\Defs\Params\AttributeParam; +use Espo\ORM\Defs\Params\FieldParam; + +class MetadataProvider +{ + private Defs $ormDefs; + + public function __construct(private Metadata $metadata, Defs $ormDefs) + { + $this->ormDefs = $ormDefs; + } + + public function getFullTextSearchOrderType(string $entityType): ?string + { + return $this->metadata->get([ + 'entityDefs', $entityType, 'collection', 'fullTextSearchOrderType' + ]); + } + + /** + * @return string[]|null + */ + public function getTextFilterAttributeList(string $entityType): ?array + { + return $this->metadata->get([ + 'entityDefs', $entityType, 'collection', 'textFilterFields' + ]); + } + + public function isFieldNotStorable(string $entityType, string $field): bool + { + return (bool) $this->metadata->get([ + 'entityDefs', $entityType, 'fields', $field, Defs\Params\FieldParam::NOT_STORABLE + ]); + } + + public function isFullTextSearchSupportedForField(string $entityType, string $field): bool + { + $fieldType = $this->metadata->get([ + 'entityDefs', $entityType, 'fields', $field, FieldParam::TYPE + ]); + + return (bool) $this->metadata->get([ + 'fields', $fieldType, 'fullTextSearch' + ]); + } + + public function hasFullTextSearch(string $entityType): bool + { + return (bool) $this->metadata->get([ + 'entityDefs', $entityType, 'collection', 'fullTextSearch' + ]); + } + + /** + * @return string[] + */ + public function getUseContainsAttributeList(string $entityType): array + { + return $this->metadata->get([ + 'selectDefs', $entityType, 'textFilterUseContainsAttributeList' + ]) ?? []; + } + + /** + * @return string[]|null + */ + public function getFullTextSearchColumnList(string $entityType): ?array + { + return $this->ormDefs + ->getEntity($entityType) + ->getParam('fullTextSearchColumnList'); + } + + public function getRelationType(string $entityType, string $link): string + { + return $this->ormDefs + ->getEntity($entityType) + ->getRelation($link) + ->getType(); + } + + public function getAttributeType(string $entityType, string $attribute): string + { + return $this->ormDefs + ->getEntity($entityType) + ->getAttribute($attribute) + ->getType(); + } + + public function getFieldType(string $entityType, string $field): ?string + { + $entityDefs = $this->ormDefs->getEntity($entityType); + + if (!$entityDefs->hasField($field)) { + return null; + } + + return $entityDefs->getField($field)->getType(); + } + + public function getRelationEntityType(string $entityType, string $link): ?string + { + $relationDefs = $this->ormDefs + ->getEntity($entityType) + ->getRelation($link); + + if (!$relationDefs->hasForeignEntityType()) { + return null; + } + + return $relationDefs->getForeignEntityType(); + } + + public function getAttributeRelationParam(string $entityType, string $attribute): ?string + { + return $this->ormDefs + ->getEntity($entityType) + ->getAttribute($attribute) + ->getParam(AttributeParam::RELATION); + } +} diff --git a/application/Espo/Core/Select/Where/Applier.php b/application/Espo/Core/Select/Where/Applier.php new file mode 100644 index 0000000..ea1b300 --- /dev/null +++ b/application/Espo/Core/Select/Where/Applier.php @@ -0,0 +1,74 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Select\Where\Item as WhereItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; +use Espo\Entities\User; + +class Applier +{ + public function __construct( + private string $entityType, + private User $user, + private ConverterFactory $converterFactory, + private CheckerFactory $checkerFactory + ) {} + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function apply(QueryBuilder $queryBuilder, WhereItem $whereItem, Params $params): void + { + $this->check($whereItem, $params); + + $converter = $this->converterFactory->create($this->entityType, $this->user); + + $convertedParams = new Converter\Params(useSubQueryIfMany: true); + + $queryBuilder->where( + $converter->convert($queryBuilder, $whereItem, $convertedParams) + ); + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function check(Item $whereItem, Params $params): void + { + $checker = $this->checkerFactory->create($this->entityType, $this->user); + + $checker->check($whereItem, $params); + } +} diff --git a/application/Espo/Core/Select/Where/Checker.php b/application/Espo/Core/Select/Where/Checker.php new file mode 100644 index 0000000..f4336b3 --- /dev/null +++ b/application/Espo/Core/Select/Where/Checker.php @@ -0,0 +1,415 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Acl; +use Espo\Core\Select\Helpers\EntityHelper; +use Espo\Core\Select\Where\Item\Type; +use Espo\Entities\Team; +use Espo\ORM\QueryComposer\Util; +use Espo\ORM\QueryComposer\Util as QueryUtil; +use Espo\ORM\EntityManager; +use Espo\ORM\Entity; +use Espo\ORM\Type\RelationType; + +/** + * Checks Where parameters. Throws an exception if anything not allowed is met. + * + * @todo Check read access to foreign entity for belongs-to, belongs-to-parent, has-one. + */ +class Checker +{ + private ?Entity $seed = null; + + private const TYPE_IN_CATEGORY = 'inCategory'; + private const TYPE_IS_USER_FROM_TEAMS = 'isUserFromTeams'; + + /** @var string[] */ + private $nestingTypeList = [ + Type::OR, + Type::AND, + Type::NOT, + Type::SUBQUERY_IN, + Type::SUBQUERY_NOT_IN, + ]; + + /** @var string[] */ + private $subQueryTypeList = [ + Type::SUBQUERY_IN, + Type::SUBQUERY_NOT_IN, + Type::NOT, + ]; + + /** @var string[] */ + private $linkTypeList = [ + self::TYPE_IN_CATEGORY, + self::TYPE_IS_USER_FROM_TEAMS, + Type::IS_LINKED_WITH, + Type::IS_NOT_LINKED_WITH, + Type::IS_LINKED_WITH_ALL, + Type::IS_LINKED_WITH_ANY, + Type::IS_LINKED_WITH_NONE, + ]; + + /** @var string[] */ + private $linkWithIdsTypeList = [ + self::TYPE_IN_CATEGORY, + self::TYPE_IS_USER_FROM_TEAMS, + Type::IS_LINKED_WITH, + Type::IS_NOT_LINKED_WITH, + Type::IS_LINKED_WITH_ALL, + ]; + + public function __construct( + private string $entityType, + private EntityManager $entityManager, + private Acl $acl, + private Acl\SystemRestriction $systemRestriction, + private EntityHelper $entityHelper, + ) {} + + /** + * Check. + * + * @throws Forbidden + * @throws BadRequest + */ + public function check(Item $item, Params $params): void + { + $this->checkItem($item, $params); + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function checkItem(Item $item, Params $params): void + { + $type = $item->getType(); + $attribute = $item->getAttribute(); + $value = $item->getValue(); + + $forbidComplexExpressions = $params->forbidComplexExpressions(); + $checkWherePermission = $params->applyPermissionCheck(); + + if ($attribute && str_contains($attribute, '#')) { + throw new Forbidden("Not allowed character in attribute."); + } + + if ($forbidComplexExpressions) { + if (in_array($type, $this->subQueryTypeList)) { + throw new Forbidden("Sub-queries are forbidden in where."); + } + } + + if ($attribute && $forbidComplexExpressions) { + if (QueryUtil::isComplexExpression($attribute)) { + throw new Forbidden("Complex expressions are forbidden in where."); + } + } + + if ($attribute) { + $argumentList = Util::getAllAttributesFromComplexExpression($attribute); + + foreach ($argumentList as $argument) { + $this->checkAttributeExistence($argument, $type); + + $this->checkAttributePermission($argument, $type, $value, $checkWherePermission); + } + } + + if (in_array($type, $this->nestingTypeList) && is_array($value)) { + foreach ($value as $subItem) { + $this->checkItem(Item::fromRaw($subItem), $params); + } + } + } + + /** + * @throws BadRequest + */ + private function checkAttributeExistence(string $attribute, string $type): void + { + if (str_contains($attribute, '.')) { + // @todo Check existence of foreign attributes. + return; + } + + if (in_array($type, $this->linkTypeList)) { + if (!$this->getSeed()->hasRelation($attribute)) { + throw new BadRequest("Not existing relation '$attribute' in where."); + } + + return; + } + + if (!$this->getSeed()->hasAttribute($attribute)) { + throw new BadRequest("Not existing attribute '$attribute' in where."); + } + } + + /** + * @throws Forbidden + * @throws BadRequest + */ + private function checkAttributePermission(string $attribute, string $type, mixed $value, bool $aclCheck): void + { + $entityType = $this->entityType; + + if (str_contains($attribute, '.')) { + [$link, $attribute] = explode('.', $attribute); + + $this->checkAttributePermissionWithLink( + entityType: $entityType, + aclCheck: $aclCheck, + link: $link, + attribute: $attribute, + ); + + return; + } + + if (in_array($type, $this->linkTypeList)) { + $this->checkLink( + type: $type, + entityType: $entityType, + link: $attribute, + value: $value, + aclCheck: $aclCheck, + ); + + return; + } + + if (!$aclCheck) { + $this->assertAttributeSystemRead($entityType, $attribute); + + return; + } + + if (in_array($attribute, $this->acl->getScopeForbiddenAttributeList($entityType))) { + throw new Forbidden("Forbidden attribute '$attribute' in where."); + } + } + + /** + * @throws Forbidden + * @throws BadRequest + */ + private function checkAttributePermissionWithLink( + string $entityType, + bool $aclCheck, + string $link, + string $attribute, + ): void { + + if (!$link) { + throw new BadRequest("Empty relation in path in where."); + } + + if (!$attribute) { + throw new BadRequest("Empty attribute in path in where."); + } + + if (!$this->getSeed()->hasRelation($link)) { + // TODO allow alias + throw new Forbidden("Bad relation '$link' in where."); + } + + $foreignEntityType = $this->getRelationEntityType($this->getSeed(), $link); + + if (!$aclCheck) { + $this->assertLinkSystemRead($entityType, $link); + + if ($this->getSeed()->getRelationType($link) === RelationType::BELONGS_TO_PARENT) { + $entityTypeList = $this->entityManager + ->getDefs() + ->getEntity($entityType) + ->tryGetField($link) + ?->getParam('entityList') ?? []; + + foreach ($entityTypeList as $foreignEntityType) { + $this->assertAttributeRead($foreignEntityType, $attribute, $link); + } + + return; + } + + if (!$foreignEntityType) { + throw new Forbidden("Bad relation '$link' in where."); + } + + $this->assertAttributeRead($foreignEntityType, $attribute, $link); + + return; + } + + if (!$foreignEntityType) { + throw new Forbidden("Bad relation '$link' in where."); + } + + if ( + !$this->acl->checkScope($foreignEntityType) || + in_array($link, $this->acl->getScopeForbiddenLinkList($entityType)) + ) { + throw new Forbidden("Forbidden relation '$link' in where."); + } + + if (in_array($attribute, $this->acl->getScopeForbiddenAttributeList($foreignEntityType))) { + throw new Forbidden("Forbidden attribute '$link.$attribute' in where."); + } + } + + private function getSeed(): Entity + { + $this->seed ??= $this->entityManager->getNewEntity($this->entityType); + + return $this->seed; + } + + private function getRelationEntityType(Entity $entity, string $relation): ?string + { + return $this->entityHelper->getRelationEntityType($entity, $relation); + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function checkLink( + string $type, + string $entityType, + string $link, + mixed $value, + bool $aclCheck, + ): void { + + if (!$this->getSeed()->hasRelation($link)) { + throw new Forbidden("Bad relation '$link' in where."); + } + + $foreignEntityType = $this->getRelationEntityType($this->getSeed(), $link); + + if (!$foreignEntityType) { + throw new Forbidden("Bad relation '$link' in where, no foreign entity type."); + } + + if ($type === self::TYPE_IS_USER_FROM_TEAMS) { + $foreignEntityType = Team::ENTITY_TYPE; + } + + if ($aclCheck) { + if ( + in_array($link, $this->acl->getScopeForbiddenFieldList($entityType)) || + !$this->acl->checkScope($foreignEntityType) || + in_array($link, $this->acl->getScopeForbiddenLinkList($entityType)) + ) { + throw new Forbidden("Forbidden link '$link' in where."); + } + } else { + $this->assertLinkSystemRead($entityType, $link); + $this->assertFieldSystemRead($entityType, $link); + } + + if (!in_array($type, $this->linkWithIdsTypeList)) { + return; + } + + if ($value === null) { + return; + } + + if (!is_array($value)) { + $value = [$value]; + } + + foreach ($value as $it) { + if (!is_string($it)) { + throw new BadRequest("Bad where item. Non-string ID."); + } + } + + // @todo Use the Select Builder instead. Check the result count equal the input IDs count. + + foreach ($value as $id) { + $entity = $this->entityManager->getEntityById($foreignEntityType, $id); + + if (!$entity) { + throw new Forbidden("Record '$foreignEntityType' `$id` not found."); + } + + if ($aclCheck && !$this->acl->checkEntityRead($entity)) { + throw new Forbidden("No access to '$foreignEntityType' `$id`."); + } + } + } + + /** + * @throws Forbidden + */ + private function assertLinkSystemRead(string $entityType, string $link): void + { + if (!$this->systemRestriction->checkLinkRead($entityType, $link)) { + throw new Forbidden("System restricted link '$link' in where."); + } + } + + /** + * @throws Forbidden + */ + private function assertFieldSystemRead(string $entityType, string $field): void + { + if (!$this->systemRestriction->checkFieldRead($entityType, $field)) { + throw new Forbidden("System restricted field '$field' in where."); + } + } + + /** + * @throws Forbidden + */ + private function assertAttributeSystemRead(string $entityType, string $attribute): void + { + if (!$this->systemRestriction->checkAttributeRead($entityType, $attribute)) { + throw new Forbidden("System restricted attribute '$attribute' in where."); + } + } + + /** + * @throws Forbidden + */ + private function assertAttributeRead(string $entityType, string $attribute, string $link): void + { + if (!$this->systemRestriction->checkAttributeRead($entityType, $attribute)) { + throw new Forbidden("System restricted attribute '$link.$attribute' in where."); + } + } +} diff --git a/application/Espo/Core/Select/Where/CheckerFactory.php b/application/Espo/Core/Select/Where/CheckerFactory.php new file mode 100644 index 0000000..323eb80 --- /dev/null +++ b/application/Espo/Core/Select/Where/CheckerFactory.php @@ -0,0 +1,60 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Acl\Exceptions\NotAvailable; +use Espo\Entities\User; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Acl\UserAclManagerProvider; +use RuntimeException; + +class CheckerFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private UserAclManagerProvider $userAclManagerProvider + ) {} + + public function create(string $entityType, User $user): Checker + { + try { + $acl = $this->userAclManagerProvider + ->get($user) + ->createUserAcl($user); + } catch (NotAvailable $e) { + throw new RuntimeException($e->getMessage()); + } + + return $this->injectableFactory->createWith(Checker::class, [ + 'entityType' => $entityType, + 'acl' => $acl, + ]); + } +} diff --git a/application/Espo/Core/Select/Where/Converter.php b/application/Espo/Core/Select/Where/Converter.php new file mode 100644 index 0000000..53265df --- /dev/null +++ b/application/Espo/Core/Select/Where/Converter.php @@ -0,0 +1,147 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Select\Where\Item\Type; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\Part\Where\Comparison; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem; +use Espo\ORM\Query\SelectBuilder; +use InvalidArgumentException; +use RuntimeException; + +/** + * Converts a search where (passed from front-end) to a where clause (for ORM). + */ +class Converter +{ + public function __construct( + private ItemConverter $itemConverter, + private Scanner $scanner + ) {} + + /** + * @throws BadRequest + */ + public function convert(SelectBuilder $queryBuilder, Item $item, ?Converter\Params $params = null): WhereItem + { + if ($params && $params->useSubQueryIfMany() && $this->hasRelatedMany($queryBuilder, $item)) { + return $this->convertSubQuery($queryBuilder, $item); + } + + $whereClause = []; + + foreach ($this->itemToList($item) as $subItemRaw) { + try { + $subItem = Item::fromRaw($subItemRaw); + } catch (InvalidArgumentException $e) { + throw new BadRequest($e->getMessage()); + } + + $part = $this->processItem($queryBuilder, $subItem); + + if ($part === []) { + continue; + } + + $whereClause[] = $part; + } + + $this->scanner->apply($queryBuilder, $item); + + return WhereClause::fromRaw($whereClause); + } + + private function hasRelatedMany(SelectBuilder $queryBuilder, Item $item): bool + { + $entityType = $queryBuilder->build()->getFrom(); + + if (!$entityType) { + throw new RuntimeException("No 'from' in queryBuilder."); + } + + return $this->scanner->hasRelatedMany($entityType, $item); + } + + /** + * @return array + * @throws BadRequest + */ + private function itemToList(Item $item): array + { + if ($item->getType() !== Type::AND) { + return [ + $item->getRaw(), + ]; + } + + $list = $item->getValue(); + + if (!is_array($list)) { + throw new BadRequest("Bad where item value."); + } + + return $list; + } + + /** + * @return array + * @throws BadRequest + */ + private function processItem(SelectBuilder $queryBuilder, Item $item): array + { + return $this->itemConverter->convert($queryBuilder, $item)->getRaw(); + } + + /** + * @throws BadRequest + */ + private function convertSubQuery(SelectBuilder $queryBuilder, Item $item): Comparison + { + $entityType = $queryBuilder->build()->getFrom() ?? throw new RuntimeException(); + + $subQueryBuilder = SelectBuilder::create() + ->from($entityType) + ->select(Attribute::ID); + + $subQueryBuilder->where( + $this->convert($subQueryBuilder, $item) + ); + + return Cond::in( + Expr::column(Attribute::ID), + $subQueryBuilder->build() + ); + } +} diff --git a/application/Espo/Core/Select/Where/Converter/Params.php b/application/Espo/Core/Select/Where/Converter/Params.php new file mode 100644 index 0000000..223298a --- /dev/null +++ b/application/Espo/Core/Select/Where/Converter/Params.php @@ -0,0 +1,54 @@ +. + * + * 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\Select\Where\Converter; + +/** + * Where converter parameters. + * + * Immutable. + * @since 9.0.0 + */ +class Params +{ + /** + * @param bool $useSubQueryIfMany To use a sub-query if at least one has-many relation appears in a where clause. + */ + public function __construct( + readonly private bool $useSubQueryIfMany = false, + ) {} + + /** + * To use a sub-query if at least one has-many relation appears in a where clause. + */ + public function useSubQueryIfMany(): bool + { + return $this->useSubQueryIfMany; + } +} diff --git a/application/Espo/Core/Select/Where/ConverterFactory.php b/application/Espo/Core/Select/Where/ConverterFactory.php new file mode 100644 index 0000000..53d2f73 --- /dev/null +++ b/application/Espo/Core/Select/Where/ConverterFactory.php @@ -0,0 +1,158 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; + +class ConverterFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Metadata $metadata + ) {} + + public function create(string $entityType, User $user): Converter + { + $dateTimeItemTransformer = $this->createDateTimeItemTransformer($entityType, $user); + + $itemConverter = $this->createItemConverter($entityType, $user, $dateTimeItemTransformer); + + $className = $this->getConverterClassName($entityType); + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + $binder + ->bindInstance(User::class, $user); + $binder + ->for($className) + ->bindValue('$entityType', $entityType) + ->bindInstance(ItemConverter::class, $itemConverter); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + private function createDateTimeItemTransformer(string $entityType, User $user): DateTimeItemTransformer + { + $className = $this->getDateTimeItemTransformerClassName($entityType); + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + $binder->bindInstance(User::class, $user); + $binder + ->for($className) + ->bindValue('$entityType', $entityType); + $binder + ->for(DefaultDateTimeItemTransformer::class) + ->bindValue('$entityType', $entityType); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + private function createItemConverter( + string $entityType, + User $user, + DateTimeItemTransformer $dateTimeItemTransformer + ): ItemConverter { + + $className = $this->getItemConverterClassName($entityType); + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + $binder + ->bindInstance(User::class, $user); + $binder + ->for($className) + ->bindValue('$entityType', $entityType) + ->bindInstance(DateTimeItemTransformer::class, $dateTimeItemTransformer); + $binder + ->for(ItemGeneralConverter::class) + ->bindValue('$entityType', $entityType) + ->bindInstance(DateTimeItemTransformer::class, $dateTimeItemTransformer); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + /** + * @return class-string + */ + private function getConverterClassName(string $entityType): string + { + $className = $this->metadata->get(['selectDefs', $entityType, 'whereConverterClassName']); + + if ($className) { + return $className; + } + + return Converter::class; + } + + /** + * @return class-string + */ + private function getItemConverterClassName(string $entityType): string + { + $className = $this->metadata->get(['selectDefs', $entityType, 'whereItemConverterClassName']); + + if ($className) { + return $className; + } + + return ItemGeneralConverter::class; + } + + /** + * @return class-string + */ + private function getDateTimeItemTransformerClassName(string $entityType): string + { + $className = $this->metadata + ->get(['selectDefs', $entityType, 'whereDateTimeItemTransformerClassName']); + + if ($className) { + return $className; + } + + return DefaultDateTimeItemTransformer::class; + } +} diff --git a/application/Espo/Core/Select/Where/DateTimeItemTransformer.php b/application/Espo/Core/Select/Where/DateTimeItemTransformer.php new file mode 100644 index 0000000..cc4a1ab --- /dev/null +++ b/application/Espo/Core/Select/Where/DateTimeItemTransformer.php @@ -0,0 +1,46 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Exceptions\BadRequest; + +/** + * Transforms date-time where item. Applies timezone. * + * Implementing a custom transformer should not be considered future-proof. + * + * @internal + */ +interface DateTimeItemTransformer +{ + /** + * @throws BadRequest + */ + public function transform(Item $item): Item; +} diff --git a/application/Espo/Core/Select/Where/DefaultDateTimeItemTransformer.php b/application/Espo/Core/Select/Where/DefaultDateTimeItemTransformer.php new file mode 100644 index 0000000..7baa454 --- /dev/null +++ b/application/Espo/Core/Select/Where/DefaultDateTimeItemTransformer.php @@ -0,0 +1,519 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Core\Utils\Config; +use Espo\Core\Select\Where\Item\Type; + +use DateTime; +use DateTimeZone; +use DateInterval; +use Exception; +use RuntimeException; + +class DefaultDateTimeItemTransformer implements DateTimeItemTransformer +{ + public function __construct( + private Config $config, + private Config\ApplicationConfig $applicationConfig, + ) {} + + /** + * @throws BadRequest + */ + public function transform(Item $item): Item + { + $format = DateTimeUtil::SYSTEM_DATE_TIME_FORMAT; + + $type = $item->getType(); + $value = $item->getValue(); + $attribute = $item->getAttribute(); + $data = $item->getData(); + + if (!$data instanceof Item\Data\DateTime) { + throw new BadRequest("Bad where item."); + } + + $timeZone = $data->getTimeZone() ?? $this->applicationConfig->getTimeZone(); + + if (!$attribute) { + throw new BadRequest("Bad datetime where item. Empty 'attribute'."); + } + + if (!$type) { + throw new BadRequest("Bad datetime where item. Empty 'type'."); + } + + if ( + empty($value) && + in_array( + $type, + [ + Type::ON, + Type::BEFORE, + Type::AFTER, + ] + ) + ) { + throw new BadRequest("Bad where item. Empty value."); + } + + $where = [ + 'attribute' => $attribute, + ]; + + try { + $dt = new DateTime('now', new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad timezone"); + } + + switch ($type) { + case Type::TODAY: + $where['type'] = Type::BETWEEN; + + $dt->setTime(0, 0); + + $dtTo = clone $dt; + $dtTo->modify('+1 day -1 second'); + $dt->setTimezone(new DateTimeZone('UTC')); + $dtTo->setTimezone(new DateTimeZone('UTC')); + + $from = $dt->format($format); + $to = $dtTo->format($format); + + $where['value'] = [$from, $to]; + + break; + + case Type::PAST: + $where['type'] = Type::LESS_THAN_OR_EQUALS; + + $dt->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = $dt->format($format); + + break; + + case Type::FUTURE: + $where['type'] = Type::AFTER; + + $dt->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = $dt->format($format); + + break; + + case Type::LAST_SEVEN_DAYS: + $where['type'] = Type::BETWEEN; + + $dtFrom = clone $dt; + $dtTo = clone $dt; + + $dtTo->setTimezone(new DateTimeZone('UTC')); + + $to = $dtTo->format($format); + + $dtFrom->modify('-7 day'); + $dtFrom->setTime(0, 0); + $dtFrom->setTimezone(new DateTimeZone('UTC')); + + $from = $dtFrom->format($format); + + $where['value'] = [$from, $to]; + + break; + + case Type::LAST_X_DAYS: + $where['type'] = Type::BETWEEN; + + $dtFrom = clone $dt; + $dtTo = clone $dt; + + $dtTo->setTimezone(new DateTimeZone('UTC')); + + $to = $dtTo->format($format); + + $number = strval(intval($value)); + + $dtFrom->modify('-'.$number.' day'); + $dtFrom->setTime(0, 0); + $dtFrom->setTimezone(new DateTimeZone('UTC')); + + $from = $dtFrom->format($format); + + $where['value'] = [$from, $to]; + + break; + + case Type::NEXT_X_DAYS: + $where['type'] = Type::BETWEEN; + + $dtTo = clone $dt; + + $dt->setTimezone(new DateTimeZone('UTC')); + + $from = $dt->format($format); + + $number = strval(intval($value)); + + $dtTo->modify('+'.$number.' day'); + $dtTo->setTime(24, 59, 59); + $dtTo->setTimezone(new DateTimeZone('UTC')); + + $to = $dtTo->format($format); + + $where['value'] = [$from, $to]; + + break; + + case Type::OLDER_THAN_X_DAYS: + $where['type'] = Type::BEFORE; + + $number = strval(intval($value)); + + $dt->modify("-$number day"); + $dt->setTime(0, 0); + $dt->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = $dt->format($format); + + break; + + case Type::AFTER_X_DAYS: + $where['type'] = Type::GREATER_THAN_OR_EQUALS; + + $number = strval(intval($value)); + + $dt->modify("+$number day"); + $dt->setTime(0, 0); + $dt->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = $dt->format($format); + + break; + + case Type::ON: + $where['type'] = Type::BETWEEN; + + try { + $dt = new DateTime($value, new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad date value or timezone."); + } + + $dtTo = clone $dt; + + if (strlen($value) <= 10) { + $dtTo->modify('+1 day -1 second'); + } + + $dt->setTimezone(new DateTimeZone('UTC')); + $dtTo->setTimezone(new DateTimeZone('UTC')); + + $from = $dt->format($format); + $to = $dtTo->format($format); + + $where['value'] = [$from, $to]; + + break; + + case Type::BEFORE: + $where['type'] = Type::BEFORE; + + try { + $dt = new DateTime($value, new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad date value or timezone."); + } + + $dt->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = $dt->format($format); + + break; + + case Type::AFTER: + $where['type'] = Type::AFTER; + + try { + $dt = new DateTime($value, new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad date value or timezone."); + } + + if (strlen($value) <= 10) { + $dt->modify('+1 day -1 second'); + } + + $dt->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = $dt->format($format); + + break; + + case Type::BETWEEN: + $where['type'] = Type::BETWEEN; + + if (!is_array($value) || count($value) < 2) { + throw new BadRequest("Bad where item. Bad value."); + } + + try { + $dt = new DateTime($value[0], new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad date value or timezone."); + } + + $dt->setTimezone(new DateTimeZone('UTC')); + + $from = $dt->format($format); + + try { + $dt = new DateTime($value[1], new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad date value or timezone."); + } + + $dt->setTimezone(new DateTimeZone('UTC')); + + if (strlen($value[1]) <= 10) { + $dt->modify('+1 day -1 second'); + } + + $to = $dt->format($format); + + $where['value'] = [$from, $to]; + + break; + + case Type::CURRENT_MONTH: + case Type::LAST_MONTH: + case Type::NEXT_MONTH: + $where['type'] = Type::BETWEEN; + + $dtFrom = $dt->modify('first day of this month')->setTime(0, 0); + + if ($type == Type::LAST_MONTH) { + $dtFrom->modify('-1 month'); + } else if ($type == Type::NEXT_MONTH) { + $dtFrom->modify('+1 month'); + } + + $dtTo = clone $dtFrom; + $dtTo->modify('+1 month')->modify('-1 second'); + + $dtFrom->setTimezone(new DateTimeZone('UTC')); + $dtTo->setTimezone(new DateTimeZone('UTC')); + + + $where['value'] = [$dtFrom->format($format), $dtTo->format($format)]; + + break; + + case Type::CURRENT_QUARTER: + case Type::LAST_QUARTER: + $where['type'] = Type::BETWEEN; + + try { + $dt = new DateTime('now', new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad timezone."); + } + + $quarter = ceil($dt->format('m') / 3); + + $dtFrom = clone $dt; + $dtFrom->modify('first day of January this year')->setTime(0, 0); + + if ($type === Type::LAST_QUARTER) { + $quarter--; + + if ($quarter == 0) { + $quarter = 4; + $dtFrom->modify('-1 year'); + } + } + + try { + $dtFrom->add(new DateInterval('P' . (($quarter - 1) * 3) . 'M')); + } catch (Exception) { + throw new RuntimeException(); + } + + $dtTo = clone $dtFrom; + $dtTo->add(new DateInterval('P3M'))->modify('-1 second'); + $dtFrom->setTimezone(new DateTimeZone('UTC')); + $dtTo->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = [ + $dtFrom->format($format), + $dtTo->format($format), + ]; + + break; + + case Type::CURRENT_YEAR: + case Type::LAST_YEAR: + $where['type'] = Type::BETWEEN; + + try { + $dtFrom = new DateTime('now', new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad timezone."); + } + + $dtFrom->modify('first day of January this year')->setTime(0, 0); + + if ($type == Type::LAST_YEAR) { + $dtFrom->modify('-1 year'); + } + + $dtTo = clone $dtFrom; + $dtTo = $dtTo->modify('+1 year')->modify('-1 second'); + $dtFrom->setTimezone(new DateTimeZone('UTC')); + $dtTo->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = [ + $dtFrom->format($format), + $dtTo->format($format), + ]; + + break; + + case Type::CURRENT_FISCAL_YEAR: + case Type::LAST_FISCAL_YEAR: + $where['type'] = Type::BETWEEN; + + try { + $dtToday = new DateTime('now', new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad timezone."); + } + + $dt = clone $dtToday; + $fiscalYearShift = $this->config->get('fiscalYearShift', 0); + + $dt + ->modify('first day of January this year') + ->modify('+' . $fiscalYearShift . ' months') + ->setTime(0, 0); + + if (intval($dtToday->format('m')) < $fiscalYearShift + 1) { + $dt->modify('-1 year'); + } + + if ($type === Type::LAST_FISCAL_YEAR) { + $dt->modify('-1 year'); + } + + $dtFrom = clone $dt; + $dtTo = clone $dt; + $dtTo = $dtTo->modify('+1 year')->modify('-1 second'); + + $dtFrom->setTimezone(new DateTimeZone('UTC')); + $dtTo->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = [ + $dtFrom->format($format), + $dtTo->format($format), + ]; + + break; + + case Type::CURRENT_FISCAL_QUARTER: + case Type::LAST_FISCAL_QUARTER: + $where['type'] = Type::BETWEEN; + + try { + $dtToday = new DateTime('now', new DateTimeZone($timeZone)); + } catch (Exception) { + throw new BadRequest("Bad timezone."); + } + + $dt = clone $dtToday; + + $fiscalYearShift = $this->config->get('fiscalYearShift', 0); + + $dt + ->modify('first day of January this year') + ->modify('+' . $fiscalYearShift . ' months') + ->setTime(0, 0); + + $month = intval($dtToday->format('m')); + + $quarterShift = floor(($month - $fiscalYearShift - 1) / 3); + + if ($quarterShift) { + if ($quarterShift >= 0) { + try { + $dt->add(new DateInterval('P' . ($quarterShift * 3) . 'M')); + } catch (Exception) { + throw new RuntimeException(); + } + } else { + $quarterShift *= -1; + + try { + $dt->sub(new DateInterval('P' . ($quarterShift * 3) . 'M')); + } catch (Exception) { + throw new RuntimeException(); + } + } + } + + if ($type === Type::LAST_FISCAL_QUARTER) { + $dt->modify('-3 months'); + } + + $dtFrom = clone $dt; + $dtTo = clone $dt; + $dtTo = $dtTo->modify('+3 months')->modify('-1 second'); + + $dtFrom->setTimezone(new DateTimeZone('UTC')); + $dtTo->setTimezone(new DateTimeZone('UTC')); + + $where['value'] = [ + $dtFrom->format($format), + $dtTo->format($format), + ]; + + break; + + default: + $where['type'] = $type; + $where['value'] = $value; + } + + return Item::fromRaw($where); + } +} diff --git a/application/Espo/Core/Select/Where/Item.php b/application/Espo/Core/Select/Where/Item.php new file mode 100644 index 0000000..58ac3ec --- /dev/null +++ b/application/Espo/Core/Select/Where/Item.php @@ -0,0 +1,253 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Select\Where\Item\Data; + +use InvalidArgumentException; +use RuntimeException; + +/** + * A where item. + * + * Immutable. + */ +class Item +{ + public const TYPE_AND = Item\Type::AND; + public const TYPE_OR = Item\Type::OR; + + private ?string $attribute = null; + private mixed $value = null; + private ?Data $data = null; + + /** @var string[] */ + private $noAttributeTypeList = [ + Item\Type::AND, + Item\Type::OR, + Item\Type::NOT, + Item\Type::SUBQUERY_IN, + Item\Type::SUBQUERY_NOT_IN, + ]; + + /** @var string[] */ + private $withNestedItemsTypeList = [ + Item\Type::AND, + Item\Type::OR, + ]; + + private function __construct(private string $type) + {} + + /** + * @param array $params + * @internal + */ + public static function fromRaw(array $params): self + { + $type = $params['type'] ?? null; + + if (!$type) { + throw new InvalidArgumentException("No 'type' in where item."); + } + + $obj = new self($type); + + $obj->attribute = $params['attribute'] ?? $params['field'] ?? null; + $obj->value = $params['value'] ?? null; + + if ($params['dateTime'] ?? false) { + $obj->data = Data\DateTime + ::create() + ->withTimeZone($params['timeZone'] ?? null); + } else if ($params['date'] ?? null) { + $obj->data = Data\Date + ::create() + ->withTimeZone($params['timeZone'] ?? null); + } + + unset($params['field']); + unset($params['dateTime']); + unset($params['date']); + unset($params['timeZone']); + + foreach (array_keys($params) as $key) { + if (!property_exists($obj, $key)) { + throw new InvalidArgumentException("Unknown parameter '$key'."); + } + } + + if ( + !$obj->attribute && + !in_array($obj->type, $obj->noAttributeTypeList) + ) { + throw new InvalidArgumentException("No 'attribute' in where item."); + } + + if (in_array($obj->type, $obj->withNestedItemsTypeList)) { + $obj->value = $obj->value ?? []; + + if ( + !is_array($obj->value) || + count($obj->value) && array_keys($obj->value) !== range(0, count($obj->value) - 1) + ) { + throw new InvalidArgumentException("Bad 'value'."); + } + } + + return $obj; + } + + /** + * @param array> $paramList + * {@internal} + */ + public static function fromRawAndGroup(array $paramList): self + { + return self::fromRaw([ + 'type' => Item\Type::AND, + 'value' => $paramList, + ]); + } + + /** + * @return array{ + * type: string, + * value: mixed, + * attribute?: string, + * dateTime?: bool, + * timeZone?: string, + * } + * {@internal} + */ + public function getRaw(): array + { + $type = $this->type; + + $raw = [ + 'type' => $type, + 'value' => $this->value, + ]; + + if ($this->attribute) { + $raw['attribute'] = $this->attribute; + } + + if ($this->data instanceof Data\DateTime || $this->data instanceof Data\Date) { + if ($this->data instanceof Data\DateTime) { + $raw['dateTime'] = true; + } + + $timeZone = $this->data->getTimeZone(); + + if ($timeZone) { + $raw['timeZone'] = $timeZone; + } + } + + return $raw; + } + + /** + * Get a type; + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get an attribute. + */ + public function getAttribute(): ?string + { + return $this->attribute; + } + + /** + * Get a value. + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * Get nested where items (for 'and', 'or' types). + * + * @return Item[] + * @throws RuntimeException If a type does not support nested items. + */ + public function getItemList(): array + { + if (!in_array($this->type, $this->withNestedItemsTypeList)) { + throw new RuntimeException("Nested items not supported for '$this->type' type."); + } + + $list = []; + + foreach ($this->value as $raw) { + $list[] = Item::fromRaw($raw); + } + + return $list; + } + + /** + * Get a data-object. + */ + public function getData(): ?Data + { + return $this->data; + } + + /** + * Create a builder. + */ + public static function createBuilder(): ItemBuilder + { + return new ItemBuilder(); + } + + /** + * Clone with data. + * + * {@internal} + */ + public function withData(?Data $data): self + { + $obj = clone $this; + $obj->data = $data; + + return $obj; + } +} diff --git a/application/Espo/Core/Select/Where/Item/Data.php b/application/Espo/Core/Select/Where/Item/Data.php new file mode 100644 index 0000000..c9fcf56 --- /dev/null +++ b/application/Espo/Core/Select/Where/Item/Data.php @@ -0,0 +1,32 @@ +. + * + * 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\Select\Where\Item; + +interface Data {} diff --git a/application/Espo/Core/Select/Where/Item/Data/Date.php b/application/Espo/Core/Select/Where/Item/Data/Date.php new file mode 100644 index 0000000..23f7605 --- /dev/null +++ b/application/Espo/Core/Select/Where/Item/Data/Date.php @@ -0,0 +1,55 @@ +. + * + * 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\Select\Where\Item\Data; + +use Espo\Core\Select\Where\Item\Data; + +class Date implements Data +{ + private ?string $timeZone = null; + + public static function create(): self + { + return new self(); + } + + public function withTimeZone(?string $timeZone): self + { + $obj = clone $this; + $obj->timeZone = $timeZone; + + return $obj; + } + + public function getTimeZone(): ?string + { + return $this->timeZone; + } +} diff --git a/application/Espo/Core/Select/Where/Item/Data/DateTime.php b/application/Espo/Core/Select/Where/Item/Data/DateTime.php new file mode 100644 index 0000000..b04aaa4 --- /dev/null +++ b/application/Espo/Core/Select/Where/Item/Data/DateTime.php @@ -0,0 +1,55 @@ +. + * + * 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\Select\Where\Item\Data; + +use Espo\Core\Select\Where\Item\Data; + +class DateTime implements Data +{ + private ?string $timeZone = null; + + public static function create(): self + { + return new self(); + } + + public function withTimeZone(?string $timeZone): self + { + $obj = clone $this; + $obj->timeZone = $timeZone; + + return $obj; + } + + public function getTimeZone(): ?string + { + return $this->timeZone; + } +} diff --git a/application/Espo/Core/Select/Where/Item/Type.php b/application/Espo/Core/Select/Where/Item/Type.php new file mode 100644 index 0000000..59ee2f5 --- /dev/null +++ b/application/Espo/Core/Select/Where/Item/Type.php @@ -0,0 +1,116 @@ +. + * + * 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\Select\Where\Item; + +class Type +{ + public const AND = 'and'; + public const OR = 'or'; + public const NOT = 'not'; + public const SUBQUERY_NOT_IN = 'subQueryNotIn'; + public const SUBQUERY_IN = 'subQueryIn'; + public const EXPRESSION = 'expression'; + public const IN = 'in'; + public const NOT_IN = 'notIn'; + public const EQUALS = 'equals'; + public const NOT_EQUALS = 'notEquals'; + public const ON = 'on'; + public const NOT_ON = 'notOn'; + public const LIKE = 'like'; + public const NOT_LIKE = 'notLike'; + public const STARTS_WITH = 'startsWith'; + public const ENDS_WITH = 'endsWith'; + public const CONTAINS = 'contains'; + public const NOT_CONTAINS = 'notContains'; + public const GREATER_THAN = 'greaterThan'; + public const LESS_THAN = 'lessThan'; + public const GREATER_THAN_OR_EQUALS = 'greaterThanOrEquals'; + public const LESS_THAN_OR_EQUALS = 'lessThanOrEquals'; + public const AFTER = 'after'; + public const BEFORE = 'before'; + public const BETWEEN = 'between'; + public const EVER = 'ever'; + public const ANY = 'any'; + public const NONE = 'none'; + public const IS_NULL = 'isNull'; + public const IS_NOT_NULL = 'isNotNull'; + public const IS_TRUE = 'isTrue'; + public const IS_FALSE = 'isFalse'; + public const TODAY = 'today'; + public const PAST = 'past'; + public const FUTURE = 'future'; + public const LAST_SEVEN_DAYS = 'lastSevenDays'; + public const LAST_X_DAYS = 'lastXDays'; + public const NEXT_X_DAYS = 'nextXDays'; + public const OLDER_THAN_X_DAYS = 'olderThanXDays'; + public const AFTER_X_DAYS = 'afterXDays'; + public const CURRENT_MONTH = 'currentMonth'; + public const NEXT_MONTH = 'nextMonth'; + public const LAST_MONTH = 'lastMonth'; + public const CURRENT_QUARTER = 'currentQuarter'; + public const LAST_QUARTER = 'lastQuarter'; + public const CURRENT_YEAR = 'currentYear'; + public const LAST_YEAR = 'lastYear'; + public const CURRENT_FISCAL_YEAR = 'currentFiscalYear'; + public const LAST_FISCAL_YEAR = 'lastFiscalYear'; + public const CURRENT_FISCAL_QUARTER = 'currentFiscalQuarter'; + public const LAST_FISCAL_QUARTER = 'lastFiscalQuarter'; + public const ARRAY_ANY_OF = 'arrayAnyOf'; + public const ARRAY_NONE_OF = 'arrayNoneOf'; + public const ARRAY_ALL_OF = 'arrayAllOf'; + public const ARRAY_IS_EMPTY = 'arrayIsEmpty'; + public const ARRAY_IS_NOT_EMPTY = 'arrayIsNotEmpty'; + + /** + * Is linked with at least one of provided values (Any Of). + */ + public const string IS_LINKED_WITH = 'linkedWith'; + + /** + * Is not linked with any of provided values (None Of). + */ + public const string IS_NOT_LINKED_WITH = 'notLinkedWith'; + + /** + * Is linked with all of provided values (All Of). + */ + public const string IS_LINKED_WITH_ALL = 'linkedWithAll'; + + /** + * Is linked with anything (Is Not Empty). + */ + public const string IS_LINKED_WITH_ANY = 'isLinked'; + + + /** + * Is not linked with anything (Is Empty). + */ + public const string IS_LINKED_WITH_NONE = 'isNotLinked'; +} diff --git a/application/Espo/Core/Select/Where/ItemBuilder.php b/application/Espo/Core/Select/Where/ItemBuilder.php new file mode 100644 index 0000000..b4b1957 --- /dev/null +++ b/application/Espo/Core/Select/Where/ItemBuilder.php @@ -0,0 +1,124 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Select\Where\Item\Data; + +/** + * A where-item builder. + */ +class ItemBuilder +{ + private ?string $type = null; + private ?string $attribute = null; + /** @var mixed */ + private $value = null; + private ?Data $data = null; + + public static function create(): self + { + return new self(); + } + + /** + * Set a type. + * + * @param (Item\Type::*)|string $type + * @return $this + * @noinspection PhpDocSignatureInspection + */ + public function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + /** + * Set a value. + * + * @param mixed $value + */ + public function setValue($value): self + { + $this->value = $value; + + return $this; + } + + /** + * Set an attribute. + */ + public function setAttribute(?string $attribute): self + { + $this->attribute = $attribute; + + return $this; + } + + /** + * Set data. + */ + public function setData(?Data $data): self + { + $this->data = $data; + + return $this; + } + + /** + * Set nested where item list. + * + * @param Item[] $itemList + * @return self + */ + public function setItemList(array $itemList): self + { + $this->value = array_map( + function (Item $item): array { + return $item->getRaw(); + }, + $itemList + ); + + return $this; + } + + public function build(): Item + { + return Item + ::fromRaw([ + 'type' => $this->type, + 'attribute' => $this->attribute, + 'value' => $this->value, + ]) + ->withData($this->data); + } +} diff --git a/application/Espo/Core/Select/Where/ItemConverter.php b/application/Espo/Core/Select/Where/ItemConverter.php new file mode 100644 index 0000000..7092d2a --- /dev/null +++ b/application/Espo/Core/Select/Where/ItemConverter.php @@ -0,0 +1,45 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Exceptions\BadRequest; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder; + +/** + * Converts a where item to a where clause item (for ORM). + */ +interface ItemConverter +{ + /** + * @throws BadRequest + */ + public function convert(SelectBuilder $queryBuilder, Item $item): WhereClauseItem; +} diff --git a/application/Espo/Core/Select/Where/ItemConverterFactory.php b/application/Espo/Core/Select/Where/ItemConverterFactory.php new file mode 100644 index 0000000..3e453a8 --- /dev/null +++ b/application/Espo/Core/Select/Where/ItemConverterFactory.php @@ -0,0 +1,118 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Binding\Binder; +use Espo\Core\Binding\BindingContainer; +use Espo\Core\Binding\BindingData; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Metadata; + +use Espo\Entities\User; +use RuntimeException; + +class ItemConverterFactory +{ + public function __construct(private InjectableFactory $injectableFactory, private Metadata $metadata) + {} + + public function hasForType(string $type): bool + { + return (bool) $this->getClassNameForType($type); + } + + public function createForType(string $type, string $entityType, User $user): ItemConverter + { + $className = $this->getClassNameForType($type); + + if (!$className) { + throw new RuntimeException("Where item converter class name is not defined."); + } + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindInstance(User::class, $user); + + $binder + ->for($className) + ->bindValue('$entityType', $entityType); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + /** + * @return ?class-string + */ + protected function getClassNameForType(string $type): ?string + { + return $this->metadata->get(['app', 'select', 'whereItemConverterClassNameMap', $type]); + } + + public function has(string $entityType, string $attribute, string $type): bool + { + return (bool) $this->getClassName($entityType, $attribute, $type); + } + + public function create(string $entityType, string $attribute, string $type, User $user): ItemConverter + { + $className = $this->getClassName($entityType, $attribute, $type); + + if (!$className) { + throw new RuntimeException("Where item converter class name is not defined."); + } + + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + $binder + ->bindInstance(User::class, $user); + $binder + ->for($className) + ->bindValue('$entityType', $entityType); + + $bindingContainer = new BindingContainer($bindingData); + + return $this->injectableFactory->createWithBinding($className, $bindingContainer); + } + + /** + * @return ?class-string + */ + protected function getClassName(string $entityType, string $attribute, string $type): ?string + { + return $this->metadata + ->get(['selectDefs', $entityType, 'whereItemConverterClassNameMap', $attribute . '_' . $type]); + } +} diff --git a/application/Espo/Core/Select/Where/ItemConverters/InCategory.php b/application/Espo/Core/Select/Where/ItemConverters/InCategory.php new file mode 100644 index 0000000..0f9311d --- /dev/null +++ b/application/Espo/Core/Select/Where/ItemConverters/InCategory.php @@ -0,0 +1,116 @@ +. + * + * 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\Select\Where\ItemConverters; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Expression as Expr; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * @noinspection PhpUnused + */ +class InCategory implements ItemConverter +{ + public function __construct( + private string $entityType, + private Defs $ormDefs + ) {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $link = $item->getAttribute(); + $value = $item->getValue(); + + if (!$link) { + throw new BadRequest("No attribute."); + } + + if ($value === null) { + return WhereClause::create(); + } + + if (!is_array($value)) { + $value = [$value]; + } + + foreach ($value as $it) { + if (!is_string($it) && !is_int($it)) { + throw new BadRequest("Bad where item. Bad array item."); + } + } + + $entityDefs = $this->ormDefs->getEntity($this->entityType); + + if (!$entityDefs->hasRelation($link)) { + throw new BadRequest("Not existing '$link' in where item."); + } + + $defs = $entityDefs->getRelation($link); + + $path = lcfirst($defs->getForeignEntityType()) . 'Path'; + + if ($defs->getType() === Entity::MANY_MANY) { + $middle = ucfirst($defs->getRelationshipName()); + + return Cond::in( + Expr::column('id'), + QueryBuilder::create() + ->from($middle, 'm') + ->select($defs->getMidKey()) + ->join( + ucfirst($path), + $path, + ["$path.descendorId:" => "m.{$defs->getForeignMidKey()}"] + ) + ->where(["$path.ascendorId" => $value]) + ->build() + ); + } + + if ($defs->getType() === Entity::BELONGS_TO) { + $queryBuilder->join( + ucfirst($path), + $path, + ["$path.descendorId:" => $defs->getKey()] + ); + + return WhereClause::fromRaw(["$path.ascendorId" => $value]); + } + + throw new BadRequest("Not supported link '$link' in where item."); + } +} diff --git a/application/Espo/Core/Select/Where/ItemConverters/IsUserFromTeams.php b/application/Espo/Core/Select/Where/ItemConverters/IsUserFromTeams.php new file mode 100644 index 0000000..389642b --- /dev/null +++ b/application/Espo/Core/Select/Where/ItemConverters/IsUserFromTeams.php @@ -0,0 +1,107 @@ +. + * + * 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\Select\Where\ItemConverters; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Select\Where\Item; +use Espo\Core\Select\Where\ItemConverter; +use Espo\Entities\Team; +use Espo\Entities\User; +use Espo\ORM\Defs; +use Espo\ORM\Entity; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\SelectBuilder; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * @noinspection PhpUnused + */ +class IsUserFromTeams implements ItemConverter +{ + public function __construct( + private string $entityType, + private Defs $ormDefs, + ) {} + + public function convert(QueryBuilder $queryBuilder, Item $item): WhereClauseItem + { + $link = $item->getAttribute(); + $value = $item->getValue(); + + if (!$link) { + throw new BadRequest("No attribute."); + } + + if ($value === null) { + return WhereClause::create(); + } + + if (!is_array($value)) { + $value = [$value]; + } + + foreach ($value as $it) { + if (!is_string($it) && !is_int($it)) { + throw new BadRequest("Bad where item. Bad array item."); + } + } + + $entityDefs = $this->ormDefs->getEntity($this->entityType); + + if (!$entityDefs->hasRelation($link)) { + throw new BadRequest("Not existing '$link' in where item."); + } + + $defs = $entityDefs->getRelation($link); + + $relationType = $defs->getType(); + $entityType = $defs->getForeignEntityType(); + + if ($entityType !== User::ENTITY_TYPE) { + throw new BadRequest("Not supported link '$link' in where item."); + } + + if ($relationType === Entity::BELONGS_TO) { + return Condition::in( + Expression::column($defs->getKey()), + SelectBuilder::create() + ->from(Team::RELATIONSHIP_TEAM_USER, 'sq') + ->select('userId') + ->where(['teamId' => $value]) + ->build() + ); + } + + throw new BadRequest("Not supported link '$link' in where item."); + } +} diff --git a/application/Espo/Core/Select/Where/ItemGeneralConverter.php b/application/Espo/Core/Select/Where/ItemGeneralConverter.php new file mode 100644 index 0000000..acbd813 --- /dev/null +++ b/application/Espo/Core/Select/Where/ItemGeneralConverter.php @@ -0,0 +1,1713 @@ +. + * + * 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\Select\Where; + +use DateTimeZone; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Field\DateTime; +use Espo\Core\Select\Where\Item\Data; +use Espo\Core\Select\Where\Item\Type; +use Espo\Core\Select\Helpers\RandomStringGenerator; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DateTime as DateTimeUtil; +use Espo\Core\Utils\Metadata; +use Espo\Entities\ArrayValue; +use Espo\Entities\User; +use Espo\ORM\Defs as ORMDefs; +use Espo\ORM\Defs\Params\AttributeParam; +use Espo\ORM\Entity; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Join; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem as WhereClauseItem; +use Espo\ORM\Query\Select; +use Espo\ORM\Query\SelectBuilder; + +use Exception; +use RuntimeException; + +/** + * Converts a where item to a where clause (for ORM). + */ +class ItemGeneralConverter implements ItemConverter +{ + public function __construct( + private string $entityType, + private User $user, + private DateTimeItemTransformer $dateTimeItemTransformer, + private Scanner $scanner, + private ItemConverterFactory $itemConverterFactory, + private RandomStringGenerator $randomStringGenerator, + private ORMDefs $ormDefs, + private Config $config, + private Metadata $metadata, + private Config\ApplicationConfig $applicationConfig, + ) {} + + /** + * @throws BadRequest + */ + public function convert(SelectBuilder $queryBuilder, Item $item): WhereClauseItem + { + $type = $item->getType(); + $value = $item->getValue(); + $attribute = $item->getAttribute(); + $data = $item->getData(); + + if ($data instanceof Item\Data\DateTime) { + return $this->convert( + $queryBuilder, + $this->dateTimeItemTransformer->transform($item) + ); + } + + if (!$type) { + throw new BadRequest("Bad where item. No 'type'."); + } + + if ( + $attribute && + $this->itemConverterFactory->has($this->entityType, $attribute, $type) + ) { + + $converter = $this->itemConverterFactory->create( + $this->entityType, + $attribute, + $type, + $this->user + ); + + return $converter->convert($queryBuilder, $item); + } + + switch ($type) { + case Type::OR: + case Type::AND: + return WhereClause::fromRaw($this->groupProcessAndOr($queryBuilder, $type, $value)); + + case Type::NOT: + case Type::SUBQUERY_NOT_IN: + case Type::SUBQUERY_IN: + return WhereClause::fromRaw($this->groupProcessSubQuery($type, $value)); + } + + if (!$attribute) { + throw new BadRequest("Bad where item. No 'attribute'."); + } + + switch ($type) { + // @todo Revise. Maybe to use regular like, in, etc. types? + case 'columnLike': + case 'columnIn': + case 'columnNotIn': + case 'columnIsNull': + case 'columnIsNotNull': + case 'columnEquals': + case 'columnNotEquals': + return WhereClause::fromRaw($this->groupProcessColumn($type, $attribute, $value)); + + case Type::ARRAY_ANY_OF: + case Type::ARRAY_NONE_OF: + case Type::ARRAY_IS_EMPTY: + case Type::ARRAY_ALL_OF: + case Type::ARRAY_IS_NOT_EMPTY: + return WhereClause::fromRaw($this->groupProcessArray($queryBuilder, $type, $attribute, $value)); + } + + if ($type === Type::IN) { + return WhereClause::fromRaw($this->processIn($attribute, $value)); + } + + if ($type === Type::NOT_IN) { + return WhereClause::fromRaw($this->processNotIn($attribute, $value)); + } + + if ($type === Type::IS_LINKED_WITH) { + return WhereClause::fromRaw($this->processLinkedWith($attribute, $value)); + } + + if ($type === Type::IS_NOT_LINKED_WITH) { + return WhereClause::fromRaw($this->processNotLinkedWith($attribute, $value)); + } + + if ($type === Type::IS_LINKED_WITH_ALL) { + return WhereClause::fromRaw($this->processLinkedWithAll($attribute, $value)); + } + + if ($type === Type::IS_LINKED_WITH_ANY) { + return WhereClause::fromRaw($this->processIsLinked($attribute)); + } + + if ($type === Type::IS_LINKED_WITH_NONE) { + return WhereClause::fromRaw($this->processIsNotLinked($attribute)); + } + + if ($type === Type::EXPRESSION) { + return WhereClause::fromRaw($this->processExpression($attribute)); + } + + if ($type === Type::EQUALS) { + return WhereClause::fromRaw($this->processEquals($attribute, $value)); + } + + if ($type === Type::NOT_EQUALS) { + return WhereClause::fromRaw($this->processNotEquals($attribute, $value)); + } + + if ($type === Type::ON) { + return WhereClause::fromRaw($this->processOn($attribute, $value)); + } + + if ($type === Type::NOT_ON) { + return WhereClause::fromRaw($this->processNotOn($attribute, $value)); + } + + if ($type === Type::EVER) { + return WhereClause::fromRaw($this->processEver($attribute)); + } + + if ($type === Type::TODAY) { + return WhereClause::fromRaw($this->processToday($attribute, $item->getData())); + } + + if ($type === Type::PAST) { + return WhereClause::fromRaw($this->processPast($attribute, $item->getData())); + } + + if ($type === Type::FUTURE) { + return WhereClause::fromRaw($this->processFuture($attribute, $item->getData())); + } + + if ($type === Type::LAST_SEVEN_DAYS) { + return WhereClause::fromRaw($this->processLastSevenDays($attribute, $item->getData())); + } + + if ($type === Type::LAST_X_DAYS) { + return WhereClause::fromRaw($this->processLastXDays($attribute, $value, $item->getData())); + } + + if ($type === Type::NEXT_X_DAYS) { + return WhereClause::fromRaw($this->processNextXDays($attribute, $value, $item->getData())); + } + + if ($type === Type::OLDER_THAN_X_DAYS) { + return WhereClause::fromRaw($this->processOlderThanXDays($attribute, $value, $item->getData())); + } + + if ($type === Type::AFTER_X_DAYS) { + return WhereClause::fromRaw($this->processAfterXDays($attribute, $value, $item->getData())); + } + + if ($type === Type::CURRENT_MONTH) { + return WhereClause::fromRaw($this->processCurrentMonth($attribute)); + } + + if ($type === Type::LAST_MONTH) { + return WhereClause::fromRaw($this->processLastMonth($attribute)); + } + + if ($type === Type::NEXT_MONTH) { + return WhereClause::fromRaw($this->processNextMonth($attribute)); + } + + if ($type === Type::CURRENT_QUARTER) { + return WhereClause::fromRaw($this->processCurrentQuarter($attribute)); + } + + if ($type === Type::LAST_QUARTER) { + return WhereClause::fromRaw($this->processLastQuarter($attribute)); + } + + if ($type === Type::CURRENT_YEAR) { + return WhereClause::fromRaw($this->processCurrentYear($attribute)); + } + + if ($type === Type::LAST_YEAR) { + return WhereClause::fromRaw($this->processLastYear($attribute)); + } + + if ($type === Type::CURRENT_FISCAL_YEAR) { + return WhereClause::fromRaw($this->processCurrentFiscalYear($attribute)); + } + + if ($type === Type::LAST_FISCAL_YEAR) { + return WhereClause::fromRaw($this->processLastFiscalYear($attribute)); + } + + if ($type === Type::CURRENT_FISCAL_QUARTER) { + return WhereClause::fromRaw($this->processCurrentFiscalQuarter($attribute)); + } + + if ($type === Type::LAST_FISCAL_QUARTER) { + return WhereClause::fromRaw($this->processLastFiscalQuarter($attribute)); + } + + if ($type === Type::BEFORE) { + return WhereClause::fromRaw($this->processBefore($attribute, $value)); + } + + if ($type === Type::AFTER) { + return WhereClause::fromRaw($this->processAfter($attribute, $value)); + } + + if ($type === Type::BETWEEN) { + return WhereClause::fromRaw($this->processBetween($attribute, $value)); + } + + if ($type === Type::LIKE) { + return WhereClause::fromRaw($this->processLike($attribute, $value)); + } + + if ($type === Type::NOT_LIKE) { + return WhereClause::fromRaw($this->processNotLike($attribute, $value)); + } + + if ($type === Type::IS_NULL) { + return WhereClause::fromRaw($this->processIsNull($attribute)); + } + + if ($type === Type::NONE) { + return WhereClause::fromRaw($this->processNone()); + } + + if ($type === Type::ANY) { + return WhereClause::fromRaw($this->processAny()); + } + + if ($type === Type::IS_NOT_NULL) { + return WhereClause::fromRaw($this->processIsNotNull($attribute)); + } + + if ($type === Type::IS_TRUE) { + return WhereClause::fromRaw($this->processIsTrue($attribute)); + } + + if ($type === Type::IS_FALSE) { + return WhereClause::fromRaw($this->processIsFalse($attribute)); + } + + if ($type === Type::STARTS_WITH) { + return WhereClause::fromRaw($this->processStartsWith($attribute, $value)); + } + + if ($type === Type::ENDS_WITH) { + return WhereClause::fromRaw($this->processEndsWith($attribute, $value)); + } + + if ($type === Type::CONTAINS) { + return WhereClause::fromRaw($this->processContains($attribute, $value)); + } + + if ($type === Type::NOT_CONTAINS) { + return WhereClause::fromRaw($this->processNotContains($attribute, $value)); + } + + if ($type === Type::GREATER_THAN) { + return WhereClause::fromRaw($this->processGreaterThan($attribute, $value)); + } + + if ($type === Type::LESS_THAN) { + return WhereClause::fromRaw($this->processLessThan($attribute, $value)); + } + + if ($type === Type::GREATER_THAN_OR_EQUALS) { + return WhereClause::fromRaw($this->processGreaterThanOrEquals($attribute, $value)); + } + + if ($type === Type::LESS_THAN_OR_EQUALS) { + return WhereClause::fromRaw($this->processLessThanOrEquals($attribute, $value)); + } + + if (!$this->itemConverterFactory->hasForType($type)) { + throw new BadRequest("Unknown where item type '$type'."); + } + + $converter = $this->itemConverterFactory->createForType($type, $this->entityType, $this->user); + + return $converter->convert($queryBuilder, $item); + } + + /** + * @param mixed $value + * @return array + * @throws BadRequest + */ + private function groupProcessAndOr(SelectBuilder $queryBuilder, string $type, $value): array + { + if (!is_array($value)) { + throw new BadRequest("Bad where item."); + } + + $whereClause = []; + + foreach ($value as $item) { + $subPart = $this->convert($queryBuilder, Item::fromRaw($item))->getRaw(); + + foreach ($subPart as $left => $right) { + if (!empty($right) || is_null($right) || $right === '' || $right === 0 || $right === false) { + $whereClause[] = [ + $left => $right, + ]; + } + } + } + + return [ + strtoupper($type) => $whereClause, + ]; + } + + /** + * @param mixed $value + * @return array + * @throws BadRequest + */ + private function groupProcessSubQuery(string $type, $value): array + { + if (!is_array($value)) { + throw new BadRequest("Bad where item."); + } + + $sqQueryBuilder = SelectBuilder::create() + ->from($this->entityType); + + $whereItem = Item::fromRaw([ + 'type' => Type::AND, + 'value' => $value, + ]); + + $whereClause = $this->convert($sqQueryBuilder, $whereItem)->getRaw(); + + $this->scanner->apply($sqQueryBuilder, $whereItem); + + $rawParams = $sqQueryBuilder->build()->getRaw(); + + $key = $type === Type::SUBQUERY_IN ? 'id=s' : 'id!=s'; + + return [ + $key => Select::fromRaw([ + 'select' => [Attribute::ID], + 'from' => $this->entityType, + 'whereClause' => $whereClause, + 'leftJoins' => $rawParams['leftJoins'] ?? [], + 'joins' => $rawParams['joins'] ?? [], + ]), + ]; + } + + /** + * @return array + * @throws BadRequest + */ + private function groupProcessColumn( + string $type, + string $attribute, + mixed $value + ): array { + + $link = $this->metadata->get(['entityDefs', $this->entityType, 'fields', $attribute, 'link']); + $column = $this->metadata->get(['entityDefs', $this->entityType, 'fields', $attribute, 'column']); + + if (!$column || !$link) { + throw new BadRequest("Bad where item 'column'."); + } + + $subQueryBuilder = SelectBuilder::create() + ->from($this->entityType) + ->select(Attribute::ID) + ->leftJoin($link); + + $alias = $link; + $expr = "{$alias}Middle.$column"; + + if ($type === 'columnLike') { + $where = [$expr . '*' => $value]; + } else if ($type === 'columnIn') { + $where = [$expr . '=' => $value]; + } else if ($type === 'columnEquals') { + $where = [$expr . '=' => $value]; + } else if ($type === 'columnNotEquals') { + $where = [$expr . '!=' => $value]; + } else if ($type === 'columnNotIn') { + $where = [$expr . '!=' => $value]; + } else if ($type === 'columnIsNull') { + $where = [$expr . '=' => null]; + } else if ($type === 'columnIsNotNull') { + $where = [$expr . '!=' => null]; + } else { + throw new BadRequest("Bad where item 'column'."); + } + + return Cond::in( + Cond::column(Attribute::ID), + $subQueryBuilder + ->where($where) + ->build() + )->getRaw(); + } + + /** + * @param mixed $value + * @return array + * @throws BadRequest + */ + private function groupProcessArray( + SelectBuilder $queryBuilder, + string $type, + string $attribute, + $value + ): array { + + $arrayAttribute = $attribute; + $arrayEntityType = $this->entityType; + $idPart = Attribute::ID; + + $isForeign = str_contains($attribute, '.'); + + $isForeignType = false; + + $entityDefs = $this->ormDefs->getEntity($this->entityType); + + if (!$isForeign) { + $isForeignType = $entityDefs->getAttribute($attribute)->getType() === Entity::FOREIGN; + $isForeign = $isForeignType; + } + + if ($isForeign) { + if ($isForeignType) { + $arrayLink = $entityDefs->getAttribute($attribute)->getParam(AttributeParam::RELATION); + $arrayAttribute = $entityDefs->getAttribute($attribute)->getParam(AttributeParam::FOREIGN); + } else { + [$arrayLink, $arrayAttribute] = explode('.', $attribute); + } + + if (!$arrayLink || !$arrayAttribute) { + throw new BadRequest("Bad where item."); + } + + $arrayEntityType = $entityDefs->getRelation($arrayLink)->getForeignEntityType(); + $arrayAlias = "{$arrayLink}ArrayFilter{$this->randomStringGenerator->generate()}"; + $idPart = "$arrayAlias.id"; + + $queryBuilder->leftJoin($arrayLink, $arrayAlias); + + // A sub-query is supposed to be used. + /* + $relationType = $entityDefs->getRelation($arrayLink)->getType(); + + if ( + in_array($relationType, [ + Entity::MANY_MANY, + Entity::HAS_MANY, + Entity::HAS_CHILDREN, + ]) + ) { + $queryBuilder->distinct(); + } + */ + } + + if ($type === Type::ARRAY_ANY_OF) { + if (!$value && !is_array($value)) { + throw new BadRequest("Bad where item. No value."); + } + + $subQuery = SelectBuilder::create() + ->select('entityId') + ->from(ArrayValue::ENTITY_TYPE) + ->where([ + 'entityType' => $arrayEntityType, + 'attribute' => $arrayAttribute, + 'value' => $value, + ]) + ->build(); + + return [$idPart . '=s' => $subQuery->getRaw()]; + } + + if ($type === Type::ARRAY_NONE_OF) { + if (!$value && !is_array($value)) { + throw new BadRequest("Bad where item 'array'. No value."); + } + + return Cond::not( + Cond::exists( + SelectBuilder::create() + ->select('entityId') + ->from(ArrayValue::ENTITY_TYPE) + ->where([ + 'entityType' => $arrayEntityType, + 'attribute' => $arrayAttribute, + 'value' => $value, + 'entityId:' => lcfirst($arrayEntityType) . '.id' + ]) + ->build() + ) + )->getRaw(); + } + + if ($type === Type::ARRAY_IS_EMPTY) { + return Cond::not( + Cond::exists( + SelectBuilder::create() + ->select('entityId') + ->from(ArrayValue::ENTITY_TYPE) + ->where([ + 'entityType' => $arrayEntityType, + 'attribute' => $arrayAttribute, + 'entityId:' => lcfirst($arrayEntityType) . '.id' + ]) + ->build() + ) + )->getRaw(); + } + + if ($type === Type::ARRAY_IS_NOT_EMPTY) { + $subQuery = SelectBuilder::create() + ->select('entityId') + ->from(ArrayValue::ENTITY_TYPE) + ->where([ + 'entityType' => $arrayEntityType, + 'attribute' => $arrayAttribute, + ]) + ->build(); + + return [$idPart . '=s' => $subQuery]; + } + + if ($type === Type::ARRAY_ALL_OF) { + if (!$value && !is_array($value)) { + throw new BadRequest("Bad where item 'array'. No value."); + } + + if (!is_array($value)) { + $value = [$value]; + } + + $whereList = []; + + foreach ($value as $arrayValue) { + $whereList[] = [ + $idPart .'=s' => SelectBuilder::create() + ->from(ArrayValue::ENTITY_TYPE) + ->select('entityId') + ->where([ + 'value' => $arrayValue, + 'attribute' => $arrayAttribute, + 'entityType' => $arrayEntityType, + Attribute::DELETED => false, + ]) + ->build() + ]; + } + + return $whereList; + } + + throw new BadRequest("Bad where item 'array'."); + } + + /** + * A complex expression w/o a value. + * + * @return array + */ + private function processExpression(string $attribute): array + { + $key = $attribute; + + if (!str_ends_with($key, ':')) { + $key .= ':'; + } + + return [ + $key => null, + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processLike(string $attribute, $value): array + { + return [ + $attribute . '*' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processNotLike(string $attribute, $value): array + { + return [ + $attribute . '!*' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processEquals(string $attribute, $value): array + { + return [ + $attribute . '=' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processOn(string $attribute, $value): array + { + return $this->processEquals($attribute, $value); + } + + /** + * @param mixed $value + * @return array + */ + private function processNotEquals(string $attribute, $value): array + { + return [ + $attribute . '!=' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processNotOn(string $attribute, $value): array + { + return $this->processNotEquals($attribute, $value); + } + + /** + * @param mixed $value + * @return array + */ + private function processStartsWith(string $attribute, $value): array + { + return [ + $attribute . '*' => $value . '%', + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processEndsWith(string $attribute, $value): array + { + return [ + $attribute . '*' => '%' . $value, + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processContains(string $attribute, $value): array + { + return [ + $attribute . '*' => '%' . $value . '%', + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processNotContains(string $attribute, $value): array + { + return [ + $attribute . '!*' => '%' . $value . '%', + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processGreaterThan(string $attribute, $value): array + { + return [ + $attribute . '>' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processAfter(string $attribute, $value): array + { + return $this->processGreaterThan($attribute, $value); + } + + /** + * @param mixed $value + * @return array + */ + private function processLessThan(string $attribute, $value): array + { + return [ + $attribute . '<' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processBefore(string $attribute, $value): array + { + return $this->processLessThan($attribute, $value); + } + + /** + * @param mixed $value + * @return array + */ + private function processGreaterThanOrEquals(string $attribute, $value): array + { + return [ + $attribute . '>=' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + */ + private function processLessThanOrEquals(string $attribute, $value): array + { + return [ + $attribute . '<=' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + * @throws BadRequest + */ + private function processIn(string $attribute, $value): array + { + if (!is_array($value)) { + throw new BadRequest("Bad where item 'in'."); + } + + return [ + $attribute . '=' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + * @throws BadRequest + */ + private function processNotIn(string $attribute, $value): array + { + if (!is_array($value)) { + throw new BadRequest("Bad where item 'notIn'."); + } + + return [ + $attribute . '!=' => $value, + ]; + } + + /** + * @param mixed $value + * @return array + * @throws BadRequest + */ + private function processBetween(string $attribute, $value): array + { + if (!is_array($value) || count($value) < 2) { + throw new BadRequest("Bad where item 'between'."); + } + + return [ + 'AND' => [ + $attribute . '>=' => $value[0], + $attribute . '<=' => $value[1], + ] + ]; + } + + /** + * @return array + */ + private function processAny(): array + { + return [ + 'true:' => null, + ]; + } + + /** + * @return array + */ + private function processNone(): array + { + return [ + 'false:' => null, + ]; + } + + /** + * @return array + */ + private function processIsNull(string $attribute): array + { + return [ + $attribute . '=' => null, + ]; + } + + /** + * @return array + */ + private function processIsNotNull(string $attribute): array + { + return [ + $attribute . '!=' => null, + ]; + } + + /** + * @return array + */ + private function processEver(string $attribute): array + { + return $this->processIsNotNull($attribute); + } + + /** + * @return array + */ + private function processIsTrue(string $attribute): array + { + return [ + $attribute . '=' => true, + ]; + } + + /** + * @return array + */ + private function processIsFalse(string $attribute): array + { + return [ + $attribute . '=' => false, + ]; + } + + /** + * @return array + * @throws BadRequest + */ + private function processToday(string $attribute, ?Data $data): array + { + $timeZone = $this->getTimeZone($data); + $today = DateTime::createNow()->withTimezone($timeZone); + + return [ + $attribute . '=' => $today->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ]; + } + + /** + * @return array + * @throws BadRequest + */ + private function processPast(string $attribute, ?Data $data): array + { + $timeZone = $this->getTimeZone($data); + $today = DateTime::createNow()->withTimezone($timeZone); + + return [ + $attribute . '<' => $today->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ]; + } + + /** + * @return array + * @throws BadRequest + */ + private function processFuture(string $attribute, ?Data $data): array + { + $timeZone = $this->getTimeZone($data); + $today = DateTime::createNow()->withTimezone($timeZone); + + return [ + $attribute . '>' => $today->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ]; + } + + /** + * @return array + * @throws BadRequest + */ + private function processLastSevenDays(string $attribute, ?Data $data): array + { + $timeZone = $this->getTimeZone($data); + $today = DateTime::createNow()->withTimezone($timeZone); + + $from = $today->addDays(-7); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<=' => $today->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + * @throws BadRequest + */ + private function processLastXDays(string $attribute, mixed $value, ?Data $data): array + { + $timeZone = $this->getTimeZone($data); + $today = DateTime::createNow()->withTimezone($timeZone); + + $number = intval($value); + + $from = $today->addDays(- $number); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<=' => $today->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + * @throws BadRequest + */ + private function processNextXDays(string $attribute, mixed $value, ?Data $data): array + { + $timeZone = $this->getTimeZone($data); + $today = DateTime::createNow()->withTimezone($timeZone); + + $number = intval($value); + + $to = $today->addDays($number); + + return [ + 'AND' => [ + $attribute . '>=' => $today->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<=' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + * @throws BadRequest + */ + private function processOlderThanXDays(string $attribute, mixed $value, ?Data $data): array + { + $timeZone = $this->getTimeZone($data); + $today = DateTime::createNow()->withTimezone($timeZone); + + $number = intval($value); + + $date = $today->addDays(- $number); + + return [ + $attribute . '<' => $date->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ]; + } + + /** + * @return array + * @throws BadRequest + */ + private function processAfterXDays(string $attribute, mixed $value, ?Data $data): array + { + $timeZone = $this->getTimeZone($data); + $today = DateTime::createNow()->withTimezone($timeZone); + + $number = intval($value); + + $date = $today->addDays($number); + + return [ + $attribute . '>' => $date->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ]; + } + + /** + * @return array + */ + private function processCurrentMonth(string $attribute): array + { + $from = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()) + ->modify('first day of this month'); + + $to = $from->addMonths(1); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processLastMonth( string $attribute): array + { + $from = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()) + ->modify('first day of last month'); + + $to = $from->addMonths(1); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processNextMonth(string $attribute): array + { + $from = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()) + ->modify('first day of next month'); + + $to = $from->addMonths(1); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processCurrentQuarter(string $attribute): array + { + $now = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()); + + $quarter = intval(ceil($now->getMonth() / 3)); + + $from = $now + ->modify('first day of January this year') + ->addMonths(($quarter - 1) * 3); + + $to = $from->addMonths(3); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processLastQuarter(string $attribute): array + { + $now = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()); + + $quarter = intval(ceil($now->getMonth() / 3)); + + $from = $now->modify('first day of January this year'); + + $quarter--; + + if ($quarter == 0) { + $quarter = 4; + + $from = $from->addYears(-1); + } + + $from = $from->addMonths(($quarter - 1) * 3); + $to = $from->addMonths(3); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processCurrentYear(string $attribute): array + { + $from = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()) + ->modify('first day of January this year'); + + $to = $from->addYears(1); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processLastYear(string $attribute): array + { + $from = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()) + ->modify('first day of January last year'); + + $to = $from->addYears(1); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processCurrentFiscalYear(string $attribute): array + { + $fiscalYearShift = $this->config->get('fiscalYearShift', 0); + + $now = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()); + + $from = $now + ->modify('first day of January this year') + ->addMonths($fiscalYearShift); + + if ($now->getMonth() < $fiscalYearShift + 1) { + $from = $from->addYears(-1); + } + + $to = $from->addYears(1); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processLastFiscalYear(string $attribute): array + { + $fiscalYearShift = $this->config->get('fiscalYearShift', 0); + + $now = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()); + + $from = $now + ->modify('first day of January this year') + ->addMonths($fiscalYearShift) + ->addYears(-1); + + if ($now->getMonth() < $fiscalYearShift + 1) { + $from = $from->addYears(-1); + } + + $to = $from->addYears(1); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processCurrentFiscalQuarter(string $attribute): array + { + $fiscalYearShift = $this->config->get('fiscalYearShift', 0); + + $now = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()); + + $quarterShift = (int) floor(($now->getMonth() - $fiscalYearShift - 1) / 3); + + $from = $now + ->modify('first day of January this year') + ->addMonths($fiscalYearShift) + ->addMonths($quarterShift * 3); + + $to = $from->addMonths(3); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processLastFiscalQuarter(string $attribute): array + { + $fiscalYearShift = $this->config->get('fiscalYearShift', 0); + + $now = DateTime::createNow() + ->withTimezone($this->getSystemTimeZone()); + + $quarterShift = (int) floor(($now->getMonth() - $fiscalYearShift - 1) / 3); + + $from = $now + ->modify('first day of January this year') + ->addMonths($fiscalYearShift) + ->addMonths($quarterShift * 3); + + $from = $from->addMonths(-3); + $to = $from->addMonths(3); + + return [ + 'AND' => [ + $attribute . '>=' => $from->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + $attribute . '<' => $to->toDateTime()->format(DateTimeUtil::SYSTEM_DATE_FORMAT), + ] + ]; + } + + /** + * @return array + */ + private function processIsNotLinked(string $attribute): array + { + $link = $attribute; + $alias = $link . 'IsLinkedFilter' . $this->randomStringGenerator->generate(); + + $defs = $this->ormDefs->getEntity($this->entityType)->getRelation($link); + + $relationType = $defs->getType(); + + if ($relationType == Entity::MANY_MANY) { + $key = $defs->getForeignMidKey(); + $nearKey = $defs->getMidKey(); + $middleEntityType = ucfirst($defs->getRelationshipName()); + + $conditions = [ + "$alias.$nearKey:" => Attribute::ID, + "$alias.deleted" => false, + ]; + + foreach ($defs->getConditions() as $k => $v) { + $conditions["$alias.$k"] = $v; + } + + // The foreign table is not joined as it would perform much slower. + // Trade off is that if a foreign record is deleted but the middle table + // is not yet deleted, it will give a non-actual result. + $subQuery = SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType) + ->leftJoin($middleEntityType, $alias, $conditions) + ->where(["$alias.$key" => null]) + ->build(); + + return ['id=s' => $subQuery]; + } + + if ( + $relationType == Entity::HAS_MANY || + $relationType == Entity::HAS_ONE || + $relationType == Entity::BELONGS_TO || + $relationType === Entity::HAS_CHILDREN + ) { + $subQuery = SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType) + ->leftJoin($link, $alias) + ->where([$alias . '.id' => null]) + ->build(); + + return ['id=s' => $subQuery]; + } + + throw new RuntimeException("Bad where item. Not supported relation type."); + } + + /** + * @return array + */ + private function processIsLinked(string $attribute): array + { + $link = $attribute; + $alias = $link . 'IsLinkedFilter' . $this->randomStringGenerator->generate(); + + $defs = $this->ormDefs->getEntity($this->entityType)->getRelation($link); + + $relationType = $defs->getType(); + + if ($relationType == Entity::MANY_MANY) { + $key = $defs->getForeignMidKey(); + $nearKey = $defs->getMidKey(); + $middleEntityType = ucfirst($defs->getRelationshipName()); + + $conditions = [ + "$alias.$nearKey:" => Attribute::ID, + "$alias.deleted" => false, + ]; + + foreach ($defs->getConditions() as $k => $v) { + $conditions["$alias.$k"] = $v; + } + + // The foreign table is not joined as it would perform much slower. + // Trade off is that if a foreign record is deleted but the middle table + // is not yet deleted, it will give a non-actual result. + $subQuery = SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType) + ->leftJoin($middleEntityType, $alias, $conditions) + ->where(["$alias.$key!=" => null]) + ->build(); + + return ['id=s' => $subQuery]; + } + + if ( + $relationType == Entity::HAS_MANY || + $relationType == Entity::HAS_ONE || + $relationType == Entity::BELONGS_TO || + $relationType == Entity::HAS_CHILDREN + ) { + $subQuery = SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType) + ->leftJoin($link, $alias) + ->where([$alias . '.id!=' => null]) + ->build(); + + return ['id=s' => $subQuery]; + } + + throw new RuntimeException("Bad where item. Not supported relation type."); + } + + /** + * @param mixed $value + * @return array + * @throws BadRequest + */ + private function processLinkedWith(string $attribute, $value): array + { + $link = $attribute; + + if (!$this->ormDefs->getEntity($this->entityType)->hasRelation($link)) { + throw new BadRequest("Not existing link '$link' in where item."); + } + + $defs = $this->ormDefs->getEntity($this->entityType)->getRelation($link); + + $alias = $link . 'LinkedWithFilter' . $this->randomStringGenerator->generate(); + + if (!is_array($value)) { + $value = [$value]; + } + + foreach ($value as $it) { + if (!is_string($it) && !is_int($it)) { + throw new BadRequest("Bad where item. Bad array item."); + } + } + + $relationType = $defs->getType(); + + if ($relationType == Entity::MANY_MANY) { + $key = $defs->getForeignMidKey(); + + // IN-sub-query performs faster than EXISTS on MariaDB when multiple IDs. + // Left-join performs faster than inner-join. + // Not joining a foreign table as it affects performance in MySQL. + // MariaDB and PostgreSQL perform fast, MySQL – slow. + return Cond::in( + Cond::column(Attribute::ID), + SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType) + ->leftJoin( + Join::create($link, $alias) + ->withOnlyMiddle() + ) + ->where(["$alias.$key" => $value]) + ->build() + )->getRaw(); + } + + if ( + $relationType == Entity::HAS_MANY || + $relationType == Entity::HAS_ONE + ) { + $subQuery = SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType) + ->leftJoin($link, $alias) + ->where([$alias . '.id' => $value]) + ->build(); + + return ['id=s' => $subQuery]; + } + + if ($relationType == Entity::BELONGS_TO) { + $key = $defs->getKey(); + + return [$key => $value]; + } + + throw new BadRequest("Bad where item. Not supported relation type."); + } + + /** + * @param mixed $value + * @return array + * @throws BadRequest + */ + private function processNotLinkedWith(string $attribute, $value): array + { + $link = $attribute; + + if (!$this->ormDefs->getEntity($this->entityType)->hasRelation($link)) { + throw new BadRequest("Not existing link '$link' in where item."); + } + + $defs = $this->ormDefs->getEntity($this->entityType)->getRelation($link); + + $alias = $link . 'NotLinkedWithFilter' . $this->randomStringGenerator->generate(); + + if (!is_array($value)) { + $value = [$value]; + } + + foreach ($value as $it) { + if (!is_string($it) && !is_int($it)) { + throw new BadRequest("Bad where item. Bad array item."); + } + } + + $relationType = $defs->getType(); + + if ($relationType == Entity::MANY_MANY) { + $key = $defs->getForeignMidKey(); + + // MariaDB and MySQL perform slow, PostgreSQL – fast. + return Cond::not( + Cond::exists( + SelectBuilder::create() + ->from($this->entityType, 'sq') + ->join( + Join::create($link, $alias) + ->withOnlyMiddle() + ) + ->where(["$alias.$key" => $value]) + ->where( + Cond::equal( + Cond::column('sq.id'), + Cond::column(lcfirst($this->entityType) . '.id') + ) + ) + ->build() + ) + )->getRaw(); + } + + if ( + $relationType == Entity::HAS_MANY || + $relationType == Entity::HAS_ONE + ) { + return Cond::not( + Cond::exists( + SelectBuilder::create() + ->select(Attribute::ID) + ->from($this->entityType, 'sq') + ->join($link, $alias) + ->where(["$alias.id" => $value]) + ->where(['sq.id:' => lcfirst($this->entityType) . '.id']) + ->build() + ) + )->getRaw(); + } + + if ($relationType == Entity::BELONGS_TO) { + $key = $defs->getKey(); + + return [$key . '!=' => $value]; + } + + throw new BadRequest("Bad where item. Not supported relation type."); + } + + /** + * @param mixed $value + * @return array + * @throws BadRequest + */ + private function processLinkedWithAll(string $attribute, $value): array + { + $link = $attribute; + + if (!$this->ormDefs->getEntity($this->entityType)->hasRelation($link)) { + throw new BadRequest("Not existing link '$link' in where item."); + } + + if (!$value && !is_array($value)) { + throw new BadRequest("Bad where item. Empty value."); + } + + if (!is_array($value)) { + $value = [$value]; + } + + foreach ($value as $it) { + if (!is_string($it) && !is_int($it)) { + throw new BadRequest("Bad where item. Bad array item."); + } + } + + $defs = $this->ormDefs->getEntity($this->entityType)->getRelation($link); + + $relationType = $defs->getType(); + + if ($relationType === Entity::MANY_MANY) { + $key = $defs->getForeignMidKey(); + + $whereList = []; + + foreach ($value as $targetId) { + // Only-middle join performs slower on MariaDB. + $sq = SelectBuilder::create() + ->from($this->entityType) + ->select(Attribute::ID) + ->leftJoin($link) + ->where([ + $link . 'Middle.' . $key => $targetId, + ]) + ->build(); + + $whereList[] = [Attribute::ID . '=s' => $sq]; + } + + return $whereList; + } + + if ($relationType === Entity::HAS_MANY) { + $whereList = []; + + foreach ($value as $targetId) { + $sq = SelectBuilder::create() + ->from($this->entityType) + ->select(Attribute::ID) + ->leftJoin($link) + ->where([$link . '.id' => $targetId]) + ->build(); + + $whereList[] = [Attribute::ID . '=s' => $sq]; + } + + return $whereList; + } + + throw new BadRequest("Bad where item. Not supported relation type."); + } + + private function getSystemTimeZone(): DateTimeZone + { + $timeZone = $this->applicationConfig->getTimeZone(); + + try { + return new DateTimeZone($timeZone); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); + } + } + + /** + * @throws BadRequest + */ + private function getTimeZone(?Data $data): DateTimeZone + { + $timeZone = $data instanceof Data\Date ? $data->getTimeZone() : null; + + if (!$timeZone) { + return $this->getSystemTimeZone(); + } + + try { + return new DateTimeZone($timeZone); + } catch (Exception $e) { + throw new BadRequest($e->getMessage()); + } + } +} diff --git a/application/Espo/Core/Select/Where/Params.php b/application/Espo/Core/Select/Where/Params.php new file mode 100644 index 0000000..dde0c15 --- /dev/null +++ b/application/Espo/Core/Select/Where/Params.php @@ -0,0 +1,84 @@ +. + * + * 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\Select\Where; + +use InvalidArgumentException; + +/** + * Where parameters. + * + * Immutable. + */ +class Params +{ + private bool $applyPermissionCheck = false; + private bool $forbidComplexExpressions = false; + + private function __construct() + {} + + /** + * @param array{ + * applyPermissionCheck?: bool, + * forbidComplexExpressions?: bool, + * } $params + */ + public static function fromAssoc(array $params): self + { + $object = new self(); + + $object->applyPermissionCheck = $params['applyPermissionCheck'] ?? false; + $object->forbidComplexExpressions = $params['forbidComplexExpressions'] ?? false; + + foreach ($params as $key => $value) { + if (!property_exists($object, $key)) { + throw new InvalidArgumentException("Unknown parameter '$key'."); + } + } + + return $object; + } + + /** + * Apply permission check. + */ + public function applyPermissionCheck(): bool + { + return $this->applyPermissionCheck; + } + + /** + * Forbid complex expressions. + */ + public function forbidComplexExpressions(): bool + { + return $this->forbidComplexExpressions; + } +} diff --git a/application/Espo/Core/Select/Where/Scanner.php b/application/Espo/Core/Select/Where/Scanner.php new file mode 100644 index 0000000..bea1c75 --- /dev/null +++ b/application/Espo/Core/Select/Where/Scanner.php @@ -0,0 +1,233 @@ +. + * + * 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\Select\Where; + +use Espo\Core\Select\Where\Item\Type; +use Espo\ORM\Defs\Params\AttributeParam; +use Espo\ORM\EntityManager; +use Espo\ORM\Entity; +use Espo\ORM\BaseEntity; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; +use Espo\ORM\QueryComposer\Util as QueryComposerUtil; +use Espo\ORM\Type\RelationType; +use RuntimeException; + +/** + * Scans where items. + */ +class Scanner +{ + /** @var array */ + private array $seedHash = []; + + /** @var string[] */ + private $nestingTypeList = [ + Type::OR, + Type::AND, + ]; + + /** @var string[] */ + private array $subQueryTypeList = [ + Type::SUBQUERY_IN, + Type::SUBQUERY_NOT_IN, + Type::NOT, + ]; + + public function __construct(private EntityManager $entityManager) + {} + + /** + * Check whether at least one has-many link appears in the where-clause. + * + * @since 9.0.0 + */ + public function hasRelatedMany(string $entityType, Item $item): bool + { + $type = $item->getType(); + $attribute = $item->getAttribute(); + + if (in_array($type, $this->subQueryTypeList)) { + return false; + } + + if (in_array($type, $this->nestingTypeList)) { + foreach ($item->getItemList() as $subItem) { + if ($this->hasRelatedMany($entityType, $subItem)) { + return true; + } + } + + return false; + } + + if (!$attribute) { + return false; + } + + $seed = $this->getSeed($entityType); + + + foreach (QueryComposerUtil::getAllAttributesFromComplexExpression($attribute) as $expr) { + if (!str_contains($expr, '.')) { + continue; + } + + [$link,] = explode('.', $expr); + + if (!$seed->hasRelation($link)) { + continue; + } + + $isMany = in_array($seed->getRelationType($link), [ + RelationType::HAS_MANY, + RelationType::MANY_MANY, + RelationType::HAS_CHILDREN, + ]); + + if ($isMany) { + return true; + } + } + + return false; + } + + /** + * Apply needed joins to a query builder. + */ + public function apply(QueryBuilder $queryBuilder, Item $item): void + { + $entityType = $queryBuilder->build()->getFrom(); + + if (!$entityType) { + throw new RuntimeException("No entity type."); + } + + $this->applyLeftJoinsFromItem($queryBuilder, $item, $entityType); + } + + private function applyLeftJoinsFromItem(QueryBuilder $queryBuilder, Item $item, string $entityType): void + { + $type = $item->getType(); + $value = $item->getValue(); + $attribute = $item->getAttribute(); + + if (in_array($type, $this->subQueryTypeList)) { + return; + } + + if (in_array($type, $this->nestingTypeList)) { + if (!is_array($value)) { + return; + } + + foreach ($value as $subItem) { + $this->applyLeftJoinsFromItem($queryBuilder, Item::fromRaw($subItem), $entityType); + } + + return; + } + + if (!$attribute) { + return; + } + + $this->applyLeftJoinsFromAttribute($queryBuilder, $attribute, $entityType); + } + + private function applyLeftJoinsFromAttribute( + QueryBuilder $queryBuilder, + string $attribute, + string $entityType + ): void { + + if (str_contains($attribute, ':')) { + $argumentList = QueryComposerUtil::getAllAttributesFromComplexExpression($attribute); + + foreach ($argumentList as $argument) { + $this->applyLeftJoinsFromAttribute($queryBuilder, $argument, $entityType); + } + + return; + } + + $seed = $this->getSeed($entityType); + + if (str_contains($attribute, '.')) { + [$link,] = explode('.', $attribute); + + if ($seed->hasRelation($link)) { + $queryBuilder->leftJoin($link); + } + + return; + } + + $attributeType = $seed->getAttributeType($attribute); + + if ($attributeType === Entity::FOREIGN) { + $relation = $this->getAttributeParam($seed, $attribute, AttributeParam::RELATION); + + if ($relation) { + $queryBuilder->leftJoin($relation); + } + } + } + + private function getSeed(string $entityType): Entity + { + if (!isset($this->seedHash[$entityType])) { + $this->seedHash[$entityType] = $this->entityManager->getNewEntity($entityType); + } + + return $this->seedHash[$entityType]; + } + + /** + * @return mixed + * @noinspection PhpSameParameterValueInspection + */ + private function getAttributeParam(Entity $entity, string $attribute, string $param) + { + if ($entity instanceof BaseEntity) { + return $entity->getAttributeParam($attribute, $param); + } + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entity->getEntityType()); + + if (!$entityDefs->hasAttribute($attribute)) { + return null; + } + + return $entityDefs->getAttribute($attribute)->getParam($param); + } +} diff --git a/application/Espo/Core/ServiceFactory.php b/application/Espo/Core/ServiceFactory.php new file mode 100644 index 0000000..f5c3c0d --- /dev/null +++ b/application/Espo/Core/ServiceFactory.php @@ -0,0 +1,95 @@ +. + * + * 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; + +use Espo\Core\Utils\ClassFinder; + +use RuntimeException; + +/** + * @deprecated As of v6.1. For business logic, use plain classes. Inject them via constructor. + * To access record services of specific entity types use `Espo\Core\Record\ServiceContainer`. + */ +class ServiceFactory +{ + private $classFinder; + private $injectableFactory; + + public function __construct(ClassFinder $classFinder, InjectableFactory $injectableFactory) + { + $this->classFinder = $classFinder; + $this->injectableFactory = $injectableFactory; + } + + /** + * @return ?class-string + */ + private function getClassName(string $name): ?string + { + return $this->classFinder->find('Services', $name); + } + + public function checkExists(string $name): bool + { + $className = $this->getClassName($name); + + if (!$className) { + return false; + } + + return true; + } + + /** + * @param array $with + */ + public function createWith(string $name, array $with): object + { + $className = $this->getClassName($name); + + if (!$className) { + throw new RuntimeException("Service '{$name}' was not found."); + } + + $obj = $this->injectableFactory->createWith($className, $with); + + // For backward compatibility. + if (method_exists($obj, 'prepare')) { + $obj->prepare(); + } + + return $obj; + } + + public function create(string $name): object + { + return $this->createWith($name, []); + } +} diff --git a/application/Espo/Core/Session/DefaultSession.php b/application/Espo/Core/Session/DefaultSession.php new file mode 100644 index 0000000..c441ee7 --- /dev/null +++ b/application/Espo/Core/Session/DefaultSession.php @@ -0,0 +1,84 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Core/Session/Session.php b/application/Espo/Core/Session/Session.php new file mode 100644 index 0000000..f7274ff --- /dev/null +++ b/application/Espo/Core/Session/Session.php @@ -0,0 +1,48 @@ +. + * + * 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 10.0.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; +} diff --git a/application/Espo/Core/Sms/Sender.php b/application/Espo/Core/Sms/Sender.php new file mode 100644 index 0000000..2043d5b --- /dev/null +++ b/application/Espo/Core/Sms/Sender.php @@ -0,0 +1,38 @@ +. + * + * 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\Sms; + +/** + * An SMS sender. + */ +interface Sender +{ + public function send(Sms $sms): void; +} diff --git a/application/Espo/Core/Sms/SenderFactory.php b/application/Espo/Core/Sms/SenderFactory.php new file mode 100644 index 0000000..8303f3c --- /dev/null +++ b/application/Espo/Core/Sms/SenderFactory.php @@ -0,0 +1,67 @@ +. + * + * 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\Sms; + +use Espo\Core\Binding\Factory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Core\InjectableFactory; + +use RuntimeException; + +/** + * @implements Factory + */ +class SenderFactory implements Factory +{ + public function __construct( + private Config $config, + private Metadata $metadata, + private InjectableFactory $injectableFactory + ) {} + + public function create(): Sender + { + $provider = $this->config->get('smsProvider'); + + if (!$provider) { + throw new RuntimeException("No `smsProvider` in config."); + } + + /** @var ?class-string $className */ + $className = $this->metadata->get(['app', 'smsProviders', $provider, 'senderClassName']); + + if (!$className) { + throw new RuntimeException("No `senderClassName` for '$provider' provider."); + } + + return $this->injectableFactory->create($className); + } +} diff --git a/application/Espo/Core/Sms/Sms.php b/application/Espo/Core/Sms/Sms.php new file mode 100644 index 0000000..495208c --- /dev/null +++ b/application/Espo/Core/Sms/Sms.php @@ -0,0 +1,47 @@ +. + * + * 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\Sms; + +/** + * An SMS message. + */ +interface Sms +{ + public function getBody(): string; + + public function getFromNumber(): ?string; + + public function getFromName(): ?string; + + /** + * @return string[] + */ + public function getToNumberList(): array; +} diff --git a/application/Espo/Core/Sms/SmsFactory.php b/application/Espo/Core/Sms/SmsFactory.php new file mode 100644 index 0000000..45c66a5 --- /dev/null +++ b/application/Espo/Core/Sms/SmsFactory.php @@ -0,0 +1,53 @@ +. + * + * 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\Sms; + +use Espo\ORM\EntityManager; +use Espo\Entities\Sms as SmsEntity; + +/** + * Creates SMS instances. + */ +class SmsFactory +{ + public function __construct(private EntityManager $entityManager) + {} + + /** + * Create an SMS instance. + */ + public function create(): SmsEntity + { + /** @var SmsEntity $sms */ + $sms = $this->entityManager->getNewEntity(SmsEntity::ENTITY_TYPE); + + return $sms; + } +} diff --git a/application/Espo/Core/Sms/SmsSender.php b/application/Espo/Core/Sms/SmsSender.php new file mode 100644 index 0000000..71caa81 --- /dev/null +++ b/application/Espo/Core/Sms/SmsSender.php @@ -0,0 +1,69 @@ +. + * + * 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\Sms; + +use Espo\Core\InjectableFactory; +use Espo\Entities\Sms as SmsEntity; +use Espo\Core\Utils\Config; + +class SmsSender +{ + private ?Sender $sender = null; + + public function __construct( + private InjectableFactory $injectableFactory, + private Config $config + ) {} + + private function getSender(): Sender + { + if ($this->sender === null) { + // Sender factory can throw an exception (if no 'smsProvider' in config). + // Better it be thrown when sending rather than when instantiating + // constructor dependencies. + $this->sender = $this->injectableFactory->createResolved(Sender::class); + } + + return $this->sender; + } + + public function send(SmsEntity $sms): void + { + $systemFromNumber = $this->config->get('outboundSmsFromNumber'); + + if ($sms->getFromNumber() === null && $systemFromNumber) { + $sms->setFromNumber($systemFromNumber); + } + + $this->getSender()->send($sms); + + $sms->setAsSent(); + } +} diff --git a/application/Espo/Core/Templates/Controllers/Base.php b/application/Espo/Core/Templates/Controllers/Base.php new file mode 100644 index 0000000..64b8a0e --- /dev/null +++ b/application/Espo/Core/Templates/Controllers/Base.php @@ -0,0 +1,37 @@ +. + * + * 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\Templates\Controllers; + +/** + * Do not remove. Used by exported custom modules. + */ +class Base extends \Espo\Core\Controllers\Record +{ +} diff --git a/application/Espo/Core/Templates/Controllers/BasePlus.php b/application/Espo/Core/Templates/Controllers/BasePlus.php new file mode 100644 index 0000000..402ccc5 --- /dev/null +++ b/application/Espo/Core/Templates/Controllers/BasePlus.php @@ -0,0 +1,35 @@ +. + * + * 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\Templates\Controllers; + +use Espo\Core\Controllers\Record; + +class BasePlus extends Record +{} diff --git a/application/Espo/Core/Templates/Controllers/CategoryTree.php b/application/Espo/Core/Templates/Controllers/CategoryTree.php new file mode 100644 index 0000000..f000235 --- /dev/null +++ b/application/Espo/Core/Templates/Controllers/CategoryTree.php @@ -0,0 +1,35 @@ +. + * + * 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\Templates\Controllers; + +use Espo\Core\Controllers\RecordTree; + +class CategoryTree extends RecordTree +{} diff --git a/application/Espo/Core/Templates/Controllers/Company.php b/application/Espo/Core/Templates/Controllers/Company.php new file mode 100644 index 0000000..a0867d8 --- /dev/null +++ b/application/Espo/Core/Templates/Controllers/Company.php @@ -0,0 +1,35 @@ +. + * + * 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\Templates\Controllers; + +use Espo\Core\Controllers\Record; + +class Company extends Record +{} diff --git a/application/Espo/Core/Templates/Controllers/Event.php b/application/Espo/Core/Templates/Controllers/Event.php new file mode 100644 index 0000000..0d10079 --- /dev/null +++ b/application/Espo/Core/Templates/Controllers/Event.php @@ -0,0 +1,35 @@ +. + * + * 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\Templates\Controllers; + +use Espo\Core\Controllers\Record; + +class Event extends Record +{} diff --git a/application/Espo/Core/Templates/Controllers/Person.php b/application/Espo/Core/Templates/Controllers/Person.php new file mode 100644 index 0000000..449232c --- /dev/null +++ b/application/Espo/Core/Templates/Controllers/Person.php @@ -0,0 +1,35 @@ +. + * + * 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\Templates\Controllers; + +use Espo\Core\Controllers\Record; + +class Person extends Record +{} diff --git a/application/Espo/Core/Templates/Entities/Base.php b/application/Espo/Core/Templates/Entities/Base.php new file mode 100644 index 0000000..78b48b7 --- /dev/null +++ b/application/Espo/Core/Templates/Entities/Base.php @@ -0,0 +1,37 @@ +. + * + * 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\Templates\Entities; + +use Espo\Core\ORM\Entity; + +class Base extends Entity +{ + public const TEMPLATE_TYPE = 'Base'; +} diff --git a/application/Espo/Core/Templates/Entities/BasePlus.php b/application/Espo/Core/Templates/Entities/BasePlus.php new file mode 100644 index 0000000..ed257ec --- /dev/null +++ b/application/Espo/Core/Templates/Entities/BasePlus.php @@ -0,0 +1,37 @@ +. + * + * 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\Templates\Entities; + +use Espo\Core\ORM\Entity; + +class BasePlus extends Entity +{ + public const TEMPLATE_TYPE = 'BasePlus'; +} diff --git a/application/Espo/Core/Templates/Entities/CategoryTree.php b/application/Espo/Core/Templates/Entities/CategoryTree.php new file mode 100644 index 0000000..9053a92 --- /dev/null +++ b/application/Espo/Core/Templates/Entities/CategoryTree.php @@ -0,0 +1,37 @@ +. + * + * 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\Templates\Entities; + +use Espo\Core\Entities\CategoryTreeItem; + +class CategoryTree extends CategoryTreeItem +{ + public const string TEMPLATE_TYPE = 'CategoryTree'; +} diff --git a/application/Espo/Core/Templates/Entities/Company.php b/application/Espo/Core/Templates/Entities/Company.php new file mode 100644 index 0000000..4506964 --- /dev/null +++ b/application/Espo/Core/Templates/Entities/Company.php @@ -0,0 +1,85 @@ +. + * + * 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\Templates\Entities; + +use Espo\Core\Field\Address; +use Espo\Core\Field\EmailAddressGroup; +use Espo\Core\Field\PhoneNumberGroup; +use Espo\Core\ORM\Entity; + +class Company extends Entity +{ + public const TEMPLATE_TYPE = 'Company'; + + public function getEmailAddressGroup(): EmailAddressGroup + { + /** @var EmailAddressGroup */ + return $this->getValueObject('emailAddress'); + } + + public function getPhoneNumberGroup(): PhoneNumberGroup + { + /** @var PhoneNumberGroup */ + return $this->getValueObject('phoneNumber'); + } + + public function setEmailAddressGroup(EmailAddressGroup $group): static + { + return $this->setValueObject('emailAddress', $group); + } + + public function setPhoneNumberGroup(PhoneNumberGroup $group): static + { + return $this->setValueObject('phoneNumber', $group); + } + + public function getBillingAddress(): Address + { + /** @var Address */ + return $this->getValueObject('billingAddress'); + } + + public function setBillingAddress(Address $address): static + { + return $this->setValueObject('billingAddress', $address); + } + + public function getShippingAddress(): Address + { + /** @var Address */ + return $this->getValueObject('shippingAddress'); + } + + public function setShippingAddress(Address $address): static + { + return $this->setValueObject('shippingAddress', $address); + } +} + diff --git a/application/Espo/Core/Templates/Entities/Event.php b/application/Espo/Core/Templates/Entities/Event.php new file mode 100644 index 0000000..d5f41cd --- /dev/null +++ b/application/Espo/Core/Templates/Entities/Event.php @@ -0,0 +1,62 @@ +. + * + * 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\Templates\Entities; + +use Espo\Core\Field\Link; +use Espo\Core\Name\Field; +use Espo\Core\ORM\Entity; + +class Event extends Entity +{ + public const TEMPLATE_TYPE = 'Event'; + + public const STATUS_PLANNED = 'Planned'; + public const STATUS_HELD = 'Held'; + public const STATUS_NOT_HELD = 'Not Held'; + + public function getStatus(): string + { + return $this->get('status'); + } + + public function setStatus(string $status): self + { + return $this->set('status', $status); + } + + /** + * @since 9.2.0 + */ + public function getAssignedUser(): ?Link + { + /** @var ?Link */ + return $this->getValueObject(Field::ASSIGNED_USER); + } +} diff --git a/application/Espo/Core/Templates/Entities/Person.php b/application/Espo/Core/Templates/Entities/Person.php new file mode 100644 index 0000000..cc783de --- /dev/null +++ b/application/Espo/Core/Templates/Entities/Person.php @@ -0,0 +1,37 @@ +. + * + * 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\Templates\Entities; + +use Espo\Core\Entities\Person as PersonEntity; + +class Person extends PersonEntity +{ + public const TEMPLATE_TYPE = 'Person'; +} diff --git a/application/Espo/Core/Templates/Layouts/CategoryTree/detail.json b/application/Espo/Core/Templates/Layouts/CategoryTree/detail.json new file mode 100644 index 0000000..5aa8a19 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/CategoryTree/detail.json @@ -0,0 +1,20 @@ +[ + { + "rows": [ + [ + { + "name": "name" + }, + { + "name": "order" + } + ], + [ + false, + { + "name": "parent" + } + ] + ] + } +] diff --git a/application/Espo/Core/Templates/Layouts/CategoryTree/detailSmall.json b/application/Espo/Core/Templates/Layouts/CategoryTree/detailSmall.json new file mode 100644 index 0000000..7435985 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/CategoryTree/detailSmall.json @@ -0,0 +1,21 @@ +[ + { + "rows": [ + [ + { + "name": "name" + } + ], + [ + { + "name": "order" + } + ], + [ + { + "name": "parent" + } + ] + ] + } +] diff --git a/application/Espo/Core/Templates/Layouts/CategoryTree/filters.json b/application/Espo/Core/Templates/Layouts/CategoryTree/filters.json new file mode 100644 index 0000000..dd8435e --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/CategoryTree/filters.json @@ -0,0 +1,3 @@ +[ + "parent" +] \ No newline at end of file diff --git a/application/Espo/Core/Templates/Layouts/CategoryTree/list.json b/application/Espo/Core/Templates/Layouts/CategoryTree/list.json new file mode 100644 index 0000000..2d27480 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/CategoryTree/list.json @@ -0,0 +1,14 @@ +[ + { + "name": "name", + "width": 40, + "link": true + }, + { + "name": "order", + "width": 15 + }, + { + "name": "parent" + } +] \ No newline at end of file diff --git a/application/Espo/Core/Templates/Layouts/CategoryTree/listSmall.json b/application/Espo/Core/Templates/Layouts/CategoryTree/listSmall.json new file mode 100644 index 0000000..2d27480 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/CategoryTree/listSmall.json @@ -0,0 +1,14 @@ +[ + { + "name": "name", + "width": 40, + "link": true + }, + { + "name": "order", + "width": 15 + }, + { + "name": "parent" + } +] \ No newline at end of file diff --git a/application/Espo/Core/Templates/Layouts/CategoryTree/massUpdate.json b/application/Espo/Core/Templates/Layouts/CategoryTree/massUpdate.json new file mode 100644 index 0000000..c44dc44 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/CategoryTree/massUpdate.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/application/Espo/Core/Templates/Layouts/CategoryTree/relationships.json b/application/Espo/Core/Templates/Layouts/CategoryTree/relationships.json new file mode 100644 index 0000000..ae0aaef --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/CategoryTree/relationships.json @@ -0,0 +1,3 @@ +[ + "children" +] \ No newline at end of file diff --git a/application/Espo/Core/Templates/Layouts/Company/detail.json b/application/Espo/Core/Templates/Layouts/Company/detail.json new file mode 100644 index 0000000..04a9337 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/Company/detail.json @@ -0,0 +1,36 @@ +[ + { + "rows": [ + [ + { + "name": "name" + }, + { + "name": "website" + } + ], + [ + { + "name": "emailAddress" + }, + { + "name": "phoneNumber" + } + ], + [ + { + "name": "billingAddress" + }, + { + "name": "shippingAddress" + } + ], + [ + { + "name": "description", + "fullWidth": true + } + ] + ] + } +] diff --git a/application/Espo/Core/Templates/Layouts/Company/detailSmall.json b/application/Espo/Core/Templates/Layouts/Company/detailSmall.json new file mode 100644 index 0000000..e4b0e30 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/Company/detailSmall.json @@ -0,0 +1,36 @@ +[ + { + "rows": [ + [ + { + "name": "name", + "fullWidth": true + } + ], + [ + { + "name": "website", + "fullWidth": true + } + ], + [ + { + "name": "emailAddress", + "fullWidth": true + } + ], + [ + { + "name": "phoneNumber", + "fullWidth": true + } + ], + [ + { + "name": "billingAddressCountry", + "fullWidth": true + } + ] + ] + } +] diff --git a/application/Espo/Core/Templates/Layouts/Event/detail.json b/application/Espo/Core/Templates/Layouts/Event/detail.json new file mode 100644 index 0000000..e251be8 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/Event/detail.json @@ -0,0 +1,42 @@ +[ + { + "rows": [ + [ + { + "name": "name" + }, + { + "name": "parent" + } + ], + [ + { + "name": "status" + }, + false + ], + [ + { + "name": "dateStart" + }, + { + "name": "dateEnd" + } + ], + [ + { + "name": "duration" + }, + { + "name": "reminders" + } + ], + [ + { + "name": "description", + "fullWidth": true + } + ] + ] + } +] diff --git a/application/Espo/Core/Templates/Layouts/Event/detailSmall.json b/application/Espo/Core/Templates/Layouts/Event/detailSmall.json new file mode 100644 index 0000000..bff0b8a --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/Event/detailSmall.json @@ -0,0 +1,48 @@ +[ + { + "rows": [ + [ + { + "name": "name", + "fullWidth": true + } + ], + [ + { + "name": "status", + "fullWidth": true + } + ], + [ + { + "name": "dateStart", + "fullWidth": true + } + ], + [ + { + "name": "duration", + "fullWidth": true + } + ], + [ + { + "name": "dateEnd", + "fullWidth": true + } + ], + [ + { + "name": "parent", + "fullWidth": true + } + ], + [ + { + "name": "description", + "fullWidth": true + } + ] + ] + } +] diff --git a/application/Espo/Core/Templates/Layouts/Person/detail.json b/application/Espo/Core/Templates/Layouts/Person/detail.json new file mode 100644 index 0000000..b1051c9 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/Person/detail.json @@ -0,0 +1,32 @@ +[ + { + "rows": [ + [ + { + "name": "name" + }, + false + ], + [ + { + "name": "emailAddress" + }, + { + "name": "phoneNumber" + } + ], + [ + { + "name": "address" + }, + false + ], + [ + { + "name": "description", + "fullWidth": true + } + ] + ] + } +] diff --git a/application/Espo/Core/Templates/Layouts/Person/detailSmall.json b/application/Espo/Core/Templates/Layouts/Person/detailSmall.json new file mode 100644 index 0000000..1d558b0 --- /dev/null +++ b/application/Espo/Core/Templates/Layouts/Person/detailSmall.json @@ -0,0 +1,24 @@ +[ + { + "rows": [ + [ + { + "name": "name", + "fullWidth": true + } + ], + [ + { + "name": "emailAddress", + "fullWidth": true + } + ], + [ + { + "name": "phoneNumber", + "fullWidth": true + } + ] + ] + } +] diff --git a/application/Espo/Core/Templates/Metadata/Base/clientDefs.json b/application/Espo/Core/Templates/Metadata/Base/clientDefs.json new file mode 100644 index 0000000..b83394b --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Base/clientDefs.json @@ -0,0 +1,4 @@ +{ + "controller": "controllers/record", + "boolFilterList": ["onlyMy"] +} diff --git a/application/Espo/Core/Templates/Metadata/Base/entityDefs.json b/application/Espo/Core/Templates/Metadata/Base/entityDefs.json new file mode 100644 index 0000000..0ba347b --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Base/entityDefs.json @@ -0,0 +1,78 @@ +{ + "fields": { + "name": { + "type": "varchar", + "required": true, + "pattern": "$noBadCharacters" + }, + "description": { + "type": "text" + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "modifiedBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "assignedUser": { + "type": "link", + "required": false, + "view": "views/fields/assigned-user" + }, + "teams": { + "type": "linkMultiple", + "view": "views/fields/teams" + } + }, + "links": { + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + }, + "assignedUser": { + "type": "belongsTo", + "entity": "User" + }, + "teams": { + "type": "hasMany", + "entity": "Team", + "relationName": "entityTeam", + "layoutRelationshipsDisabled": true + } + }, + "collection": { + "orderBy": "createdAt", + "order": "desc" + }, + "indexes": { + "name": { + "columns": ["name", "deleted"] + }, + "assignedUser": { + "columns": ["assignedUserId", "deleted"] + }, + "createdAt": { + "columns": ["createdAt"] + }, + "createdAtId": { + "unique": true, + "columns": ["createdAt", "id"] + } + } +} diff --git a/application/Espo/Core/Templates/Metadata/Base/recordDefs.json b/application/Espo/Core/Templates/Metadata/Base/recordDefs.json new file mode 100644 index 0000000..cfd6715 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Base/recordDefs.json @@ -0,0 +1,3 @@ +{ + "duplicateWhereBuilderClassName": "Espo\\Classes\\DuplicateWhereBuilders\\General" +} diff --git a/application/Espo/Core/Templates/Metadata/Base/scopes.json b/application/Espo/Core/Templates/Metadata/Base/scopes.json new file mode 100644 index 0000000..62badf4 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Base/scopes.json @@ -0,0 +1,11 @@ +{ + "entity": true, + "layouts": true, + "tab": true, + "acl": true, + "aclPortal": true, + "aclPortalLevelList": ["all", "account", "contact", "own", "no"], + "customizable": true, + "importable": true, + "notifications": true +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/Metadata/BasePlus/clientDefs.json b/application/Espo/Core/Templates/Metadata/BasePlus/clientDefs.json new file mode 100644 index 0000000..812f5ff --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/BasePlus/clientDefs.json @@ -0,0 +1,34 @@ +{ + "controller": "controllers/record", + "boolFilterList": ["onlyMy"], + "sidePanels": { + "detail": [ + { + "name": "activities", + "reference": "activities" + }, + { + "name": "history", + "reference": "history" + }, + { + "name": "tasks", + "reference": "tasks" + } + ] + }, + "bottomPanels": { + "detail": [ + { + "name": "activities", + "reference": "activities", + "disabled": true + }, + { + "name": "history", + "reference": "history", + "disabled": true + } + ] + } +} diff --git a/application/Espo/Core/Templates/Metadata/BasePlus/entityDefs.json b/application/Espo/Core/Templates/Metadata/BasePlus/entityDefs.json new file mode 100644 index 0000000..425be08 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/BasePlus/entityDefs.json @@ -0,0 +1,99 @@ +{ + "fields": { + "name": { + "type": "varchar", + "required": true, + "pattern": "$noBadCharacters" + }, + "description": { + "type": "text" + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "modifiedBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "assignedUser": { + "type": "link", + "required": false, + "view": "views/fields/assigned-user" + }, + "teams": { + "type": "linkMultiple", + "view": "views/fields/teams" + } + }, + "links": { + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + }, + "assignedUser": { + "type": "belongsTo", + "entity": "User" + }, + "teams": { + "type": "hasMany", + "entity": "Team", + "relationName": "entityTeam", + "layoutRelationshipsDisabled": true + }, + "meetings": { + "type": "hasMany", + "entity": "Meeting", + "foreign": "parent" + }, + "calls": { + "type": "hasMany", + "entity": "Call", + "foreign": "parent" + }, + "tasks": { + "type": "hasChildren", + "entity": "Task", + "foreign": "parent" + }, + "emails": { + "type": "hasChildren", + "entity": "Email", + "foreign": "parent", + "layoutRelationshipsDisabled": true + } + }, + "collection": { + "orderBy": "createdAt", + "order": "desc" + }, + "indexes": { + "name": { + "columns": ["name", "deleted"] + }, + "assignedUser": { + "columns": ["assignedUserId", "deleted"] + }, + "createdAt": { + "columns": ["createdAt"] + }, + "createdAtId": { + "unique": true, + "columns": ["createdAt", "id"] + } + } +} diff --git a/application/Espo/Core/Templates/Metadata/BasePlus/recordDefs.json b/application/Espo/Core/Templates/Metadata/BasePlus/recordDefs.json new file mode 100644 index 0000000..cfd6715 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/BasePlus/recordDefs.json @@ -0,0 +1,3 @@ +{ + "duplicateWhereBuilderClassName": "Espo\\Classes\\DuplicateWhereBuilders\\General" +} diff --git a/application/Espo/Core/Templates/Metadata/BasePlus/scopes.json b/application/Espo/Core/Templates/Metadata/BasePlus/scopes.json new file mode 100644 index 0000000..62badf4 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/BasePlus/scopes.json @@ -0,0 +1,11 @@ +{ + "entity": true, + "layouts": true, + "tab": true, + "acl": true, + "aclPortal": true, + "aclPortalLevelList": ["all", "account", "contact", "own", "no"], + "customizable": true, + "importable": true, + "notifications": true +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/Metadata/CategoryTree/clientDefs.json b/application/Espo/Core/Templates/Metadata/CategoryTree/clientDefs.json new file mode 100644 index 0000000..f01f13f --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/CategoryTree/clientDefs.json @@ -0,0 +1,46 @@ +{ + "controller": "controllers/record-tree", + "collection": "collections/tree", + "menu": { + "listTree": { + "buttons": [ + { + "label": "List View", + "link": "#{entityType}/list", + "acl": "read", + "style": "default" + }, + { + "labelTranslation": "Global.scopeNamesPlural.{subjectEntityType}", + "link": "#{subjectEntityType}", + "acl": "read", + "style": "default", + "aclScope": "{subjectEntityType}" + } + ] + }, + "list": { + "buttons": [ + { + "label": "Tree View", + "link": "#{entityType}", + "acl": "read", + "style": "default" + }, + { + "labelTranslation": "Global.scopeNamesPlural.{subjectEntityType}", + "link": "#{subjectEntityType}", + "acl": "read", + "style": "default", + "aclScope": "{subjectEntityType}" + } + ] + } + }, + "relationshipPanels": { + "children": { + "selectDisabled": true, + "unlinkDisabled": true + } + } +} diff --git a/application/Espo/Core/Templates/Metadata/CategoryTree/entityDefs.json b/application/Espo/Core/Templates/Metadata/CategoryTree/entityDefs.json new file mode 100644 index 0000000..9644c12 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/CategoryTree/entityDefs.json @@ -0,0 +1,108 @@ +{ + "fields": { + "name": { + "type": "varchar", + "required": true, + "pattern": "$noBadCharacters" + }, + "order": { + "type": "int", + "minValue": 1 + }, + "description": { + "type": "text" + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "modifiedBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "teams": { + "type": "linkMultiple" + }, + "parent": { + "type": "link" + }, + "childList": { + "type": "jsonArray", + "notStorable": true, + "utility": true, + "orderDisabled": true + } + }, + "links": { + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + }, + "teams": { + "type": "hasMany", + "entity": "Team", + "relationName": "entityTeam", + "layoutRelationshipsDisabled": true + }, + "parent": { + "type": "belongsTo", + "foreign": "children", + "entity": "{entityType}", + "isCustom": true + }, + "children": { + "type": "hasMany", + "foreign": "parent", + "entity": "{entityType}", + "readOnly": true, + "isCustom": true + } + }, + "collection": { + "orderBy": "parent", + "order": "asc" + }, + "indexes": { + "name": { + "columns": [ + "name", + "deleted" + ] + } + }, + "additionalTables": { + "{entityType}Path": { + "attributes": { + "id": { + "type": "id", + "dbType": "integer", + "len": 11, + "autoincrement": true, + "unique" : true + }, + "ascendorId": { + "type": "foreignId", + "index": true + }, + "descendorId": { + "type": "foreignId", + "index": true + } + } + } + } +} diff --git a/application/Espo/Core/Templates/Metadata/CategoryTree/scopes.json b/application/Espo/Core/Templates/Metadata/CategoryTree/scopes.json new file mode 100644 index 0000000..0b6d3b2 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/CategoryTree/scopes.json @@ -0,0 +1,26 @@ +{ + "entity": true, + "tab": false, + "acl": true, + "aclPortal": true, + "aclLevelList": [ + "all", + "team", + "no" + ], + "aclPortalLevelList": [ + "all", + "no" + ], + "importable": false, + "notifications": false, + "customizable": false, + "entityManager": { + "fields": false, + "formula": false, + "relationships": false, + "addField": false, + "edit": false, + "layouts": false + } +} diff --git a/application/Espo/Core/Templates/Metadata/Company/clientDefs.json b/application/Espo/Core/Templates/Metadata/Company/clientDefs.json new file mode 100644 index 0000000..812f5ff --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Company/clientDefs.json @@ -0,0 +1,34 @@ +{ + "controller": "controllers/record", + "boolFilterList": ["onlyMy"], + "sidePanels": { + "detail": [ + { + "name": "activities", + "reference": "activities" + }, + { + "name": "history", + "reference": "history" + }, + { + "name": "tasks", + "reference": "tasks" + } + ] + }, + "bottomPanels": { + "detail": [ + { + "name": "activities", + "reference": "activities", + "disabled": true + }, + { + "name": "history", + "reference": "history", + "disabled": true + } + ] + } +} diff --git a/application/Espo/Core/Templates/Metadata/Company/entityDefs.json b/application/Espo/Core/Templates/Metadata/Company/entityDefs.json new file mode 100644 index 0000000..190829f --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Company/entityDefs.json @@ -0,0 +1,146 @@ +{ + "fields": { + "name": { + "type": "varchar", + "required": true, + "pattern": "$noBadCharacters" + }, + "description": { + "type": "text" + }, + "website": { + "type": "url", + "strip": true + }, + "emailAddress": { + "type": "email" + }, + "phoneNumber": { + "type": "phone", + "typeList": ["Office", "Mobile", "Fax", "Other"], + "defaultType": "Office" + }, + "billingAddress": { + "type": "address" + }, + "billingAddressStreet": { + "type": "text", + "maxLength": 255, + "dbType": "varchar" + }, + "billingAddressCity": { + "type": "varchar" + }, + "billingAddressState": { + "type": "varchar" + }, + "billingAddressCountry": { + "type": "varchar" + }, + "billingAddressPostalCode": { + "type": "varchar" + }, + "shippingAddress": { + "type": "address", + "view": "crm:views/account/fields/shipping-address" + }, + "shippingAddressStreet": { + "type": "text", + "maxLength": 255, + "dbType": "varchar" + }, + "shippingAddressCity": { + "type": "varchar" + }, + "shippingAddressState": { + "type": "varchar" + }, + "shippingAddressCountry": { + "type": "varchar" + }, + "shippingAddressPostalCode": { + "type": "varchar" + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "modifiedBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "assignedUser": { + "type": "link", + "required": false, + "view": "views/fields/assigned-user" + }, + "teams": { + "type": "linkMultiple", + "view": "views/fields/teams" + } + }, + "links": { + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + }, + "assignedUser": { + "type": "belongsTo", + "entity": "User" + }, + "teams": { + "type": "hasMany", + "entity": "Team", + "relationName": "entityTeam", + "layoutRelationshipsDisabled": true + }, + "meetings": { + "type": "hasMany", + "entity": "Meeting", + "foreign": "parent" + }, + "calls": { + "type": "hasMany", + "entity": "Call", + "foreign": "parent" + }, + "tasks": { + "type": "hasChildren", + "entity": "Task", + "foreign": "parent" + } + }, + "collection": { + "orderBy": "createdAt", + "order": "desc" + }, + "indexes": { + "name": { + "columns": ["name", "deleted"] + }, + "assignedUser": { + "columns": ["assignedUserId", "deleted"] + }, + "createdAt": { + "columns": ["createdAt"] + }, + "createdAtId": { + "unique": true, + "columns": ["createdAt", "id"] + } + } +} diff --git a/application/Espo/Core/Templates/Metadata/Company/recordDefs.json b/application/Espo/Core/Templates/Metadata/Company/recordDefs.json new file mode 100644 index 0000000..cfd6715 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Company/recordDefs.json @@ -0,0 +1,3 @@ +{ + "duplicateWhereBuilderClassName": "Espo\\Classes\\DuplicateWhereBuilders\\General" +} diff --git a/application/Espo/Core/Templates/Metadata/Company/scopes.json b/application/Espo/Core/Templates/Metadata/Company/scopes.json new file mode 100644 index 0000000..80235c7 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Company/scopes.json @@ -0,0 +1,12 @@ +{ + "entity": true, + "layouts": true, + "tab": true, + "acl": true, + "aclPortal": true, + "aclPortalLevelList": ["all", "account", "contact", "own", "no"], + "customizable": true, + "importable": true, + "notifications": true, + "duplicateCheckFieldList": ["name", "emailAddress"] +} diff --git a/application/Espo/Core/Templates/Metadata/Event/clientDefs.json b/application/Espo/Core/Templates/Metadata/Event/clientDefs.json new file mode 100644 index 0000000..6723da5 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Event/clientDefs.json @@ -0,0 +1,57 @@ +{ + "controller": "controllers/record", + "boolFilterList": ["onlyMy"], + "recordViews": { + "detail": "views/templates/event/record/detail" + }, + "activityDefs": { + "activitiesCreate": true, + "historyCreate": true + }, + "viewSetupHandlers": { + "record/detail": [ + "__APPEND__", + "crm:handlers/event/reminders-handler" + ], + "record/edit": [ + "__APPEND__", + "crm:handlers/event/reminders-handler" + ] + }, + "bottomPanels": { + "detail": [ + { + "name": "scheduler", + "label": "Scheduler", + "view": "crm:views/meeting/record/panels/scheduler", + "disabled": true, + "order": 3 + } + ], + "edit": [ + { + "name": "scheduler", + "label": "Scheduler", + "view": "crm:views/meeting/record/panels/scheduler", + "disabled": true, + "order": 1 + } + ] + }, + "filterList": [ + { + "name":"planned" + }, + { + "name":"held", + "style": "success" + }, + { + "name":"todays" + } + ], + "forcePatchAttributeDependencyMap": { + "dateEnd": ["dateStart"], + "dateEndDate": ["dateStartDate"] + } +} diff --git a/application/Espo/Core/Templates/Metadata/Event/entityDefs.json b/application/Espo/Core/Templates/Metadata/Event/entityDefs.json new file mode 100644 index 0000000..a9c3398 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Event/entityDefs.json @@ -0,0 +1,160 @@ +{ + "fields": { + "name": { + "type": "varchar", + "required": true, + "pattern": "$noBadCharacters" + }, + "status": { + "type": "enum", + "options": [ + "Planned", + "Held", + "Not Held" + ], + "default": "Planned", + "style": { + "Held": "success", + "Not Held": "info" + }, + "displayAsLabel": true, + "labelType": "state", + "audited": true + }, + "dateStart": { + "type": "datetimeOptional", + "view": "crm:views/meeting/fields/date-start", + "required": true, + "default": "javascript: return this.dateTime.getNow(15);", + "audited": true + }, + "dateEnd": { + "type": "datetimeOptional", + "view": "crm:views/meeting/fields/date-end", + "required": true, + "after": "dateStart", + "suppressValidationList": ["required"] + }, + "isAllDay": { + "type": "bool", + "layoutListDisabled": true, + "layoutDetailDisabled": true, + "layoutMassUpdateDisabled": true + }, + "duration": { + "type": "duration", + "start": "dateStart", + "end": "dateEnd", + "options": [300, 600, 900, 1800, 2700, 3600, 7200, 10800], + "default": 300, + "notStorable": true, + "select": { + "select": "TIMESTAMPDIFF_SECOND:(dateStart, dateEnd)" + }, + "order": { + "order": [ + ["TIMESTAMPDIFF_SECOND:(dateStart, dateEnd)", "{direction}"] + ] + } + }, + "parent": { + "type": "linkParent", + "entityList": ["Account", "Lead", "Contact"] + }, + "description": { + "type": "text" + }, + "reminders": { + "type": "jsonArray", + "notStorable": true, + "orderDisabled": true, + "view": "crm:views/meeting/fields/reminders", + "layoutListDisabled": true, + "validatorClassNameList": [ + "Espo\\Modules\\Crm\\Classes\\FieldValidators\\Event\\Reminders\\Valid", + "Espo\\Modules\\Crm\\Classes\\FieldValidators\\Event\\Reminders\\MaxCount" + ], + "dynamicLogicDisabled": true, + "duplicateIgnore": true + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "modifiedBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "assignedUser": { + "type": "link", + "required": false, + "view": "views/fields/assigned-user" + }, + "teams": { + "type": "linkMultiple", + "view": "views/fields/teams" + } + }, + "links": { + "parent": { + "type": "belongsToParent", + "foreign": "{entityTypeLowerFirst}Children" + }, + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + }, + "assignedUser": { + "type": "belongsTo", + "entity": "User" + }, + "teams": { + "type": "hasMany", + "entity": "Team", + "relationName": "entityTeam", + "layoutRelationshipsDisabled": true + } + }, + "collection": { + "orderBy": "dateStart", + "order": "desc" + }, + "indexes": { + "dateStartStatus": { + "columns": ["dateStart", "status"] + }, + "dateStart": { + "columns": ["dateStart", "deleted"] + }, + "status": { + "columns": ["status", "deleted"] + }, + "assignedUser": { + "columns": ["assignedUserId", "deleted"] + }, + "assignedUserStatus": { + "columns": ["assignedUserId", "status"] + }, + "createdAt": { + "columns": ["createdAt"] + }, + "createdAtId": { + "unique": true, + "columns": ["createdAt", "id"] + } + } +} diff --git a/application/Espo/Core/Templates/Metadata/Event/logicDefs.json b/application/Espo/Core/Templates/Metadata/Event/logicDefs.json new file mode 100644 index 0000000..9c6347e --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Event/logicDefs.json @@ -0,0 +1,14 @@ +{ + "fields":{ + "duration": { + "readOnly": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "isAllDay" + } + ] + } + } + } +} diff --git a/application/Espo/Core/Templates/Metadata/Event/recordDefs.json b/application/Espo/Core/Templates/Metadata/Event/recordDefs.json new file mode 100644 index 0000000..f6b00fd --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Event/recordDefs.json @@ -0,0 +1,12 @@ +{ + "readLoaderClassNameList": [ + "Espo\\Core\\FieldProcessing\\Reminder\\Loader" + ], + "saverClassNameList": [ + "Espo\\Core\\FieldProcessing\\Reminder\\Saver" + ], + "beforeUpdateHookClassNameList": [ + "__APPEND__", + "Espo\\Classes\\RecordHooks\\Event\\BeforeUpdatePreserveDuration" + ] +} diff --git a/application/Espo/Core/Templates/Metadata/Event/scopes.json b/application/Espo/Core/Templates/Metadata/Event/scopes.json new file mode 100644 index 0000000..29430ac --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Event/scopes.json @@ -0,0 +1,19 @@ +{ + "entity": true, + "layouts": true, + "tab": true, + "acl": true, + "aclPortal": true, + "aclPortalLevelList": ["all", "account", "contact", "own", "no"], + "customizable": true, + "importable": true, + "calendar": true, + "activity": true, + "notifications": true, + "activityStatusList": ["Planned"], + "historyStatusList": ["Held", "Not Held"], + "completedStatusList": ["Held"], + "canceledStatusList": ["Not Held"], + "statusField": "status", + "statusFieldLocked": true +} diff --git a/application/Espo/Core/Templates/Metadata/Event/selectDefs.json b/application/Espo/Core/Templates/Metadata/Event/selectDefs.json new file mode 100644 index 0000000..a9efb96 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Event/selectDefs.json @@ -0,0 +1,16 @@ +{ + "selectAttributesDependencyMap": { + "duration": [ + "dateStart", + "dateEnd" + ], + "dateStart": [ + "dateEnd" + ] + }, + "primaryFilterClassNameMap": { + "planned": "Espo\\Classes\\Select\\Event\\PrimaryFilters\\Planned", + "held": "Espo\\Classes\\Select\\Event\\PrimaryFilters\\Held", + "todays": "Espo\\Classes\\Select\\Event\\PrimaryFilters\\Todays" + } +} diff --git a/application/Espo/Core/Templates/Metadata/Person/clientDefs.json b/application/Espo/Core/Templates/Metadata/Person/clientDefs.json new file mode 100644 index 0000000..812f5ff --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Person/clientDefs.json @@ -0,0 +1,34 @@ +{ + "controller": "controllers/record", + "boolFilterList": ["onlyMy"], + "sidePanels": { + "detail": [ + { + "name": "activities", + "reference": "activities" + }, + { + "name": "history", + "reference": "history" + }, + { + "name": "tasks", + "reference": "tasks" + } + ] + }, + "bottomPanels": { + "detail": [ + { + "name": "activities", + "reference": "activities", + "disabled": true + }, + { + "name": "history", + "reference": "history", + "disabled": true + } + ] + } +} diff --git a/application/Espo/Core/Templates/Metadata/Person/entityDefs.json b/application/Espo/Core/Templates/Metadata/Person/entityDefs.json new file mode 100644 index 0000000..9e89068 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Person/entityDefs.json @@ -0,0 +1,139 @@ +{ + "fields": { + "name": { + "type": "personName", + "isPersonalData": true + }, + "salutationName": { + "type": "enum", + "options": ["", "Mr.", "Ms.", "Mrs.", "Dr."] + }, + "firstName": { + "type": "varchar", + "maxLength": 100 + }, + "lastName": { + "type": "varchar", + "maxLength": 100, + "required": true + }, + "description": { + "type": "text" + }, + "emailAddress": { + "type": "email", + "isPersonalData": true + }, + "phoneNumber": { + "type": "phone", + "typeList": ["Mobile", "Office", "Home", "Fax", "Other"], + "defaultType": "Mobile", + "isPersonalData": true + }, + "address": { + "type": "address", + "isPersonalData": true + }, + "addressStreet": { + "type": "text", + "maxLength": 255, + "dbType": "varchar" + }, + "addressCity": { + "type": "varchar" + }, + "addressState": { + "type": "varchar" + }, + "addressCountry": { + "type": "varchar" + }, + "addressPostalCode": { + "type": "varchar" + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "modifiedBy": { + "type": "link", + "readOnly": true, + "view": "views/fields/user" + }, + "assignedUser": { + "type": "link", + "required": false, + "view": "views/fields/assigned-user" + }, + "teams": { + "type": "linkMultiple", + "view": "views/fields/teams" + } + }, + "links": { + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + }, + "assignedUser": { + "type": "belongsTo", + "entity": "User" + }, + "teams": { + "type": "hasMany", + "entity": "Team", + "relationName": "entityTeam", + "layoutRelationshipsDisabled": true + }, + "meetings": { + "type": "hasMany", + "entity": "Meeting", + "foreign": "parent" + }, + "calls": { + "type": "hasMany", + "entity": "Call", + "foreign": "parent" + }, + "tasks": { + "type": "hasChildren", + "entity": "Task", + "foreign": "parent" + } + }, + "collection": { + "orderBy": "createdAt", + "order": "desc" + }, + "indexes": { + "firstName": { + "columns": ["firstName", "deleted"] + }, + "name": { + "columns": ["firstName", "lastName"] + }, + "assignedUser": { + "columns": ["assignedUserId", "deleted"] + }, + "createdAt": { + "columns": ["createdAt"] + }, + "createdAtId": { + "unique": true, + "columns": ["createdAt", "id"] + } + } +} diff --git a/application/Espo/Core/Templates/Metadata/Person/recordDefs.json b/application/Espo/Core/Templates/Metadata/Person/recordDefs.json new file mode 100644 index 0000000..cfd6715 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Person/recordDefs.json @@ -0,0 +1,3 @@ +{ + "duplicateWhereBuilderClassName": "Espo\\Classes\\DuplicateWhereBuilders\\General" +} diff --git a/application/Espo/Core/Templates/Metadata/Person/scopes.json b/application/Espo/Core/Templates/Metadata/Person/scopes.json new file mode 100644 index 0000000..6399067 --- /dev/null +++ b/application/Espo/Core/Templates/Metadata/Person/scopes.json @@ -0,0 +1,13 @@ +{ + "entity": true, + "layouts": true, + "tab": true, + "acl": true, + "aclPortal": true, + "aclPortalLevelList": ["all", "account", "contact", "own", "no"], + "customizable": true, + "importable": true, + "notifications": true, + "hasPersonalData": true, + "duplicateCheckFieldList": ["name", "emailAddress"] +} diff --git a/application/Espo/Core/Templates/Repositories/Base.php b/application/Espo/Core/Templates/Repositories/Base.php new file mode 100644 index 0000000..bc999f5 --- /dev/null +++ b/application/Espo/Core/Templates/Repositories/Base.php @@ -0,0 +1,39 @@ +. + * + * 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\Templates\Repositories; + +use Espo\Core\ORM\Entity; +use Espo\Core\Repositories\Database; + +/** + * @extends Database + */ +class Base extends Database +{} diff --git a/application/Espo/Core/Templates/Repositories/BasePlus.php b/application/Espo/Core/Templates/Repositories/BasePlus.php new file mode 100644 index 0000000..319ea62 --- /dev/null +++ b/application/Espo/Core/Templates/Repositories/BasePlus.php @@ -0,0 +1,39 @@ +. + * + * 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\Templates\Repositories; + +use Espo\Core\ORM\Entity; +use Espo\Core\Repositories\Database; + +/** + * @extends Database + */ +class BasePlus extends Database +{} diff --git a/application/Espo/Core/Templates/Repositories/CategoryTree.php b/application/Espo/Core/Templates/Repositories/CategoryTree.php new file mode 100644 index 0000000..6e75584 --- /dev/null +++ b/application/Espo/Core/Templates/Repositories/CategoryTree.php @@ -0,0 +1,38 @@ +. + * + * 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\Templates\Repositories; + +/** + * @template TEntity of \Espo\Core\Entities\CategoryTreeItem + * @extends \Espo\Core\Repositories\CategoryTree + */ +class CategoryTree extends \Espo\Core\Repositories\CategoryTree +{ +} diff --git a/application/Espo/Core/Templates/Repositories/Company.php b/application/Espo/Core/Templates/Repositories/Company.php new file mode 100644 index 0000000..e84e10b --- /dev/null +++ b/application/Espo/Core/Templates/Repositories/Company.php @@ -0,0 +1,39 @@ +. + * + * 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\Templates\Repositories; + +use Espo\Core\ORM\Entity; +use Espo\Core\Repositories\Database; + +/** + * @extends Database + */ +class Company extends Database +{} diff --git a/application/Espo/Core/Templates/Repositories/Event.php b/application/Espo/Core/Templates/Repositories/Event.php new file mode 100644 index 0000000..c66bf99 --- /dev/null +++ b/application/Espo/Core/Templates/Repositories/Event.php @@ -0,0 +1,35 @@ +. + * + * 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\Templates\Repositories; + +use Espo\Core\Repositories\Event as BaseEvent; + +class Event extends BaseEvent +{} diff --git a/application/Espo/Core/Templates/Repositories/Person.php b/application/Espo/Core/Templates/Repositories/Person.php new file mode 100644 index 0000000..2f17a6a --- /dev/null +++ b/application/Espo/Core/Templates/Repositories/Person.php @@ -0,0 +1,39 @@ +. + * + * 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\Templates\Repositories; + +use Espo\Core\ORM\Entity; +use Espo\Core\Repositories\Database; + +/** + * @extends Database + */ +class Person extends Database +{} diff --git a/application/Espo/Core/Templates/Services/Base.php b/application/Espo/Core/Templates/Services/Base.php new file mode 100644 index 0000000..8b47d03 --- /dev/null +++ b/application/Espo/Core/Templates/Services/Base.php @@ -0,0 +1,39 @@ +. + * + * 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\Templates\Services; + +use Espo\Core\Templates\Entities\Base as BaseEntity; +use Espo\Services\Record; + +/** + * @extends Record + */ +class Base extends Record +{} diff --git a/application/Espo/Core/Templates/Services/BasePlus.php b/application/Espo/Core/Templates/Services/BasePlus.php new file mode 100644 index 0000000..6e007e6 --- /dev/null +++ b/application/Espo/Core/Templates/Services/BasePlus.php @@ -0,0 +1,39 @@ +. + * + * 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\Templates\Services; + +use Espo\Core\Templates\Entities\BasePlus as BasePlusEntity; +use Espo\Services\Record; + +/** + * @extends Record + */ +class BasePlus extends Record +{} diff --git a/application/Espo/Core/Templates/Services/CategoryTree.php b/application/Espo/Core/Templates/Services/CategoryTree.php new file mode 100644 index 0000000..575bbe4 --- /dev/null +++ b/application/Espo/Core/Templates/Services/CategoryTree.php @@ -0,0 +1,39 @@ +. + * + * 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\Templates\Services; + +use Espo\Core\Templates\Entities\CategoryTree as CategoryTreeEntity; +use Espo\Services\RecordTree; + +/** + * @extends RecordTree + */ +class CategoryTree extends RecordTree +{} diff --git a/application/Espo/Core/Templates/Services/Company.php b/application/Espo/Core/Templates/Services/Company.php new file mode 100644 index 0000000..858fc3c --- /dev/null +++ b/application/Espo/Core/Templates/Services/Company.php @@ -0,0 +1,40 @@ +. + * + * 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\Templates\Services; + +use Espo\Core\Templates\Entities\Company as CompanyEntity; +use Espo\Services\Record; + +/** + * @extends Record + */ +class Company extends Record +{ +} diff --git a/application/Espo/Core/Templates/Services/Event.php b/application/Espo/Core/Templates/Services/Event.php new file mode 100644 index 0000000..a0c9ffe --- /dev/null +++ b/application/Espo/Core/Templates/Services/Event.php @@ -0,0 +1,39 @@ +. + * + * 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\Templates\Services; + +use Espo\Services\Record; + +/** + * @extends Record<\Espo\Core\Templates\Entities\Event> + * @deprecated Left for backward compatibility. + */ +class Event extends Record +{} diff --git a/application/Espo/Core/Templates/Services/Person.php b/application/Espo/Core/Templates/Services/Person.php new file mode 100644 index 0000000..8371427 --- /dev/null +++ b/application/Espo/Core/Templates/Services/Person.php @@ -0,0 +1,39 @@ +. + * + * 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\Templates\Services; + +use Espo\Core\Templates\Entities\Person as PersonEntity; +use Espo\Services\Record; + +/** + * @extends Record + */ +class Person extends Record +{} diff --git a/application/Espo/Core/Templates/i18n/ar_AR/Base.json b/application/Espo/Core/Templates/i18n/ar_AR/Base.json new file mode 100644 index 0000000..961eec9 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ar_AR/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "إنشاء {الكيانTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ar_AR/BasePlus.json b/application/Espo/Core/Templates/i18n/ar_AR/BasePlus.json new file mode 100644 index 0000000..5ddc2e3 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ar_AR/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "الاجتماعات", + "calls": "المكالمات", + "tasks": "مهام" + }, + "labels": { + "Create {entityType}": "إنشاء {الكيانTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ar_AR/Company.json b/application/Espo/Core/Templates/i18n/ar_AR/Company.json new file mode 100644 index 0000000..e2de487 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ar_AR/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "عنوان وصول الفواتير", + "shippingAddress": "عنوان الشحن", + "website": "موقع الكتروني" + }, + "links": { + "meetings": "الاجتماعات", + "calls": "المكالمات", + "tasks": "مهام" + }, + "labels": { + "Create {entityType}": "إنشاء {الكيانTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ar_AR/Event.json b/application/Espo/Core/Templates/i18n/ar_AR/Event.json new file mode 100644 index 0000000..d94e9d1 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ar_AR/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "الأبوين", + "dateStart": "تاريخ البدء", + "dateEnd": "تاريخ الانتهاء", + "duration": "مدة", + "status": "حالة", + "reminders": "تذكير", + "dateStartDate": "تاريخ البدء (طوال اليوم)", + "dateEndDate": "تاريخ الانتهاء (طوال اليوم)", + "isAllDay": "طوال اليوم" + }, + "links": { + "parent": "الأبوين" + }, + "options": { + "status": { + "Planned": "مخطط", + "Held": "محتجز", + "Not Held": "لم يعقد" + } + }, + "labels": { + "Create {entityType}": "إنشاء {الكيانTypeTranslated}", + "Schedule {entityType}": "جدولة {الكيانTypeTranslated}", + "Log {entityType}": "سجل {الكيانTypeTranslated}", + "Set Held": "مجموعة معلقة", + "Set Not Held": "التعيين لم يتم عقده" + }, + "massActions": { + "setHeld": "مجموعة معلقة", + "setNotHeld": "التعيين لم يتم عقده" + }, + "presetFilters": { + "planned": "مخطط", + "held": "محتجز", + "todays": "اليوم" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ar_AR/Person.json b/application/Espo/Core/Templates/i18n/ar_AR/Person.json new file mode 100644 index 0000000..fdbc3ae --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ar_AR/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "العنوان" + }, + "links": { + "meetings": "الاجتماعات", + "calls": "المكالمات", + "tasks": "مهام" + }, + "labels": { + "Create {entityType}": "إنشاء {الكيانTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/bg_BG/Base.json b/application/Espo/Core/Templates/i18n/bg_BG/Base.json new file mode 100644 index 0000000..47b4d57 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/bg_BG/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Създаване на {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/bg_BG/BasePlus.json b/application/Espo/Core/Templates/i18n/bg_BG/BasePlus.json new file mode 100644 index 0000000..1e86e4a --- /dev/null +++ b/application/Espo/Core/Templates/i18n/bg_BG/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Срещи", + "calls": "Разговори", + "tasks": "Задачи" + }, + "labels": { + "Create {entityType}": "Създаване на {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/bg_BG/CategoryTree.json b/application/Espo/Core/Templates/i18n/bg_BG/CategoryTree.json new file mode 100644 index 0000000..47b4d57 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/bg_BG/CategoryTree.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Създаване на {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/bg_BG/Company.json b/application/Espo/Core/Templates/i18n/bg_BG/Company.json new file mode 100644 index 0000000..1090942 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/bg_BG/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Адрес за плащане", + "shippingAddress": "Адрес за доставка", + "website": "Уебсайт" + }, + "links": { + "meetings": "Срещи", + "calls": "Обаждане", + "tasks": "Задачи" + }, + "labels": { + "Create {entityType}": "Създаване на {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/bg_BG/Event.json b/application/Espo/Core/Templates/i18n/bg_BG/Event.json new file mode 100644 index 0000000..60e0b4f --- /dev/null +++ b/application/Espo/Core/Templates/i18n/bg_BG/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Родител", + "dateStart": "Начална дата", + "dateEnd": "Крайна дата", + "duration": "Продължителност", + "status": "Статус", + "reminders": "Напомняния", + "dateStartDate": "Начална дата (цял ден)", + "dateEndDate": "Крайна дата (цял ден)", + "isAllDay": "Цял ден" + }, + "links": { + "parent": "Родител" + }, + "options": { + "status": { + "Planned": "Планирано", + "Held": "Състояло се", + "Not Held": "Несъстояло се" + } + }, + "labels": { + "Create {entityType}": "Създаване на {entityTypeTranslated}", + "Schedule {entityType}": "Насрочи {entityTypeTranslated}", + "Log {entityType}": "Регистрирай {entityTypeTranslated}", + "Set Held": "Задай като състояло се", + "Set Not Held": "Задай като несъстояло се" + }, + "massActions": { + "setHeld": "Задай като състояло се", + "setNotHeld": "Задай като несъстояло се" + }, + "presetFilters": { + "planned": "Планирано", + "held": "Състояли се", + "todays": "Днешните" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/bg_BG/Person.json b/application/Espo/Core/Templates/i18n/bg_BG/Person.json new file mode 100644 index 0000000..ff96fc3 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/bg_BG/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Адрес" + }, + "links": { + "meetings": "Срещи", + "calls": "Разговори", + "tasks": "Задачи" + }, + "labels": { + "Create {entityType}": "Създаване на {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/cs_CZ/Base.json b/application/Espo/Core/Templates/i18n/cs_CZ/Base.json new file mode 100644 index 0000000..35a3cbf --- /dev/null +++ b/application/Espo/Core/Templates/i18n/cs_CZ/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Vytvořit {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/cs_CZ/BasePlus.json b/application/Espo/Core/Templates/i18n/cs_CZ/BasePlus.json new file mode 100644 index 0000000..7d6dbb9 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/cs_CZ/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Schůzky", + "calls": "Hovory", + "tasks": "Úkoly" + }, + "labels": { + "Create {entityType}": "Vytvořit {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/cs_CZ/CategoryTree.json b/application/Espo/Core/Templates/i18n/cs_CZ/CategoryTree.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/cs_CZ/CategoryTree.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/cs_CZ/Company.json b/application/Espo/Core/Templates/i18n/cs_CZ/Company.json new file mode 100644 index 0000000..9bd849b --- /dev/null +++ b/application/Espo/Core/Templates/i18n/cs_CZ/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Fakturační adresa", + "shippingAddress": "Dodací adresa", + "website": "Webová stránka" + }, + "links": { + "meetings": "Schůzky", + "calls": "Hovory", + "tasks": "Úkoly" + }, + "labels": { + "Create {entityType}": "Vytvořit {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/cs_CZ/Event.json b/application/Espo/Core/Templates/i18n/cs_CZ/Event.json new file mode 100644 index 0000000..07da2c7 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/cs_CZ/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Rodič", + "dateStart": "Datum začátku", + "dateEnd": "Datum konce", + "duration": "Trvání", + "status": "Stav", + "reminders": "Připomínky", + "dateStartDate": "Datum začátku (celodenní)", + "dateEndDate": "Datum konce (celodenní)", + "isAllDay": "Je celodenní" + }, + "links": { + "parent": "Rodič" + }, + "options": { + "status": { + "Planned": "Plánováno", + "Held": "Uskutečněno", + "Not Held": "Neuskutečněno" + } + }, + "labels": { + "Create {entityType}": "Vytvořit {entityTypeTranslated}", + "Schedule {entityType}": "Naplánovat {entityTypeTranslated}", + "Log {entityType}": "Zaznamenat {entityTypeTranslated}", + "Set Held": "Označit jako uskutečněné", + "Set Not Held": "Označit jako neuskutečněné" + }, + "massActions": { + "setHeld": "Označit jako uskutečněné", + "setNotHeld": "Označit jako neuskutečněné" + }, + "presetFilters": { + "planned": "Plánované", + "held": "Uskutečněné", + "todays": "Dnešní" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/cs_CZ/Person.json b/application/Espo/Core/Templates/i18n/cs_CZ/Person.json new file mode 100644 index 0000000..18a8a42 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/cs_CZ/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adresa" + }, + "links": { + "meetings": "Schůzky", + "calls": "Hovory", + "tasks": "Úkoly" + }, + "labels": { + "Create {entityType}": "Vytvořit {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/da_DK/Base.json b/application/Espo/Core/Templates/i18n/da_DK/Base.json new file mode 100644 index 0000000..9d6670e --- /dev/null +++ b/application/Espo/Core/Templates/i18n/da_DK/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Opret {entityTypeTranslated} " + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/da_DK/BasePlus.json b/application/Espo/Core/Templates/i18n/da_DK/BasePlus.json new file mode 100644 index 0000000..5313501 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/da_DK/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Møder", + "calls": "Opkald", + "tasks": "Opgaver" + }, + "labels": { + "Create {entityType}": "Opret {entityTypeTranslated} " + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/da_DK/Company.json b/application/Espo/Core/Templates/i18n/da_DK/Company.json new file mode 100644 index 0000000..43cd6ac --- /dev/null +++ b/application/Espo/Core/Templates/i18n/da_DK/Company.json @@ -0,0 +1,14 @@ +{ + "fields": { + "billingAddress": "Betalingsadresse", + "shippingAddress": "Leveringsadresse" + }, + "links": { + "meetings": "Møder", + "calls": "Opkald", + "tasks": "Opgaver" + }, + "labels": { + "Create {entityType}": "Opret {entityTypeTranslated} " + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/da_DK/Event.json b/application/Espo/Core/Templates/i18n/da_DK/Event.json new file mode 100644 index 0000000..c710bd7 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/da_DK/Event.json @@ -0,0 +1,37 @@ +{ + "fields": { + "parent": "Henviser til", + "dateStart": "Startdato", + "dateEnd": "Slutdato", + "duration": "Varighed", + "reminders": "Underretninger", + "dateStartDate": "Start dato (hele dagen)", + "dateEndDate": "Slut dato (hele dagen)", + "isAllDay": "Hele dagen" + }, + "links": { + "parent": "Henviser til" + }, + "options": { + "status": { + "Planned": "Planlagt", + "Held": "Gennemført", + "Not Held": "Ikke Gennemført" + } + }, + "labels": { + "Create {entityType}": "Opret {entityTypeTranslated}", + "Schedule {entityType}": "Planlæg {entityTypeTranslated}", + "Set Held": "Marker som Gennemført", + "Set Not Held": "Marker som Ikke Gennemført" + }, + "massActions": { + "setHeld": "Marker som Gennemført", + "setNotHeld": "Marker som Ikke Gennemført" + }, + "presetFilters": { + "planned": "Planlagt", + "held": "Gennemført", + "todays": "Dagens" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/da_DK/Person.json b/application/Espo/Core/Templates/i18n/da_DK/Person.json new file mode 100644 index 0000000..9543dbf --- /dev/null +++ b/application/Espo/Core/Templates/i18n/da_DK/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adresse" + }, + "links": { + "meetings": "Møder", + "calls": "Opkald", + "tasks": "Opgaver" + }, + "labels": { + "Create {entityType}": "Opret {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/de_DE/Base.json b/application/Espo/Core/Templates/i18n/de_DE/Base.json new file mode 100644 index 0000000..9d1c7b1 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/de_DE/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "{entityTypeTranslated} erstellen" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/de_DE/BasePlus.json b/application/Espo/Core/Templates/i18n/de_DE/BasePlus.json new file mode 100644 index 0000000..2aaed00 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/de_DE/BasePlus.json @@ -0,0 +1,9 @@ +{ + "links": { + "calls": "Anrufe", + "tasks": "Aufgaben" + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} erstellen" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/de_DE/CategoryTree.json b/application/Espo/Core/Templates/i18n/de_DE/CategoryTree.json new file mode 100644 index 0000000..9d1c7b1 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/de_DE/CategoryTree.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "{entityTypeTranslated} erstellen" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/de_DE/Company.json b/application/Espo/Core/Templates/i18n/de_DE/Company.json new file mode 100644 index 0000000..d18fe6c --- /dev/null +++ b/application/Espo/Core/Templates/i18n/de_DE/Company.json @@ -0,0 +1,14 @@ +{ + "fields": { + "billingAddress": "Rechnungsadresse", + "shippingAddress": "Lieferadresse", + "website": "Webseite" + }, + "links": { + "calls": "Anrufe", + "tasks": "Aufgaben" + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} erstellen" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/de_DE/Event.json b/application/Espo/Core/Templates/i18n/de_DE/Event.json new file mode 100644 index 0000000..c8ffa0f --- /dev/null +++ b/application/Espo/Core/Templates/i18n/de_DE/Event.json @@ -0,0 +1,37 @@ +{ + "fields": { + "parent": "Bezieht sich auf", + "dateStart": "Startdatum", + "dateEnd": "Enddatum", + "duration": "Dauer", + "reminders": "Erinnerungen", + "dateStartDate": "Startdatum (ganztägig)", + "dateEndDate": "Enddatum (ganztägig)", + "isAllDay": "Ist ganztägig" + }, + "links": { + "parent": "Bezieht sich auf" + }, + "options": { + "status": { + "Planned": "Geplant", + "Held": "Durchgeführt", + "Not Held": "Nicht durchgeführt" + } + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} erstellen", + "Schedule {entityType}": "{entityTypeTranslated} planen", + "Set Held": "Auf durchgeführt setzen", + "Set Not Held": "Auf nicht durchgeführt setzen" + }, + "massActions": { + "setHeld": "Auf durchgeführt setzen", + "setNotHeld": "Auf nicht durchgeführt setzen" + }, + "presetFilters": { + "planned": "Geplant", + "held": "Durchgeführt", + "todays": "Heutige" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/de_DE/Person.json b/application/Espo/Core/Templates/i18n/de_DE/Person.json new file mode 100644 index 0000000..618bfb5 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/de_DE/Person.json @@ -0,0 +1,12 @@ +{ + "fields": { + "address": "Adresse" + }, + "links": { + "calls": "Anrufe", + "tasks": "Aufgaben" + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} erstellen" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/el_GR/Base.json b/application/Espo/Core/Templates/i18n/el_GR/Base.json new file mode 100644 index 0000000..9dc8a97 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/el_GR/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Δημιουργία {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/el_GR/BasePlus.json b/application/Espo/Core/Templates/i18n/el_GR/BasePlus.json new file mode 100644 index 0000000..007a8f2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/el_GR/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Συναντήσεις", + "calls": "Κλήσεις", + "tasks": "Εργασίες" + }, + "labels": { + "Create {entityType}": "Δημιουργία {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/el_GR/Company.json b/application/Espo/Core/Templates/i18n/el_GR/Company.json new file mode 100644 index 0000000..4d884b0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/el_GR/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Διεύθυνση Τιμολόγησης", + "shippingAddress": "Διεύθυνση Αποστολής", + "website": "Ιστοσελίδα" + }, + "links": { + "meetings": "Συναντήσεις", + "calls": "Κλήσεις", + "tasks": "Εργασίες" + }, + "labels": { + "Create {entityType}": "Δημιουργία {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/el_GR/Event.json b/application/Espo/Core/Templates/i18n/el_GR/Event.json new file mode 100644 index 0000000..fdc3c8e --- /dev/null +++ b/application/Espo/Core/Templates/i18n/el_GR/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Γονικό", + "dateStart": "Ημερομηνία Έναρξης", + "dateEnd": "Ημερομηνία λήξης", + "duration": "Διάρκεια", + "status": "Κατάσταση", + "reminders": "Υπενθυμίσεις", + "dateStartDate": "Ημερομηνία Έναρξης (όλη την ημέρα)", + "dateEndDate": "Ημερομηνία λήξης (όλη την ημέρα)", + "isAllDay": "Όλη την ημέρα" + }, + "links": { + "parent": "Γονικό" + }, + "options": { + "status": { + "Planned": "Προγραμματισμένο", + "Held": "Έχει συμβεί", + "Not Held": "Δεν Πραγματοποιήθηκε" + } + }, + "labels": { + "Create {entityType}": "Δημιουργία {entityTypeTranslated}", + "Schedule {entityType}": "Προγραμματισμός {entityTypeTranslated}", + "Log {entityType}": "Αρχείο καταγραφής {entityTypeTranslated}", + "Set Held": "Ορισμός ως Πραγματοποιηθέν", + "Set Not Held": "Ορισμός ως Μη Πραγματοποιηθέν" + }, + "massActions": { + "setHeld": "Ορισμός ως Μη Πραγματοποιηθέν", + "setNotHeld": "Ορισμός ως Μη Πραγματοποιηθέν" + }, + "presetFilters": { + "planned": "Προγραμματισμένο", + "held": "Έχουν συμβεί", + "todays": "Σημερινά" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/el_GR/Person.json b/application/Espo/Core/Templates/i18n/el_GR/Person.json new file mode 100644 index 0000000..7355c4f --- /dev/null +++ b/application/Espo/Core/Templates/i18n/el_GR/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Διεύθυνση" + }, + "links": { + "meetings": "Συναντήσεις", + "calls": "Κλήσεις", + "tasks": "Εργασίες" + }, + "labels": { + "Create {entityType}": "Δημιουργία {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/en_GB/Base.json b/application/Espo/Core/Templates/i18n/en_GB/Base.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_GB/Base.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/en_GB/BasePlus.json b/application/Espo/Core/Templates/i18n/en_GB/BasePlus.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_GB/BasePlus.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/en_GB/Company.json b/application/Espo/Core/Templates/i18n/en_GB/Company.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_GB/Company.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/en_GB/Event.json b/application/Espo/Core/Templates/i18n/en_GB/Event.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_GB/Event.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/en_GB/Person.json b/application/Espo/Core/Templates/i18n/en_GB/Person.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_GB/Person.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/en_US/Base.json b/application/Espo/Core/Templates/i18n/en_US/Base.json new file mode 100644 index 0000000..2597fd6 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_US/Base.json @@ -0,0 +1,9 @@ +{ + "fields": { + }, + "links": { + }, + "labels": { + "Create {entityType}": "Create {entityTypeTranslated}" + } +} diff --git a/application/Espo/Core/Templates/i18n/en_US/BasePlus.json b/application/Espo/Core/Templates/i18n/en_US/BasePlus.json new file mode 100644 index 0000000..e925510 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_US/BasePlus.json @@ -0,0 +1,12 @@ +{ + "fields": { + }, + "links": { + "meetings": "Meetings", + "calls": "Calls", + "tasks": "Tasks" + }, + "labels": { + "Create {entityType}": "Create {entityTypeTranslated}" + } +} diff --git a/application/Espo/Core/Templates/i18n/en_US/CategoryTree.json b/application/Espo/Core/Templates/i18n/en_US/CategoryTree.json new file mode 100644 index 0000000..81eede1 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_US/CategoryTree.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Create {entityTypeTranslated}" + } +} diff --git a/application/Espo/Core/Templates/i18n/en_US/Company.json b/application/Espo/Core/Templates/i18n/en_US/Company.json new file mode 100644 index 0000000..341895d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_US/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Billing Address", + "shippingAddress": "Shipping Address", + "website": "Website" + }, + "links": { + "meetings": "Meetings", + "calls": "Calls", + "tasks": "Tasks" + }, + "labels": { + "Create {entityType}": "Create {entityTypeTranslated}" + } +} diff --git a/application/Espo/Core/Templates/i18n/en_US/Event.json b/application/Espo/Core/Templates/i18n/en_US/Event.json new file mode 100644 index 0000000..257c4e3 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_US/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Parent", + "dateStart": "Date Start", + "dateEnd": "Date End", + "duration": "Duration", + "status": "Status", + "reminders": "Reminders", + "dateStartDate": "Date Start (all day)", + "dateEndDate": "Date End (all day)", + "isAllDay": "Is All-Day" + }, + "links": { + "parent": "Parent" + }, + "options": { + "status": { + "Planned": "Planned", + "Held": "Held", + "Not Held": "Not Held" + } + }, + "labels": { + "Create {entityType}": "Create {entityTypeTranslated}", + "Schedule {entityType}": "Schedule {entityTypeTranslated}", + "Log {entityType}": "Log {entityTypeTranslated}", + "Set Held": "Set Held", + "Set Not Held": "Set Not Held" + }, + "massActions": { + "setHeld": "Set Held", + "setNotHeld": "Set Not Held" + }, + "presetFilters": { + "planned": "Planned", + "held": "Held", + "todays": "Today's" + } +} diff --git a/application/Espo/Core/Templates/i18n/en_US/Person.json b/application/Espo/Core/Templates/i18n/en_US/Person.json new file mode 100644 index 0000000..7793632 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/en_US/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Address" + }, + "links": { + "meetings": "Meetings", + "calls": "Calls", + "tasks": "Tasks" + }, + "labels": { + "Create {entityType}": "Create {entityTypeTranslated}" + } +} diff --git a/application/Espo/Core/Templates/i18n/es_ES/Base.json b/application/Espo/Core/Templates/i18n/es_ES/Base.json new file mode 100644 index 0000000..f238c9d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_ES/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/es_ES/BasePlus.json b/application/Espo/Core/Templates/i18n/es_ES/BasePlus.json new file mode 100644 index 0000000..4407c16 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_ES/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Reuniones", + "calls": "Llamadas", + "tasks": "Tareas" + }, + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/es_ES/Company.json b/application/Espo/Core/Templates/i18n/es_ES/Company.json new file mode 100644 index 0000000..56dbdfa --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_ES/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Dirección de facturación", + "shippingAddress": "Dirección de envío", + "website": "Sitio web" + }, + "links": { + "meetings": "Reuniones", + "calls": "Llamadas", + "tasks": "Tareas" + }, + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/es_ES/Event.json b/application/Espo/Core/Templates/i18n/es_ES/Event.json new file mode 100644 index 0000000..40dbddd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_ES/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Padre", + "dateStart": "Fecha de inicio", + "dateEnd": "Fecha de finalización", + "duration": "Duración", + "status": "Estado", + "reminders": "Recordatorios", + "dateStartDate": "Fecha de inicio (todo el día)", + "dateEndDate": "Fecha de finalización (todo el día)", + "isAllDay": "Es todo el día" + }, + "links": { + "parent": "Padre" + }, + "options": { + "status": { + "Planned": "Planeado", + "Held": "Agarrado", + "Not Held": "Sin celebrar" + } + }, + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}", + "Schedule {entityType}": "Programar {entityTypeTranslated}", + "Log {entityType}": "Registros de {entityTypeTranslated}", + "Set Held": "Marcar como celebrada", + "Set Not Held": "Marcar como No celebrada" + }, + "massActions": { + "setHeld": "Marcar como celebrada", + "setNotHeld": "Marcar como No celebrada" + }, + "presetFilters": { + "planned": "Planeadas", + "held": "Celebradas", + "todays": "De hoy" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/es_ES/Person.json b/application/Espo/Core/Templates/i18n/es_ES/Person.json new file mode 100644 index 0000000..8244d12 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_ES/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Dirección" + }, + "links": { + "meetings": "Reuniones", + "calls": "Llamadas", + "tasks": "Tareas" + }, + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/es_MX/Base.json b/application/Espo/Core/Templates/i18n/es_MX/Base.json new file mode 100644 index 0000000..f238c9d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_MX/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/es_MX/BasePlus.json b/application/Espo/Core/Templates/i18n/es_MX/BasePlus.json new file mode 100644 index 0000000..638c821 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_MX/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Presentaciones", + "calls": "Llamadas", + "tasks": "Tareas" + }, + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/es_MX/Company.json b/application/Espo/Core/Templates/i18n/es_MX/Company.json new file mode 100644 index 0000000..eeaf29b --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_MX/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Dirección de Facturación", + "shippingAddress": "Dirección de Entrega", + "website": "Sitio Web" + }, + "links": { + "meetings": "Presentaciones", + "calls": "Llamadas", + "tasks": "Tareas" + }, + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/es_MX/Event.json b/application/Espo/Core/Templates/i18n/es_MX/Event.json new file mode 100644 index 0000000..51ce142 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_MX/Event.json @@ -0,0 +1,36 @@ +{ + "fields": { + "parent": "Padre", + "dateStart": "Fecha de incio", + "dateEnd": "Fecha final", + "duration": "Duracion", + "status": "Estátus", + "reminders": "Recordatorios" + }, + "links": { + "parent": "Padre" + }, + "options": { + "status": { + "Planned": "Planeado", + "Held": "Retenidas", + "Not Held": "Pendientes" + } + }, + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}", + "Schedule {entityType}": "Agendar {entityTypeTranslated}", + "Log {entityType}": "Registrar {entityTypeTranslated}", + "Set Held": "Marcar como Retenida", + "Set Not Held": "Marcar como Pendiente" + }, + "massActions": { + "setHeld": "Marcar como Retenida", + "setNotHeld": "Marcar como Pendiente" + }, + "presetFilters": { + "planned": "Planeado", + "held": "Retenida", + "todays": "Hoy" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/es_MX/Person.json b/application/Espo/Core/Templates/i18n/es_MX/Person.json new file mode 100644 index 0000000..31a2f3f --- /dev/null +++ b/application/Espo/Core/Templates/i18n/es_MX/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Dirección" + }, + "links": { + "meetings": "Presentaciones", + "calls": "Llamadas", + "tasks": "Tareas" + }, + "labels": { + "Create {entityType}": "Crear {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fa_IR/Base.json b/application/Espo/Core/Templates/i18n/fa_IR/Base.json new file mode 100644 index 0000000..4fe7c4e --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fa_IR/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "ایجاد {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fa_IR/BasePlus.json b/application/Espo/Core/Templates/i18n/fa_IR/BasePlus.json new file mode 100644 index 0000000..79dd1e4 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fa_IR/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "جلسات", + "calls": "تماس ها", + "tasks": "وظایف" + }, + "labels": { + "Create {entityType}": "ایجاد {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fa_IR/Company.json b/application/Espo/Core/Templates/i18n/fa_IR/Company.json new file mode 100644 index 0000000..989c0e1 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fa_IR/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "آدرس صورتحساب", + "shippingAddress": "آدرس حمل و نقل", + "website": "وب سایت" + }, + "links": { + "meetings": "جلسات", + "calls": "تماس ها", + "tasks": "وظایف" + }, + "labels": { + "Create {entityType}": "ایجاد {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fa_IR/Event.json b/application/Espo/Core/Templates/i18n/fa_IR/Event.json new file mode 100644 index 0000000..ca89eca --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fa_IR/Event.json @@ -0,0 +1,34 @@ +{ + "fields": { + "parent": "پدر", + "dateStart": "تاریخ شروع", + "dateEnd": "تاریخ پایان", + "duration": "مدت زمان", + "status": "وضعیت", + "reminders": "یادآوری ها" + }, + "links": { + "parent": "پدر" + }, + "options": { + "status": { + "Planned": "برنامه ریزی شده", + "Held": "برگزار شد", + "Not Held": "برگزار نشد" + } + }, + "labels": { + "Create {entityType}": "ایجاد {entityTypeTranslated}", + "Set Held": "تنظیم برگذاری", + "Set Not Held": "تنظیم عدم برگذاری" + }, + "massActions": { + "setHeld": "تنظیم برگذاری", + "setNotHeld": "تنظیم عدم برگذاری" + }, + "presetFilters": { + "planned": "برنامه ریزی شده", + "held": "برگزار شد", + "todays": "امروزه" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fa_IR/Person.json b/application/Espo/Core/Templates/i18n/fa_IR/Person.json new file mode 100644 index 0000000..c7010c0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fa_IR/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "آدرس" + }, + "links": { + "meetings": "جلسات", + "calls": "تماس ها", + "tasks": "وظایف" + }, + "labels": { + "Create {entityType}": "ایجاد {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fr_FR/Base.json b/application/Espo/Core/Templates/i18n/fr_FR/Base.json new file mode 100644 index 0000000..5d823cd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fr_FR/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Créer un {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fr_FR/BasePlus.json b/application/Espo/Core/Templates/i18n/fr_FR/BasePlus.json new file mode 100644 index 0000000..5d823cd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fr_FR/BasePlus.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Créer un {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fr_FR/Company.json b/application/Espo/Core/Templates/i18n/fr_FR/Company.json new file mode 100644 index 0000000..5d823cd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fr_FR/Company.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Créer un {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fr_FR/Event.json b/application/Espo/Core/Templates/i18n/fr_FR/Event.json new file mode 100644 index 0000000..22a2656 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fr_FR/Event.json @@ -0,0 +1,15 @@ +{ + "fields": { + "dateStart": "Date de début", + "dateEnd": "Date de fin", + "reminders": "Rappels", + "dateStartDate": "Date de début (toute la journée)", + "dateEndDate": "Date de fin (toute la journée)", + "isAllDay": "Est toute la journée" + }, + "labels": { + "Create {entityType}": "Créer un {entityTypeTranslated}", + "Schedule {entityType}": "Programmer un {entityTypeTranslated}", + "Log {entityType}": "Log de {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/fr_FR/Person.json b/application/Espo/Core/Templates/i18n/fr_FR/Person.json new file mode 100644 index 0000000..5d823cd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/fr_FR/Person.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Créer un {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hr_HR/Base.json b/application/Espo/Core/Templates/i18n/hr_HR/Base.json new file mode 100644 index 0000000..c658a6e --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hr_HR/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Napravi {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hr_HR/BasePlus.json b/application/Espo/Core/Templates/i18n/hr_HR/BasePlus.json new file mode 100644 index 0000000..4c61255 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hr_HR/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Sastanci", + "calls": "Pozivi", + "tasks": "Zadaci" + }, + "labels": { + "Create {entityType}": "Kreiraj {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hr_HR/Company.json b/application/Espo/Core/Templates/i18n/hr_HR/Company.json new file mode 100644 index 0000000..ab1de01 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hr_HR/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Adresa za naplatu", + "shippingAddress": "Adresa za dostavu", + "website": "Sajt" + }, + "links": { + "meetings": "Sastanci", + "calls": "Pozivi", + "tasks": "Zadaci" + }, + "labels": { + "Create {entityType}": "Kreiraj {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hr_HR/Event.json b/application/Espo/Core/Templates/i18n/hr_HR/Event.json new file mode 100644 index 0000000..8b18fb8 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hr_HR/Event.json @@ -0,0 +1,35 @@ +{ + "fields": { + "parent": "Nadređen", + "dateStart": "Početni datum", + "dateEnd": "Završni datum", + "duration": "Trajanje", + "reminders": "Podsjetnici" + }, + "links": { + "parent": "Nadređen" + }, + "options": { + "status": { + "Planned": "Planiran", + "Held": "Održan", + "Not Held": "Nije održan" + } + }, + "labels": { + "Create {entityType}": "Kreiraj {entityTypeTranslated}", + "Schedule {entityType}": "Zakaži {entityTypeTranslated}", + "Log {entityType}": "Zabilježi {entitiTipeTranslated}", + "Set Held": "Postavi kao održano", + "Set Not Held": "Postavi kao nije održano" + }, + "massActions": { + "setHeld": "Postavi kao održano", + "setNotHeld": "Postavi kao nije održano" + }, + "presetFilters": { + "planned": "Planiran", + "held": "Održan", + "todays": "Današnji" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hr_HR/Person.json b/application/Espo/Core/Templates/i18n/hr_HR/Person.json new file mode 100644 index 0000000..2a4e7ea --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hr_HR/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adresa" + }, + "links": { + "meetings": "Sastanci", + "calls": "Pozivi", + "tasks": "Zadaci" + }, + "labels": { + "Create {entityType}": "Kreiraj {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hu_HU/Base.json b/application/Espo/Core/Templates/i18n/hu_HU/Base.json new file mode 100644 index 0000000..e86131a --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hu_HU/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "{EntityTypeTranslated} létrehozása" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hu_HU/BasePlus.json b/application/Espo/Core/Templates/i18n/hu_HU/BasePlus.json new file mode 100644 index 0000000..593d672 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hu_HU/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "találkozók", + "calls": "felhívja", + "tasks": "Feladatok" + }, + "labels": { + "Create {entityType}": "{EntityTypeTranslated} létrehozása" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hu_HU/Company.json b/application/Espo/Core/Templates/i18n/hu_HU/Company.json new file mode 100644 index 0000000..a06b636 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hu_HU/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "számlázási cím", + "shippingAddress": "szállítási cím", + "website": "Weboldal" + }, + "links": { + "meetings": "találkozók", + "calls": "felhívja", + "tasks": "Feladatok" + }, + "labels": { + "Create {entityType}": "{EntityTypeTranslated} létrehozása" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hu_HU/Event.json b/application/Espo/Core/Templates/i18n/hu_HU/Event.json new file mode 100644 index 0000000..0008605 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hu_HU/Event.json @@ -0,0 +1,27 @@ +{ + "fields": { + "parent": "Szülő", + "dateStart": "Dátum kezdete", + "dateEnd": "Dátum vége", + "duration": "tartam", + "status": "Állapot", + "reminders": "Emlékeztetők" + }, + "links": { + "parent": "Szülő" + }, + "options": { + "status": { + "Planned": "Tervezett", + "Not Held": "Nem tartott" + } + }, + "labels": { + "Create {entityType}": "{EntityTypeTranslated} létrehozása", + "Schedule {entityType}": "{EntityTypeTranslated} ütemezése" + }, + "presetFilters": { + "planned": "Tervezett", + "todays": "A mai" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/hu_HU/Person.json b/application/Espo/Core/Templates/i18n/hu_HU/Person.json new file mode 100644 index 0000000..1a63ed4 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/hu_HU/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Cím" + }, + "links": { + "meetings": "találkozók", + "calls": "felhívja", + "tasks": "Feladatok" + }, + "labels": { + "Create {entityType}": "{EntityTypeTranslated} létrehozása" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/id_ID/Base.json b/application/Espo/Core/Templates/i18n/id_ID/Base.json new file mode 100644 index 0000000..d3310a0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/id_ID/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Buat {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/id_ID/BasePlus.json b/application/Espo/Core/Templates/i18n/id_ID/BasePlus.json new file mode 100644 index 0000000..d3310a0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/id_ID/BasePlus.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Buat {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/id_ID/Company.json b/application/Espo/Core/Templates/i18n/id_ID/Company.json new file mode 100644 index 0000000..d3310a0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/id_ID/Company.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Buat {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/id_ID/Event.json b/application/Espo/Core/Templates/i18n/id_ID/Event.json new file mode 100644 index 0000000..d3310a0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/id_ID/Event.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Buat {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/id_ID/Person.json b/application/Espo/Core/Templates/i18n/id_ID/Person.json new file mode 100644 index 0000000..d3310a0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/id_ID/Person.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Buat {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/it_IT/Base.json b/application/Espo/Core/Templates/i18n/it_IT/Base.json new file mode 100644 index 0000000..8e02ccd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/it_IT/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Crea {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/it_IT/BasePlus.json b/application/Espo/Core/Templates/i18n/it_IT/BasePlus.json new file mode 100644 index 0000000..52956b5 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/it_IT/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Riunioni", + "calls": "Chiamate", + "tasks": "Compiti" + }, + "labels": { + "Create {entityType}": "Crea {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/it_IT/Company.json b/application/Espo/Core/Templates/i18n/it_IT/Company.json new file mode 100644 index 0000000..9af8e2c --- /dev/null +++ b/application/Espo/Core/Templates/i18n/it_IT/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Indirizzo di fatturazione", + "shippingAddress": "Indirizzo di spedizione", + "website": "Sito web" + }, + "links": { + "meetings": "Riunioni", + "calls": "Chiamate", + "tasks": "Compiti" + }, + "labels": { + "Create {entityType}": "Crea {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/it_IT/Event.json b/application/Espo/Core/Templates/i18n/it_IT/Event.json new file mode 100644 index 0000000..b594eac --- /dev/null +++ b/application/Espo/Core/Templates/i18n/it_IT/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Genitore", + "dateStart": "Data Inizio", + "dateEnd": "Data Fine", + "duration": "Durata", + "status": "Stato", + "reminders": "Promemoria", + "dateStartDate": "Data Inizio (tutto il giorno)", + "dateEndDate": "Data Fine (tutto il giorno)", + "isAllDay": "È tutto il giorno" + }, + "links": { + "parent": "Genitore" + }, + "options": { + "status": { + "Planned": "Pianificato", + "Held": "Effettuata", + "Not Held": "Non Effettuata" + } + }, + "labels": { + "Create {entityType}": "Crea {entityTypeTranslated}", + "Schedule {entityType}": "Pianifica {entityTypeTranslated}", + "Log {entityType}": "Registra {entityTypeTranslated}", + "Set Held": "Effettuata", + "Set Not Held": "Non Effettuata" + }, + "massActions": { + "setHeld": "Effettuata", + "setNotHeld": "Non Effettuata" + }, + "presetFilters": { + "planned": "Pianificato", + "held": "Effettuata", + "todays": "Di Oggi" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/it_IT/Person.json b/application/Espo/Core/Templates/i18n/it_IT/Person.json new file mode 100644 index 0000000..436a874 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/it_IT/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Indirizzo" + }, + "links": { + "meetings": "Riunioni", + "calls": "Chiamate", + "tasks": "Compiti" + }, + "labels": { + "Create {entityType}": "Crea {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ja_JP/Base.json b/application/Espo/Core/Templates/i18n/ja_JP/Base.json new file mode 100644 index 0000000..86721a2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ja_JP/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "{entityTypeTranslated} を作成する" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ja_JP/BasePlus.json b/application/Espo/Core/Templates/i18n/ja_JP/BasePlus.json new file mode 100644 index 0000000..81d3c98 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ja_JP/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "会議", + "calls": "通話", + "tasks": "タスク" + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} を作成する" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ja_JP/Company.json b/application/Espo/Core/Templates/i18n/ja_JP/Company.json new file mode 100644 index 0000000..0d869d0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ja_JP/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "請求先住所", + "shippingAddress": "お届け先の住所", + "website": " Webサイト" + }, + "links": { + "meetings": "会議", + "calls": "通話", + "tasks": "タスク" + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} を作成する" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ja_JP/Event.json b/application/Espo/Core/Templates/i18n/ja_JP/Event.json new file mode 100644 index 0000000..8739803 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ja_JP/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "親", + "dateStart": "開始日", + "dateEnd": "終了日", + "duration": "間隔", + "status": "状態", + "reminders": "リマインダー", + "dateStartDate": "開始日(終日)", + "dateEndDate": "日付終了(終日)", + "isAllDay": "終日" + }, + "links": { + "parent": "親" + }, + "options": { + "status": { + "Planned": "計画済み", + "Held": "開催", + "Not Held": "開催されなかった" + } + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} を作成する", + "Schedule {entityType}": "スケジュール {entityTypeTranslated}", + "Log {entityType}": "ログ {entityTypeTranslated}", + "Set Held": "セットホールド", + "Set Not Held": "セットは保持されません" + }, + "massActions": { + "setHeld": "セットホールド", + "setNotHeld": "セットは保持されません" + }, + "presetFilters": { + "planned": "計画済み", + "held": "開催", + "todays": "今日の" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ja_JP/Person.json b/application/Espo/Core/Templates/i18n/ja_JP/Person.json new file mode 100644 index 0000000..a438894 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ja_JP/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "住所" + }, + "links": { + "meetings": "会議", + "calls": "通話", + "tasks": "タスク" + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} を作成する" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lt_LT/Base.json b/application/Espo/Core/Templates/i18n/lt_LT/Base.json new file mode 100644 index 0000000..ab68911 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lt_LT/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Sukurti {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lt_LT/BasePlus.json b/application/Espo/Core/Templates/i18n/lt_LT/BasePlus.json new file mode 100644 index 0000000..7dbb435 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lt_LT/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Susitikimai", + "calls": "Skambučiai", + "tasks": "Užduotys" + }, + "labels": { + "Create {entityType}": "Sukurti {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lt_LT/CategoryTree.json b/application/Espo/Core/Templates/i18n/lt_LT/CategoryTree.json new file mode 100644 index 0000000..ab68911 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lt_LT/CategoryTree.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Sukurti {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lt_LT/Company.json b/application/Espo/Core/Templates/i18n/lt_LT/Company.json new file mode 100644 index 0000000..1a7cd58 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lt_LT/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Mokėtojo adresas", + "shippingAddress": "Siuntimo adresas", + "website": "Internetinė Svetainė" + }, + "links": { + "meetings": "Susitikimai", + "calls": "Skambučiai", + "tasks": "Užduotys" + }, + "labels": { + "Create {entityType}": "Sukurti {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lt_LT/Event.json b/application/Espo/Core/Templates/i18n/lt_LT/Event.json new file mode 100644 index 0000000..1295ff9 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lt_LT/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Pagrindinis", + "dateStart": "Pradžios data", + "dateEnd": "Pabaigos data", + "duration": "Trukmė", + "status": "Statusas", + "reminders": "Priminimai", + "dateStartDate": "Data Pradžia (visą dieną)", + "dateEndDate": "Data Pabaiga (visą dieną)", + "isAllDay": "Ar visą dieną" + }, + "links": { + "parent": "Pagrindinis" + }, + "options": { + "status": { + "Planned": "Suplanuota", + "Held": "Įvykdyta", + "Not Held": "Neįvykdyta" + } + }, + "labels": { + "Create {entityType}": "Sukurti {entityTypeTranslated} ", + "Schedule {entityType}": "Suplanuoti {entityTypeTranslated}", + "Log {entityType}": "Žurnalas {entityTypeTranslated}", + "Set Held": "Pažymėti kaip įvykdytą", + "Set Not Held": "Pažymėti kaip neįvykdytą" + }, + "massActions": { + "setHeld": "Pažymėti kaip įvykdytą", + "setNotHeld": "Pažymėti kaip neįvykdytą" + }, + "presetFilters": { + "planned": "Suplanuota", + "held": "Įvykdyta", + "todays": "Šiandienos" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lt_LT/Person.json b/application/Espo/Core/Templates/i18n/lt_LT/Person.json new file mode 100644 index 0000000..cafb590 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lt_LT/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adresas" + }, + "links": { + "meetings": "Susitikimai", + "calls": "Skambučiai", + "tasks": "Užduotys" + }, + "labels": { + "Create {entityType}": "Sukurti {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lv_LV/Base.json b/application/Espo/Core/Templates/i18n/lv_LV/Base.json new file mode 100644 index 0000000..a153ac5 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lv_LV/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Izveidot {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lv_LV/BasePlus.json b/application/Espo/Core/Templates/i18n/lv_LV/BasePlus.json new file mode 100644 index 0000000..af527a7 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lv_LV/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Tikšanās", + "calls": "Zvani", + "tasks": "Uzdevumi" + }, + "labels": { + "Create {entityType}": "Izveidot {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lv_LV/CategoryTree.json b/application/Espo/Core/Templates/i18n/lv_LV/CategoryTree.json new file mode 100644 index 0000000..a153ac5 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lv_LV/CategoryTree.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Izveidot {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lv_LV/Company.json b/application/Espo/Core/Templates/i18n/lv_LV/Company.json new file mode 100644 index 0000000..ffcd193 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lv_LV/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Norēķinu adrese", + "shippingAddress": "Piegādes adrese", + "website": "Vietne" + }, + "links": { + "meetings": "Tikšanās", + "calls": "Zvani", + "tasks": "Uzdevumi" + }, + "labels": { + "Create {entityType}": "Izveidot {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lv_LV/Event.json b/application/Espo/Core/Templates/i18n/lv_LV/Event.json new file mode 100644 index 0000000..bb04de4 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lv_LV/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Primārais elements", + "dateStart": "Sākuma datums", + "dateEnd": "Beigu datums", + "duration": "Ilgums", + "status": "Statuss", + "reminders": "Atgādinājumi", + "dateStartDate": "Datums Sākums (visu dienu)", + "dateEndDate": "Beigu datums (visu dienu)", + "isAllDay": "Vai ir visas dienas" + }, + "links": { + "parent": "Primārais elements" + }, + "options": { + "status": { + "Planned": "Plānots", + "Held": "Noticis", + "Not Held": "Nenoticis" + } + }, + "labels": { + "Create {entityType}": "Izveidot {entityTypeTranslated}", + "Schedule {entityType}": "Ieplānot {entityTypeTranslated}", + "Log {entityType}": "Reģistrēt {entityTypeTranslated}", + "Set Held": "Iestatīt kā notikušu", + "Set Not Held": "Iestatīt kā nenotikušu" + }, + "massActions": { + "setHeld": "Iestatīt kā notikušu", + "setNotHeld": "Iestatīt kā nenotikušu" + }, + "presetFilters": { + "planned": "Ieplānots", + "held": "Noticis", + "todays": "Šodienas" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/lv_LV/Person.json b/application/Espo/Core/Templates/i18n/lv_LV/Person.json new file mode 100644 index 0000000..7c841c4 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/lv_LV/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adrese" + }, + "links": { + "meetings": "Tikšanās", + "calls": "Zvani", + "tasks": "Uzdevumi" + }, + "labels": { + "Create {entityType}": "Izveidot {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nb_NO/Base.json b/application/Espo/Core/Templates/i18n/nb_NO/Base.json new file mode 100644 index 0000000..efa8ea5 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nb_NO/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Opprett {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nb_NO/BasePlus.json b/application/Espo/Core/Templates/i18n/nb_NO/BasePlus.json new file mode 100644 index 0000000..5bb262c --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nb_NO/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Møter", + "calls": "Samtaler", + "tasks": "Oppgaver" + }, + "labels": { + "Create {entityType}": "Opprett {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nb_NO/Company.json b/application/Espo/Core/Templates/i18n/nb_NO/Company.json new file mode 100644 index 0000000..2ae0686 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nb_NO/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Faktureringsadresse", + "shippingAddress": "Leveringsadresse", + "website": "Nettside" + }, + "links": { + "meetings": "Møter", + "calls": "Samtaler", + "tasks": "Oppgaver" + }, + "labels": { + "Create {entityType}": "Opprett {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nb_NO/Event.json b/application/Espo/Core/Templates/i18n/nb_NO/Event.json new file mode 100644 index 0000000..289c089 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nb_NO/Event.json @@ -0,0 +1,38 @@ +{ + "fields": { + "parent": "Forelder", + "dateStart": "Startdato", + "dateEnd": "Sluttdato", + "duration": "Varighet", + "reminders": "Påminnelser", + "dateStartDate": "Startdato (hele dagen)", + "dateEndDate": "Sluttdato (hele dagen)", + "isAllDay": "Er heldags" + }, + "links": { + "parent": "Forelder" + }, + "options": { + "status": { + "Planned": "Planlagt", + "Held": "Gjennomført", + "Not Held": "Ikke gjennomført" + } + }, + "labels": { + "Create {entityType}": "Opprett {entityTypeTranslated}", + "Schedule {entityType}": "Planlegg {entityTypeTranslated}", + "Log {entityType}": "Logg {entityTypeTranslated}", + "Set Held": "Merk som gjennomført", + "Set Not Held": "Merk som ikke gjennomført" + }, + "massActions": { + "setHeld": "Merk som gjennomført", + "setNotHeld": "Merk som ikke gjennomført" + }, + "presetFilters": { + "planned": "Planlagt", + "held": "Gjennomført", + "todays": "Dagens" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nb_NO/Person.json b/application/Espo/Core/Templates/i18n/nb_NO/Person.json new file mode 100644 index 0000000..ea90976 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nb_NO/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adresse" + }, + "links": { + "meetings": "Møter", + "calls": "Samtaler", + "tasks": "Oppgaver" + }, + "labels": { + "Create {entityType}": "Opprett {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nl_NL/Base.json b/application/Espo/Core/Templates/i18n/nl_NL/Base.json new file mode 100644 index 0000000..83d06da --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nl_NL/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Creëer {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nl_NL/BasePlus.json b/application/Espo/Core/Templates/i18n/nl_NL/BasePlus.json new file mode 100644 index 0000000..779b41d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nl_NL/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Vergaderingen", + "calls": "Gesprekken", + "tasks": "Taken" + }, + "labels": { + "Create {entityType}": "Creëer {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nl_NL/CategoryTree.json b/application/Espo/Core/Templates/i18n/nl_NL/CategoryTree.json new file mode 100644 index 0000000..aa2a73a --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nl_NL/CategoryTree.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Maak {entityTypeTranslated} aan" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nl_NL/Company.json b/application/Espo/Core/Templates/i18n/nl_NL/Company.json new file mode 100644 index 0000000..5b2cf7b --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nl_NL/Company.json @@ -0,0 +1,14 @@ +{ + "fields": { + "billingAddress": "Factuuradres", + "shippingAddress": "Vestigingsadres" + }, + "links": { + "meetings": "Vergaderingen", + "calls": "Gesprekken", + "tasks": "Taken" + }, + "labels": { + "Create {entityType}": "Creëer {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nl_NL/Event.json b/application/Espo/Core/Templates/i18n/nl_NL/Event.json new file mode 100644 index 0000000..4c9475d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nl_NL/Event.json @@ -0,0 +1,37 @@ +{ + "fields": { + "parent": "Behorende bij", + "dateStart": "Start datum", + "dateEnd": "Eind datum", + "duration": "Duur", + "reminders": "Herinneringen", + "dateStartDate": "Startdatum (de hele dag)", + "dateEndDate": "Datum einde (hele dag)", + "isAllDay": "Is de hele dag" + }, + "links": { + "parent": "Behorende bij" + }, + "options": { + "status": { + "Planned": "Gepland", + "Held": "Gedaan", + "Not Held": "Niet gedaan" + } + }, + "labels": { + "Create {entityType}": "Creëer {entityTypeTranslated}", + "Schedule {entityType}": "Schema {entityTypeTranslated}", + "Set Held": "Gedaan", + "Set Not Held": "Niet gedaan" + }, + "massActions": { + "setHeld": "Gedaan", + "setNotHeld": "Niet gedaan" + }, + "presetFilters": { + "planned": "Gepland", + "held": "Gedaan", + "todays": "Vandaag" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/nl_NL/Person.json b/application/Espo/Core/Templates/i18n/nl_NL/Person.json new file mode 100644 index 0000000..b300216 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/nl_NL/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adres" + }, + "links": { + "meetings": "Vergaderingen", + "calls": "Gesprekken", + "tasks": "Taken" + }, + "labels": { + "Create {entityType}": "Creëer {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pl_PL/Base.json b/application/Espo/Core/Templates/i18n/pl_PL/Base.json new file mode 100644 index 0000000..824ff2d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pl_PL/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Utwórz {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pl_PL/BasePlus.json b/application/Espo/Core/Templates/i18n/pl_PL/BasePlus.json new file mode 100644 index 0000000..4946117 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pl_PL/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Spotkania", + "calls": "Połączenia", + "tasks": "Zadania" + }, + "labels": { + "Create {entityType}": "Utwórz {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pl_PL/Company.json b/application/Espo/Core/Templates/i18n/pl_PL/Company.json new file mode 100644 index 0000000..24e5b59 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pl_PL/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Adres rozliczeniowy", + "shippingAddress": "Adres dostawy", + "website": "Strona internetowa" + }, + "links": { + "meetings": "Spotkania", + "calls": "Połączenia", + "tasks": "Zadania" + }, + "labels": { + "Create {entityType}": "Utwórz {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pl_PL/Event.json b/application/Espo/Core/Templates/i18n/pl_PL/Event.json new file mode 100644 index 0000000..8276d35 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pl_PL/Event.json @@ -0,0 +1,38 @@ +{ + "fields": { + "parent": "Rodzic", + "dateStart": "Data rozpoczęcia", + "dateEnd": "Data zakończenia", + "duration": "Czas trwania", + "reminders": "Przypomnienia", + "dateStartDate": "Data rozpoczęcia (cały dzień)", + "dateEndDate": "Data zakończenia (cały dzień)", + "isAllDay": "Cały dzień" + }, + "links": { + "parent": "Rodzic" + }, + "options": { + "status": { + "Planned": "Planowane", + "Held": "Odbyło się", + "Not Held": "Nie odbyło się" + } + }, + "labels": { + "Create {entityType}": "Utwórz {entityTypeTranslated}", + "Schedule {entityType}": "Zaplanuj {entityTypeTranslated}", + "Log {entityType}": "Dziennik {entityTypeTranslated}", + "Set Held": "Ustaw jako Odbyte", + "Set Not Held": "Ustaw jako Nieodbyte" + }, + "massActions": { + "setHeld": "Ustaw jako Odbyte", + "setNotHeld": "Ustaw jako nieodbyty" + }, + "presetFilters": { + "planned": "Planowane", + "held": "Trzymany", + "todays": "Dzisiejsze" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pl_PL/Person.json b/application/Espo/Core/Templates/i18n/pl_PL/Person.json new file mode 100644 index 0000000..328bbbb --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pl_PL/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adres" + }, + "links": { + "meetings": "Spotkania", + "calls": "Rozmowy", + "tasks": "Zadania" + }, + "labels": { + "Create {entityType}": "Utwórz {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_BR/Base.json b/application/Espo/Core/Templates/i18n/pt_BR/Base.json new file mode 100644 index 0000000..c7dcccd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_BR/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_BR/BasePlus.json b/application/Espo/Core/Templates/i18n/pt_BR/BasePlus.json new file mode 100644 index 0000000..5eddb02 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_BR/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Reuniões", + "calls": "Ligações", + "tasks": "Tarefas" + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_BR/CategoryTree.json b/application/Espo/Core/Templates/i18n/pt_BR/CategoryTree.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_BR/CategoryTree.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_BR/Company.json b/application/Espo/Core/Templates/i18n/pt_BR/Company.json new file mode 100644 index 0000000..aa57905 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_BR/Company.json @@ -0,0 +1,14 @@ +{ + "fields": { + "billingAddress": "Endereço de Cobrança", + "shippingAddress": "Endereço de Entrega" + }, + "links": { + "meetings": "Reuniões", + "calls": "Ligações", + "tasks": "Tarefas" + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_BR/Event.json b/application/Espo/Core/Templates/i18n/pt_BR/Event.json new file mode 100644 index 0000000..2a2ffcb --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_BR/Event.json @@ -0,0 +1,32 @@ +{ + "fields": { + "dateStart": "Data de Início", + "dateEnd": "Data Final", + "duration": "Duração", + "dateStartDate": "Data de Início (dia todo)", + "dateEndDate": "Data Final (dia todo)", + "isAllDay": "É o Dia Todo" + }, + "options": { + "status": { + "Planned": "Planejado", + "Held": "Executar", + "Not Held": "Não Executar" + } + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}", + "Schedule {entityType}": "Agenda {entityTypeTranslated}", + "Set Held": "Marcar Realizada", + "Set Not Held": "Marcar como Não Realizada" + }, + "massActions": { + "setHeld": "Marcar como Realizada", + "setNotHeld": "Marcar como Não Realizada" + }, + "presetFilters": { + "planned": "Planejado", + "held": "Realizada", + "todays": "De hoje" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_BR/Person.json b/application/Espo/Core/Templates/i18n/pt_BR/Person.json new file mode 100644 index 0000000..baad622 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_BR/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Endereço" + }, + "links": { + "meetings": "Reuniões", + "calls": "Ligações", + "tasks": "Tarefas" + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PR/Base.json b/application/Espo/Core/Templates/i18n/pt_PR/Base.json new file mode 100644 index 0000000..c7dcccd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PR/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PR/BasePlus.json b/application/Espo/Core/Templates/i18n/pt_PR/BasePlus.json new file mode 100644 index 0000000..9986f27 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PR/BasePlus.json @@ -0,0 +1,9 @@ +{ + "links": { + "meetings": "Reuniões", + "tasks": "Tarefas" + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PR/Company.json b/application/Espo/Core/Templates/i18n/pt_PR/Company.json new file mode 100644 index 0000000..5b9a277 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PR/Company.json @@ -0,0 +1,13 @@ +{ + "fields": { + "billingAddress": "Endereço de Cobrança", + "shippingAddress": "Endereço de Entrega" + }, + "links": { + "meetings": "Reuniões", + "tasks": "Tarefas" + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PR/Event.json b/application/Espo/Core/Templates/i18n/pt_PR/Event.json new file mode 100644 index 0000000..c7dcccd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PR/Event.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PR/Person.json b/application/Espo/Core/Templates/i18n/pt_PR/Person.json new file mode 100644 index 0000000..9787f23 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PR/Person.json @@ -0,0 +1,12 @@ +{ + "fields": { + "address": "Endereço" + }, + "links": { + "meetings": "Reuniões", + "tasks": "Tarefas" + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PT/Base.json b/application/Espo/Core/Templates/i18n/pt_PT/Base.json new file mode 100644 index 0000000..c7dcccd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PT/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PT/BasePlus.json b/application/Espo/Core/Templates/i18n/pt_PT/BasePlus.json new file mode 100644 index 0000000..e06b36d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PT/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Reuniões", + "calls": "Chamadas", + "tasks": "Tarefas" + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PT/Company.json b/application/Espo/Core/Templates/i18n/pt_PT/Company.json new file mode 100644 index 0000000..e73ace1 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PT/Company.json @@ -0,0 +1,14 @@ +{ + "fields": { + "billingAddress": "Endereço de faturação", + "shippingAddress": "Endereço de envio" + }, + "links": { + "meetings": "Reuniões", + "calls": "Chamadas", + "tasks": "Tarefas" + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PT/Event.json b/application/Espo/Core/Templates/i18n/pt_PT/Event.json new file mode 100644 index 0000000..9bf9bb2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PT/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Relativo a", + "dateStart": "Data de início", + "dateEnd": "Data de fim", + "duration": "Duração", + "status": "Estado", + "reminders": "Lembretes", + "dateStartDate": "Data de início (todo o dia)", + "dateEndDate": "Data de fim (todo o dia)", + "isAllDay": "Todo o dia" + }, + "links": { + "parent": "Relativo a" + }, + "options": { + "status": { + "Planned": "Planeado", + "Held": "Realizada", + "Not Held": "Não Realizada" + } + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}", + "Schedule {entityType}": "Planeamento {entityTypeTranslated}", + "Log {entityType}": "Registo {entityTypeTranslated}", + "Set Held": "Configurar como Realizada", + "Set Not Held": "Configurar como Não Realizada" + }, + "massActions": { + "setHeld": "Configurar como Realizada", + "setNotHeld": "Configurar como Não Realizado" + }, + "presetFilters": { + "planned": "Planeado", + "held": "Realizado", + "todays": "Hoje" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/pt_PT/Person.json b/application/Espo/Core/Templates/i18n/pt_PT/Person.json new file mode 100644 index 0000000..afba4ed --- /dev/null +++ b/application/Espo/Core/Templates/i18n/pt_PT/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Endereço" + }, + "links": { + "meetings": "Reuniões", + "calls": "Chamadas", + "tasks": "Tarefas" + }, + "labels": { + "Create {entityType}": "Criar {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ro_RO/Base.json b/application/Espo/Core/Templates/i18n/ro_RO/Base.json new file mode 100644 index 0000000..a6099b2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ro_RO/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Creare {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ro_RO/BasePlus.json b/application/Espo/Core/Templates/i18n/ro_RO/BasePlus.json new file mode 100644 index 0000000..a6099b2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ro_RO/BasePlus.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Creare {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ro_RO/Company.json b/application/Espo/Core/Templates/i18n/ro_RO/Company.json new file mode 100644 index 0000000..a6099b2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ro_RO/Company.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Creare {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ro_RO/Event.json b/application/Espo/Core/Templates/i18n/ro_RO/Event.json new file mode 100644 index 0000000..a6099b2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ro_RO/Event.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Creare {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ro_RO/Person.json b/application/Espo/Core/Templates/i18n/ro_RO/Person.json new file mode 100644 index 0000000..a6099b2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ro_RO/Person.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Creare {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ru_RU/Base.json b/application/Espo/Core/Templates/i18n/ru_RU/Base.json new file mode 100644 index 0000000..c7046e9 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ru_RU/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Создать {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ru_RU/BasePlus.json b/application/Espo/Core/Templates/i18n/ru_RU/BasePlus.json new file mode 100644 index 0000000..54e920d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ru_RU/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Встречи", + "calls": "Звонки", + "tasks": "Задачи" + }, + "labels": { + "Create {entityType}": "Создать {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ru_RU/CategoryTree.json b/application/Espo/Core/Templates/i18n/ru_RU/CategoryTree.json new file mode 100644 index 0000000..c7046e9 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ru_RU/CategoryTree.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Создать {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ru_RU/Company.json b/application/Espo/Core/Templates/i18n/ru_RU/Company.json new file mode 100644 index 0000000..d390698 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ru_RU/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Платежный адрес", + "shippingAddress": "Адрес доставки", + "website": "Сайт" + }, + "links": { + "meetings": "Встречи", + "calls": "Звонки", + "tasks": "Задачи" + }, + "labels": { + "Create {entityType}": "Создать {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ru_RU/Event.json b/application/Espo/Core/Templates/i18n/ru_RU/Event.json new file mode 100644 index 0000000..39adb4a --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ru_RU/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Источник", + "dateStart": "Дата начала", + "dateEnd": "Дата окончания", + "duration": "Длительность", + "status": "Статус", + "reminders": "Напоминания", + "dateStartDate": "Дата начала (весь день)", + "dateEndDate": "Дата окончания (весь день)", + "isAllDay": "Весь день" + }, + "links": { + "parent": "Источник" + }, + "options": { + "status": { + "Planned": "Запланировано", + "Held": "Состоялось", + "Not Held": "Не состоялся" + } + }, + "labels": { + "Create {entityType}": "Создать {entityTypeTranslated}", + "Schedule {entityType}": "Запланировать {entityTypeTranslated}", + "Log {entityType}": "Журнал {entityTypeTranslated}", + "Set Held": "Состоялось", + "Set Not Held": "Не состоялось" + }, + "massActions": { + "setHeld": "Состоялось", + "setNotHeld": "Не состоялось" + }, + "presetFilters": { + "planned": "Запланировано", + "held": "Состоялся", + "todays": "Сегодняшние" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ru_RU/Person.json b/application/Espo/Core/Templates/i18n/ru_RU/Person.json new file mode 100644 index 0000000..8c6e470 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ru_RU/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Адрес" + }, + "links": { + "meetings": "Встречи", + "calls": "Звонки", + "tasks": "Задачи" + }, + "labels": { + "Create {entityType}": "Создать {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sk_SK/Base.json b/application/Espo/Core/Templates/i18n/sk_SK/Base.json new file mode 100644 index 0000000..b165ee0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sk_SK/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Vytvoriť {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sk_SK/BasePlus.json b/application/Espo/Core/Templates/i18n/sk_SK/BasePlus.json new file mode 100644 index 0000000..b93e467 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sk_SK/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Stretnutia", + "calls": "Hovory", + "tasks": "Úlohy" + }, + "labels": { + "Create {entityType}": "Vytvoriť {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sk_SK/Company.json b/application/Espo/Core/Templates/i18n/sk_SK/Company.json new file mode 100644 index 0000000..f76b5fe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sk_SK/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Fakturačná adresa", + "shippingAddress": "Dodacia adresa", + "website": "Webová stránka" + }, + "links": { + "meetings": "Stretnutia", + "calls": "Hovory", + "tasks": "Úlohy" + }, + "labels": { + "Create {entityType}": "Vytvoriť {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sk_SK/Event.json b/application/Espo/Core/Templates/i18n/sk_SK/Event.json new file mode 100644 index 0000000..1b4b72d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sk_SK/Event.json @@ -0,0 +1,36 @@ +{ + "fields": { + "parent": "Rodič", + "dateStart": "Dátum začiatku", + "dateEnd": "Dátum konca", + "duration": "Trvanie", + "status": "Stav", + "reminders": "Pripomienky" + }, + "links": { + "parent": "Rodič" + }, + "options": { + "status": { + "Planned": "Plánovaný", + "Held": "Pozdržaný", + "Not Held": "Nepozdržaný" + } + }, + "labels": { + "Create {entityType}": "Vytvoriť {entityTypeTranslated}", + "Schedule {entityType}": "Naplánovať {entityTypeTranslated}", + "Log {entityType}": "Protokol {entityTypeTranslated}", + "Set Held": "Nastav pozdržaný", + "Set Not Held": "Nastav nepozdržaný" + }, + "massActions": { + "setHeld": "Nastav pozdržaný", + "setNotHeld": "Nastav nepozdržaný" + }, + "presetFilters": { + "planned": "Plánovaný", + "held": "Pozdržaný", + "todays": "Dnešné" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sk_SK/Person.json b/application/Espo/Core/Templates/i18n/sk_SK/Person.json new file mode 100644 index 0000000..dc029ce --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sk_SK/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adresa" + }, + "links": { + "meetings": "Stretnutia", + "calls": "Hovory", + "tasks": "Úlohy" + }, + "labels": { + "Create {entityType}": "Vytvoriť {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sl_SI/Base.json b/application/Espo/Core/Templates/i18n/sl_SI/Base.json new file mode 100644 index 0000000..b5e5bfb --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sl_SI/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Ustvari {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sl_SI/BasePlus.json b/application/Espo/Core/Templates/i18n/sl_SI/BasePlus.json new file mode 100644 index 0000000..9869130 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sl_SI/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Srečanja", + "calls": "Klici", + "tasks": "Naloge" + }, + "labels": { + "Create {entityType}": "Ustvari {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sl_SI/Company.json b/application/Espo/Core/Templates/i18n/sl_SI/Company.json new file mode 100644 index 0000000..28d36ac --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sl_SI/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Naslov za pošiljanje računa", + "shippingAddress": "naslov za pošiljanje", + "website": "Spletna stran" + }, + "links": { + "meetings": "Srečanja", + "calls": "Klici", + "tasks": "Naloge" + }, + "labels": { + "Create {entityType}": "Ustvari {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sl_SI/Event.json b/application/Espo/Core/Templates/i18n/sl_SI/Event.json new file mode 100644 index 0000000..5abb2b3 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sl_SI/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "starš", + "dateStart": "Začetek datuma", + "dateEnd": "Konec datuma", + "duration": "Trajanje", + "status": "Stanje", + "reminders": "Opomniki", + "dateStartDate": "Datum začetka (ves dan)", + "dateEndDate": "Konec datuma (ves dan)", + "isAllDay": "Je celodnevna" + }, + "links": { + "parent": "starš" + }, + "options": { + "status": { + "Planned": "Načrtovano", + "Held": "Zadržano", + "Not Held": "Ni zadržano" + } + }, + "labels": { + "Create {entityType}": "Ustvari {entityTypeTranslated}", + "Schedule {entityType}": "Razpored {entityTypeTranslated}", + "Log {entityType}": "Dnevnik {entityTypeTranslated}", + "Set Held": "Nastavitev Zadržano", + "Set Not Held": "Set ni zadržan" + }, + "massActions": { + "setHeld": "Nastavitev Zadržano", + "setNotHeld": "Set ni zadržan" + }, + "presetFilters": { + "planned": "Načrtovano", + "held": "Zadržano", + "todays": "Današnji" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sl_SI/Person.json b/application/Espo/Core/Templates/i18n/sl_SI/Person.json new file mode 100644 index 0000000..b3e47e2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sl_SI/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Naslov" + }, + "links": { + "meetings": "Srečanja", + "calls": "Klici", + "tasks": "Naloge" + }, + "labels": { + "Create {entityType}": "Ustvari {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sr_RS/Base.json b/application/Espo/Core/Templates/i18n/sr_RS/Base.json new file mode 100644 index 0000000..c658a6e --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sr_RS/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Napravi {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sr_RS/BasePlus.json b/application/Espo/Core/Templates/i18n/sr_RS/BasePlus.json new file mode 100644 index 0000000..14254d5 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sr_RS/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Ročišta", + "calls": "Pozivi", + "tasks": "Zadaci" + }, + "labels": { + "Create {entityType}": "Napravi {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sr_RS/Company.json b/application/Espo/Core/Templates/i18n/sr_RS/Company.json new file mode 100644 index 0000000..f2cce7d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sr_RS/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Adresa za naplatu", + "shippingAddress": "Adresa za dostavu", + "website": "Sajt" + }, + "links": { + "meetings": "Ročišta", + "calls": "Pozivi", + "tasks": "Zadaci" + }, + "labels": { + "Create {entityType}": "Napravi {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sr_RS/Event.json b/application/Espo/Core/Templates/i18n/sr_RS/Event.json new file mode 100644 index 0000000..500a1d8 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sr_RS/Event.json @@ -0,0 +1,35 @@ +{ + "fields": { + "parent": "Roditelj", + "dateStart": "Početni datum", + "dateEnd": "Krajnji datum", + "duration": "Trajanje", + "reminders": "Podsetnici" + }, + "links": { + "parent": "Roditelj" + }, + "options": { + "status": { + "Planned": "Planiran", + "Held": "Održan", + "Not Held": "Nije održan" + } + }, + "labels": { + "Create {entityType}": "Napravi {entityTypeTranslated}", + "Schedule {entityType}": "Zakaži {entityTypeTranslated}", + "Log {entityType}": "Zabeleži {entitiTipeTranslated}", + "Set Held": "Postavi kao održano", + "Set Not Held": "Postavi kao nije održano" + }, + "massActions": { + "setHeld": "Postavi kao održano", + "setNotHeld": "Postavi kao nije održano" + }, + "presetFilters": { + "planned": "Planiran", + "held": "Održan", + "todays": "Današnji" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sr_RS/Person.json b/application/Espo/Core/Templates/i18n/sr_RS/Person.json new file mode 100644 index 0000000..d18da3b --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sr_RS/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adresa" + }, + "links": { + "meetings": "Ročišta", + "calls": "Pozivi", + "tasks": "Zadaci" + }, + "labels": { + "Create {entityType}": "Napravi {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sv_SE/Base.json b/application/Espo/Core/Templates/i18n/sv_SE/Base.json new file mode 100644 index 0000000..deb69eb --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sv_SE/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Skapa {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sv_SE/BasePlus.json b/application/Espo/Core/Templates/i18n/sv_SE/BasePlus.json new file mode 100644 index 0000000..048018c --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sv_SE/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Möten", + "calls": "Samtal", + "tasks": "Uppgifter" + }, + "labels": { + "Create {entityType}": "Skapa {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sv_SE/Company.json b/application/Espo/Core/Templates/i18n/sv_SE/Company.json new file mode 100644 index 0000000..239f6d4 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sv_SE/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Faktureringsadress", + "shippingAddress": "Leveransadress", + "website": "Hemsida" + }, + "links": { + "meetings": "Möten", + "calls": "Samtal", + "tasks": "Uppgifter" + }, + "labels": { + "Create {entityType}": "Skapa {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sv_SE/Event.json b/application/Espo/Core/Templates/i18n/sv_SE/Event.json new file mode 100644 index 0000000..b36d3da --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sv_SE/Event.json @@ -0,0 +1,38 @@ +{ + "fields": { + "parent": "Förälder", + "dateStart": "Startdatum", + "dateEnd": "Slutdatum", + "duration": "Varaktighet", + "reminders": "Påminnelser", + "dateStartDate": "Startdatum", + "dateEndDate": "Slutdatum", + "isAllDay": "Är heldag" + }, + "links": { + "parent": "Förälder" + }, + "options": { + "status": { + "Planned": "Planerad", + "Held": "Stoppad", + "Not Held": "Inte stoppad" + } + }, + "labels": { + "Create {entityType}": "Skapa {entityTypeTranslated}", + "Schedule {entityType}": "Schemalägg {entityTypeTranslated}", + "Log {entityType}": "Logga {entityTypeTranslated}", + "Set Held": "Stoppa", + "Set Not Held": "Stoppa inte" + }, + "massActions": { + "setHeld": "Stoppa", + "setNotHeld": "Stoppa inte" + }, + "presetFilters": { + "planned": "Planerad", + "held": "Stoppad", + "todays": "Idag" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/sv_SE/Person.json b/application/Espo/Core/Templates/i18n/sv_SE/Person.json new file mode 100644 index 0000000..b35ae85 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/sv_SE/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adress" + }, + "links": { + "meetings": "Möten", + "calls": "Samtal", + "tasks": "Uppgifter" + }, + "labels": { + "Create {entityType}": "Skapa {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/th_TH/Base.json b/application/Espo/Core/Templates/i18n/th_TH/Base.json new file mode 100644 index 0000000..ef5d747 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/th_TH/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "สร้าง {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/th_TH/BasePlus.json b/application/Espo/Core/Templates/i18n/th_TH/BasePlus.json new file mode 100644 index 0000000..28ac38d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/th_TH/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "การประชุม", + "calls": "โทร", + "tasks": "งาน" + }, + "labels": { + "Create {entityType}": "สร้าง {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/th_TH/Company.json b/application/Espo/Core/Templates/i18n/th_TH/Company.json new file mode 100644 index 0000000..c5565a0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/th_TH/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "ที่อยู่เรียกเก็บเงิน", + "shippingAddress": "ที่อยู่จัดส่ง", + "website": "เว็บไซต์" + }, + "links": { + "meetings": "การประชุม", + "calls": "โทร", + "tasks": "งาน" + }, + "labels": { + "Create {entityType}": "สร้าง {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/th_TH/Event.json b/application/Espo/Core/Templates/i18n/th_TH/Event.json new file mode 100644 index 0000000..6c6f334 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/th_TH/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "ผู้ปกครอง", + "dateStart": "วันที่เริ่มต้น", + "dateEnd": "วันที่สิ้นสุด", + "duration": "ระยะเวลา", + "status": "สถานะ", + "reminders": "การแจ้งเตือน", + "dateStartDate": "วันที่เริ่มต้น (ทั้งวัน)", + "dateEndDate": "วันที่สิ้นสุด (ทั้งวัน)", + "isAllDay": "เป็นตลอดทั้งวัน" + }, + "links": { + "parent": "ผู้ปกครอง" + }, + "options": { + "status": { + "Planned": "วางแผน", + "Held": "จัดขึ้น", + "Not Held": "ไม่ได้จัดขึ้น" + } + }, + "labels": { + "Create {entityType}": "สร้าง {entityTypeTranslated}", + "Schedule {entityType}": "กำหนดเวลา {entityTypeTranslated}", + "Log {entityType}": "บันทึก {entityTypeTranslated}", + "Set Held": "ตั้งค่าไว้", + "Set Not Held": "ตั้งค่าไม่ถูกระงับ" + }, + "massActions": { + "setHeld": "ตั้งไว้", + "setNotHeld": "ตั้งค่าไม่ถูกระงับ" + }, + "presetFilters": { + "planned": "วางแผน", + "held": "จัดขึ้น", + "todays": "วันนี้" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/th_TH/Person.json b/application/Espo/Core/Templates/i18n/th_TH/Person.json new file mode 100644 index 0000000..43ce359 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/th_TH/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "ที่อยู่" + }, + "links": { + "meetings": "การประชุม", + "calls": "โทร", + "tasks": "งาน" + }, + "labels": { + "Create {entityType}": "สร้าง {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/tr_TR/Base.json b/application/Espo/Core/Templates/i18n/tr_TR/Base.json new file mode 100644 index 0000000..91827ea --- /dev/null +++ b/application/Espo/Core/Templates/i18n/tr_TR/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "{entityTypeTranslated} oluştur" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/tr_TR/BasePlus.json b/application/Espo/Core/Templates/i18n/tr_TR/BasePlus.json new file mode 100644 index 0000000..518fdb0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/tr_TR/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Toplantılar", + "calls": "(Seslenme)", + "tasks": "Görevler" + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} oluştur" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/tr_TR/Company.json b/application/Espo/Core/Templates/i18n/tr_TR/Company.json new file mode 100644 index 0000000..3b19ef0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/tr_TR/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Fatura Adresi", + "shippingAddress": "Teslimat Adresi", + "website": "Web sitesi" + }, + "links": { + "meetings": "Toplantılar", + "calls": "(Seslenme)", + "tasks": "Görevler" + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} oluştur" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/tr_TR/Event.json b/application/Espo/Core/Templates/i18n/tr_TR/Event.json new file mode 100644 index 0000000..d6c6288 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/tr_TR/Event.json @@ -0,0 +1,36 @@ +{ + "fields": { + "parent": "Üst", + "dateStart": "Başlangıç ​​Tarihi", + "dateEnd": "Son Tarih", + "duration": "Süre ", + "status": "Durum ", + "reminders": "Hatırlatıcılar" + }, + "links": { + "parent": "Üst" + }, + "options": { + "status": { + "Planned": "Planlandı", + "Held": "Düzenlenen", + "Not Held": "Düzenlenmedi" + } + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} oluştur", + "Schedule {entityType}": "Zamanlama {entityTypeTranslated}", + "Log {entityType}": "{entityTypeTranslated} günlüğünü kaydet", + "Set Held": "Düzenlendi", + "Set Not Held": "Düzenlenmedi olarak ayarla" + }, + "massActions": { + "setHeld": "Düzenlendi", + "setNotHeld": "Düzenlenmedi olarak ayarla" + }, + "presetFilters": { + "planned": "Planlandı", + "held": "Düzenlenen", + "todays": "Bugünün" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/tr_TR/Person.json b/application/Espo/Core/Templates/i18n/tr_TR/Person.json new file mode 100644 index 0000000..59bacd3 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/tr_TR/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Adres " + }, + "links": { + "meetings": "Toplantılar", + "calls": "(Seslenme)", + "tasks": "Görevler" + }, + "labels": { + "Create {entityType}": "{entityTypeTranslated} oluştur" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/uk_UA/Base.json b/application/Espo/Core/Templates/i18n/uk_UA/Base.json new file mode 100644 index 0000000..7f064d0 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/uk_UA/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Створити {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/uk_UA/BasePlus.json b/application/Espo/Core/Templates/i18n/uk_UA/BasePlus.json new file mode 100644 index 0000000..0f5c1d8 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/uk_UA/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Зустрічі", + "calls": "Дзвінки", + "tasks": "Завдання" + }, + "labels": { + "Create {entityType}": "Створити {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/uk_UA/CategoryTree.json b/application/Espo/Core/Templates/i18n/uk_UA/CategoryTree.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/uk_UA/CategoryTree.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/uk_UA/Company.json b/application/Espo/Core/Templates/i18n/uk_UA/Company.json new file mode 100644 index 0000000..e190c93 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/uk_UA/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Платіжна адреса", + "shippingAddress": "Адреса доставки", + "website": "Вебсайт" + }, + "links": { + "meetings": "Зустрічі", + "calls": "Дзвінки", + "tasks": "Завдання" + }, + "labels": { + "Create {entityType}": "Створити {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/uk_UA/Event.json b/application/Espo/Core/Templates/i18n/uk_UA/Event.json new file mode 100644 index 0000000..523b58d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/uk_UA/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "Батько", + "dateStart": "Дата початку", + "dateEnd": "Дата завершення", + "duration": "Тривалість", + "status": "Статус", + "reminders": "Нагадування", + "dateStartDate": "Дата початку (цілий день)", + "dateEndDate": "Дата завершення (цілий день)", + "isAllDay": "Є цілий день" + }, + "links": { + "parent": "Батько" + }, + "options": { + "status": { + "Planned": "Заплановано", + "Held": "Проведено", + "Not Held": "Не проведено" + } + }, + "labels": { + "Create {entityType}": "Створити {entityTypeTranslated}", + "Schedule {entityType}": "Розклад {entityTypeTranslated}", + "Log {entityType}": "Журнал {entityTypeTranslated}", + "Set Held": "Позначити проведеним", + "Set Not Held": "Позначити непроведеним" + }, + "massActions": { + "setHeld": "Позначити проведеним", + "setNotHeld": "Позначити непроведеним" + }, + "presetFilters": { + "planned": "Заплановано", + "held": "Проведені", + "todays": "Сьогоднішні" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/uk_UA/Person.json b/application/Espo/Core/Templates/i18n/uk_UA/Person.json new file mode 100644 index 0000000..b7b448c --- /dev/null +++ b/application/Espo/Core/Templates/i18n/uk_UA/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "Адреса" + }, + "links": { + "meetings": "Зустрічі", + "calls": "Дзвінки", + "tasks": "Завдання" + }, + "labels": { + "Create {entityType}": "Створити {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ur_IN/Base.json b/application/Espo/Core/Templates/i18n/ur_IN/Base.json new file mode 100644 index 0000000..bcff0bd --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ur_IN/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "{entityTypetranslated} بنائیں" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ur_IN/BasePlus.json b/application/Espo/Core/Templates/i18n/ur_IN/BasePlus.json new file mode 100644 index 0000000..f360592 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ur_IN/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "ملاقاتیں", + "calls": "کالز", + "tasks": "کام" + }, + "labels": { + "Create {entityType}": "{entityTypetranslated} بنائیں" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ur_IN/CategoryTree.json b/application/Espo/Core/Templates/i18n/ur_IN/CategoryTree.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ur_IN/CategoryTree.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ur_IN/Company.json b/application/Espo/Core/Templates/i18n/ur_IN/Company.json new file mode 100644 index 0000000..00a0486 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ur_IN/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "بلنگ ایڈریس", + "shippingAddress": "شپنگ ایڈریس", + "website": "ویب سائٹ" + }, + "links": { + "meetings": "ملاقاتیں", + "calls": "کالز", + "tasks": "کام" + }, + "labels": { + "Create {entityType}": "{entityTypetranslated} بنائیں" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ur_IN/Event.json b/application/Espo/Core/Templates/i18n/ur_IN/Event.json new file mode 100644 index 0000000..7c2ab24 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ur_IN/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "والدین", + "dateStart": "تاریخ شروع", + "dateEnd": "تاریخ اختتام", + "duration": "دورانیہ", + "status": "حیثیت", + "reminders": "یاد دہانی", + "dateStartDate": "تاریخ شروع (سارا دن)", + "dateEndDate": "تاریخ اختتام (سارا دن)", + "isAllDay": "سارا دن ہے" + }, + "links": { + "parent": "والدین" + }, + "options": { + "status": { + "Planned": "منصوبہ بند", + "Held": "منعقد", + "Not Held": "نہیں رکھا گیا" + } + }, + "labels": { + "Create {entityType}": "{entityTypetranslated} بنائیں", + "Schedule {entityType}": "شیڈول {entityTepetranslated}", + "Log {entityType}": "لاگ {entityTyPetranslated}", + "Set Held": "سیٹ کیا گیا", + "Set Not Held": "سیٹ نہیں ہوا" + }, + "massActions": { + "setHeld": "سیٹ کیا گیا", + "setNotHeld": "سیٹ نہیں ہوا" + }, + "presetFilters": { + "planned": "منصوبہ بند", + "held": "منعقد", + "todays": "آج کی بات ہے" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/ur_IN/Person.json b/application/Espo/Core/Templates/i18n/ur_IN/Person.json new file mode 100644 index 0000000..956ebc2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/ur_IN/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "پتہ" + }, + "links": { + "meetings": "ملاقاتیں", + "calls": "کالز", + "tasks": "کام" + }, + "labels": { + "Create {entityType}": "{entityTypetranslated} بنائیں" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/vi_VN/Base.json b/application/Espo/Core/Templates/i18n/vi_VN/Base.json new file mode 100644 index 0000000..c596f2c --- /dev/null +++ b/application/Espo/Core/Templates/i18n/vi_VN/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "Tạo {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/vi_VN/BasePlus.json b/application/Espo/Core/Templates/i18n/vi_VN/BasePlus.json new file mode 100644 index 0000000..6227e8c --- /dev/null +++ b/application/Espo/Core/Templates/i18n/vi_VN/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "Cuộc hẹn", + "calls": "Cuộc gọi", + "tasks": "Nhiệm vụ" + }, + "labels": { + "Create {entityType}": "Tạo {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/vi_VN/Company.json b/application/Espo/Core/Templates/i18n/vi_VN/Company.json new file mode 100644 index 0000000..219e61d --- /dev/null +++ b/application/Espo/Core/Templates/i18n/vi_VN/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "Địa chỉ thanh toán", + "shippingAddress": "Địa chỉ giao hàng", + "website": "Trang web" + }, + "links": { + "meetings": "Cuộc hẹn", + "calls": "Cuộc gọi", + "tasks": "Nhiệm vụ" + }, + "labels": { + "Create {entityType}": "Tạo {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/vi_VN/Event.json b/application/Espo/Core/Templates/i18n/vi_VN/Event.json new file mode 100644 index 0000000..7313aa3 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/vi_VN/Event.json @@ -0,0 +1,12 @@ +{ + "fields": { + "dateStart": "Ngày bắt đầu", + "dateEnd": "Ngày kết thúc", + "status": "Tình trạng", + "reminders": "Nhắc nhở" + }, + "presetFilters": { + "planned": "Đã lên kế hoạch", + "todays": "Hôm nay" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/vi_VN/Person.json b/application/Espo/Core/Templates/i18n/vi_VN/Person.json new file mode 100644 index 0000000..60c856b --- /dev/null +++ b/application/Espo/Core/Templates/i18n/vi_VN/Person.json @@ -0,0 +1,10 @@ +{ + "fields": { + "address": "Địa chỉ" + }, + "links": { + "meetings": "Cuộc hẹn", + "calls": "Cuộc gọi", + "tasks": "Nhiệm vụ" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_CN/Base.json b/application/Espo/Core/Templates/i18n/zh_CN/Base.json new file mode 100644 index 0000000..52d924f --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_CN/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "创建 {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_CN/BasePlus.json b/application/Espo/Core/Templates/i18n/zh_CN/BasePlus.json new file mode 100644 index 0000000..bbd35ab --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_CN/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "会议", + "calls": "通话", + "tasks": "任务" + }, + "labels": { + "Create {entityType}": "创建 {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_CN/Company.json b/application/Espo/Core/Templates/i18n/zh_CN/Company.json new file mode 100644 index 0000000..651d522 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_CN/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "账单地址", + "shippingAddress": "邮寄地址", + "website": "网站" + }, + "links": { + "meetings": "会议", + "calls": "通话", + "tasks": "任务" + }, + "labels": { + "Create {entityType}": "创建 {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_CN/Event.json b/application/Espo/Core/Templates/i18n/zh_CN/Event.json new file mode 100644 index 0000000..a423807 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_CN/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "关联", + "dateStart": "开始日期", + "dateEnd": "结束日期", + "duration": "持续时间", + "status": "状态", + "reminders": "提醒", + "dateStartDate": "开始日期 (全天)", + "dateEndDate": "结束日期 (全天)", + "isAllDay": "全天" + }, + "links": { + "parent": "关联" + }, + "options": { + "status": { + "Planned": "已计划", + "Held": "已举行", + "Not Held": "未举行" + } + }, + "labels": { + "Create {entityType}": "创建 {entityTypeTranslated}", + "Schedule {entityType}": "计划 {entityTypeTranslated}", + "Log {entityType}": "记录 {entityTypeTranslated}", + "Set Held": "设为已举行", + "Set Not Held": "设为未举行" + }, + "massActions": { + "setHeld": "设为已举行", + "setNotHeld": "设为未举行" + }, + "presetFilters": { + "planned": "已计划", + "held": "已举行", + "todays": "今天的" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_CN/Person.json b/application/Espo/Core/Templates/i18n/zh_CN/Person.json new file mode 100644 index 0000000..3fef537 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_CN/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "地址" + }, + "links": { + "meetings": "会议", + "calls": "通话", + "tasks": "任务" + }, + "labels": { + "Create {entityType}": "创建 {entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_TW/Base.json b/application/Espo/Core/Templates/i18n/zh_TW/Base.json new file mode 100644 index 0000000..f8eec90 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_TW/Base.json @@ -0,0 +1,5 @@ +{ + "labels": { + "Create {entityType}": "建立{entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_TW/BasePlus.json b/application/Espo/Core/Templates/i18n/zh_TW/BasePlus.json new file mode 100644 index 0000000..4ba7559 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_TW/BasePlus.json @@ -0,0 +1,10 @@ +{ + "links": { + "meetings": "會議", + "calls": "通話", + "tasks": "任務" + }, + "labels": { + "Create {entityType}": "建立{entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_TW/Company.json b/application/Espo/Core/Templates/i18n/zh_TW/Company.json new file mode 100644 index 0000000..a1cb9f2 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_TW/Company.json @@ -0,0 +1,15 @@ +{ + "fields": { + "billingAddress": "帳單地址", + "shippingAddress": "送貨地址", + "website": "網站" + }, + "links": { + "meetings": "會議", + "calls": "通話", + "tasks": "任務" + }, + "labels": { + "Create {entityType}": "建立{entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_TW/Event.json b/application/Espo/Core/Templates/i18n/zh_TW/Event.json new file mode 100644 index 0000000..1e55843 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_TW/Event.json @@ -0,0 +1,39 @@ +{ + "fields": { + "parent": "上層", + "dateStart": "開始日期", + "dateEnd": "結束日期", + "duration": "持續時間", + "status": "狀態", + "reminders": "提醒", + "dateStartDate": "結束日期 (全天)", + "dateEndDate": "是否全天", + "isAllDay": "預算貨幣" + }, + "links": { + "parent": "上層" + }, + "options": { + "status": { + "Planned": "己計劃", + "Held": "己保留", + "Not Held": "未保留" + } + }, + "labels": { + "Create {entityType}": "建立{entityTypeTranslated}", + "Schedule {entityType}": "時間表{entityTypeTranslated}", + "Log {entityType}": "日誌{entityTypeTranslated}", + "Set Held": "設定保留", + "Set Not Held": "設定未保留" + }, + "massActions": { + "setHeld": "設定保留", + "setNotHeld": "設定未保留" + }, + "presetFilters": { + "planned": "計劃", + "held": "保留", + "todays": "今天的" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Templates/i18n/zh_TW/Person.json b/application/Espo/Core/Templates/i18n/zh_TW/Person.json new file mode 100644 index 0000000..ec0fc37 --- /dev/null +++ b/application/Espo/Core/Templates/i18n/zh_TW/Person.json @@ -0,0 +1,13 @@ +{ + "fields": { + "address": "地址" + }, + "links": { + "meetings": "會議", + "calls": "通話", + "tasks": "任務" + }, + "labels": { + "Create {entityType}": "建立{entityTypeTranslated}" + } +} \ No newline at end of file diff --git a/application/Espo/Core/Upgrades/ActionManager.php b/application/Espo/Core/Upgrades/ActionManager.php new file mode 100644 index 0000000..7ca1d25 --- /dev/null +++ b/application/Espo/Core/Upgrades/ActionManager.php @@ -0,0 +1,141 @@ +. + * + * 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; + +use Espo\Core\Container; +use Espo\Core\Exceptions\Error; +use Espo\Core\Upgrades\Actions\Base as ActionBase; + +class ActionManager +{ + private string $managerName; + private Container $container; + /** @var array> */ + private $objects; + protected ?string $currentAction; + /** @var array */ + protected array $params; + + /** + * @param array $params + */ + public function __construct(string $managerName, Container $container, array $params) + { + $this->managerName = $managerName; + $this->container = $container; + + $params['name'] = $managerName; + $this->params = $params; + } + + protected function getManagerName(): string + { + return $this->managerName; + } + + protected function getContainer(): Container + { + return $this->container; + } + + public function setAction(string $action): void + { + $this->currentAction = $action; + } + + public function getAction(): string + { + assert($this->currentAction !== null); + + return $this->currentAction; + } + + /** + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + /** + * @throws Error + */ + public function run(mixed $data): mixed + { + $object = $this->getObject(); + + return $object->run($data); + } + + /** + * @throws Error + */ + public function getActionClass(string $actionName): ActionBase + { + return $this->getObject($actionName); + } + + /** + * @return array + * @throws Error + */ + public function getManifest(): array + { + return $this->getObject()->getManifest(); + } + + /** + * @param ?string $actionName + * @throws Error + */ + protected function getObject(?string $actionName = null): ActionBase + { + $managerName = $this->getManagerName(); + + if (!$actionName) { + $actionName = $this->getAction(); + } + + if (!isset($this->objects[$managerName][$actionName])) { + $class = "Espo\\Core\\Upgrades\\Actions\\" . ucfirst($managerName) . '\\' . ucfirst($actionName); + + if (!class_exists($class)) { + throw new Error('Could not find an action ['.ucfirst($actionName).'], class ['.$class.'].'); + } + + /** @var class-string $class */ + + $this->objects[$managerName][$actionName] = new $class($this->container, $this); + } + + return $this->objects[$managerName][$actionName]; + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Base.php b/application/Espo/Core/Upgrades/Actions/Base.php new file mode 100644 index 0000000..75caef3 --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Base.php @@ -0,0 +1,1108 @@ +. + * + * 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\Actions; + +use Espo\Core\DataManager; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\Utils\Id\RecordIdGenerator; +use Espo\Core\Utils\Util; +use Espo\Core\Utils\System; +use Espo\Core\Utils\Json; +use Espo\Core\Exceptions\Error; +use Espo\Core\Container; +use Espo\Core\InjectableFactory; +use Espo\Core\Upgrades\ActionManager; +use Espo\Core\Utils\Config\ConfigWriter; +use Espo\Core\Utils\Database\Helper as DatabaseHelper; +use Espo\Core\Utils\File\ZipArchive; +use Espo\Core\Utils\Log; + +use Composer\Semver\Semver; + +use Espo\ORM\EntityManager; +use Throwable; + +abstract class Base +{ + /** Directory name of files in a package. */ + protected const FILES = 'files'; + /** Directory name of scripts in a package. */ + protected const SCRIPTS = 'scripts'; + + private string $defaultPackageType = 'extension'; + private string $vendorDirName = 'vendor'; + protected string $manifestName = 'manifest.json'; + private string $packagePostfix = 'z'; + + /** @var array */ + protected mixed $data = []; + /** @var array */ + private array $params; + protected ?string $processId = null; + protected ?string $parentProcessId = null; + /** @var array */ + protected array $scriptParams = []; + + /** @var array */ + private array $packageTypes = [ + 'upgrade' => 'upgrade', + 'extension' => 'extension', + ]; + + private ZipArchive $zipUtil; + private ?DatabaseHelper $databaseHelper = null; + private ?Helper $helper = null; + + public function __construct( + private Container $container, + private ActionManager $actionManager, + ) { + $this->params = $actionManager->getParams(); + + $fileManager = $container->getByClass(FileManager::class); + $this->zipUtil = new ZipArchive($fileManager); + } + + private function getContainer(): Container + { + return $this->container; + } + + private function getParam(string $name): mixed + { + if (isset($this->params[$name])) { + return $this->params[$name]; + } + + return null; + } + + private function getZipUtil(): ZipArchive + { + return $this->zipUtil; + } + + private function getDatabaseHelper(): DatabaseHelper + { + if (!isset($this->databaseHelper)) { + /** @var InjectableFactory $injectableFactory */ + $injectableFactory = $this->getContainer()->get('injectableFactory'); + + $this->databaseHelper = $injectableFactory->create(DatabaseHelper::class); + } + + return $this->databaseHelper; + } + + protected function getLog(): Log + { + return $this->getContainer()->getByClass(Log::class); + } + + protected function getFileManager(): FileManager + { + return $this->getContainer()->getByClass(FileManager::class); + } + + protected function getConfig(): Config + { + return $this->getContainer()->getByClass(Config::class); + } + + protected function getSystemConfig(): Config\SystemConfig + { + return $this->getContainer()->getByClass(Config\SystemConfig::class); + } + + protected function getEntityManager(): EntityManager + { + return $this->getContainer()->getByClass(EntityManager::class); + } + + protected function getInjectableFactory(): InjectableFactory + { + return $this->getContainer()->getByClass(InjectableFactory::class); + } + + protected function createConfigWriter(): ConfigWriter + { + $injectableFactory = $this->getContainer()->getByClass(InjectableFactory::class); + + return $injectableFactory->create(ConfigWriter::class); + } + + /** + * @throws Error + */ + public function throwErrorAndRemovePackage( + string $errorMessage = '', + bool $deletePackage = true, + bool $systemRebuild = true, + ?Throwable $exception = null + ): void { + + if ($deletePackage) { + $this->deletePackageFiles(); + $this->deletePackageArchive(); + } + + $this->disableMaintenanceMode(true); + + if ($systemRebuild) { + $this->systemRebuild(); + } + + if ($exception && !$errorMessage) { + $errorMessage = $exception->getMessage(); + } + + throw new Error($errorMessage, 0, $exception); + } + + abstract public function run(mixed $data): mixed; + + /** + * @throws Error + */ + protected function createProcessId(): string + { + if (isset($this->processId)) { + throw new Error('Another installation process is currently running.'); + } + + $recordIdGenerator = $this->getInjectableFactory()->createResolved(RecordIdGenerator::class); + + $this->processId = $recordIdGenerator->generate(); + + return $this->processId; + } + + /** + * @throws Error + */ + protected function getProcessId(): string + { + if (!isset($this->processId)) { + throw new Error('Installation ID was not specified.'); + } + + return $this->processId; + } + + private function getParentProcessId(): ?string + { + return $this->parentProcessId; + } + + public function setProcessId(string $processId): void + { + $this->processId = $processId; + } + + protected function setParentProcessId(string $processId): void + { + $this->parentProcessId = $processId; + } + + /** + * Check if version of upgrade/extension is acceptable to current version of EspoCRM. + * + * @throws Error + */ + protected function isAcceptable(): bool + { + $manifest = $this->getManifest(); + + $res = $this->checkPackageType(); + + if (isset($manifest['php'])) { + $res &= $this->checkVersions( + $manifest['php'], System::getPhpVersion(), + 'Your PHP version ({version}) is not supported. Required version: {requiredVersion}.' + ); + } + + if (isset($manifest['database'])) { + $databaseHelper = $this->getDatabaseHelper(); + $databaseType = $databaseHelper->getType(); + $databaseTypeLc = strtolower($databaseType); + + if (isset($manifest['database'][$databaseTypeLc])) { + $databaseVersion = $databaseHelper->getVersion(); + + if ($databaseVersion) { + $res &= $this->checkVersions( + $manifest['database'][$databaseTypeLc], + $databaseVersion, + 'Your '. $databaseType . + ' version ({version}) is not supported. Required version: {requiredVersion}.' + ); + } + } + } + + $version = $this->getSystemConfig()->getVersion(); + + // Skip @@version for extension development. + if (isset($manifest['acceptableVersions']) && $version !== '@@version') { + $res &= $this->checkVersions( + $manifest['acceptableVersions'], + $version, + 'Your EspoCRM version ({version}) is not supported. Required version: {requiredVersion}.' + ); + } + + if (!empty($manifest['dependencies'])) { + $res &= $this->checkDependencies($manifest['dependencies']); + } + + return (bool) $res; + } + + /** + * @param string[]|string $versionList + * @throws Error + */ + public function checkVersions($versionList, ?string $currentVersion, string $errorMessage = ''): bool + { + if (empty($versionList)) { + return true; + } + + if (!$currentVersion) { + return false; + } + + if (is_string($versionList)) { + $versionList = (array) $versionList; + } + + $version = null; + + foreach ($versionList as $version) { + $isInRange = false; + + try { + $isInRange = Semver::satisfies($currentVersion, $version); + } catch (Throwable $e) { + $this->getLog()->error("SemVer: Version identification error: {$e->getMessage()}."); + } + + if ($isInRange) { + return true; + } + } + + /** @noinspection RegExpRedundantEscape */ + $errorMessage = preg_replace('/\{version\}/', $currentVersion, $errorMessage); + + if (!is_string($errorMessage)) { + $errorMessage = '?'; + } + + /** @noinspection PhpArgumentWithoutNamedIdentifierInspection */ + /** @noinspection RegExpRedundantEscape */ + $errorMessage = preg_replace('/\{requiredVersion\}/', $version, $errorMessage); + + if (!is_string($errorMessage)) { + $errorMessage = '?'; + } + + $this->throwErrorAndRemovePackage($errorMessage); + + return false; + } + + /** + * @throws Error + */ + private function checkPackageType(): bool + { + $manifest = $this->getManifest(); + + /** check package type */ + $type = strtolower($this->getParam('name')); + + $manifestType = isset($manifest['type']) ? strtolower($manifest['type']) : $this->defaultPackageType; + + if (!in_array($manifestType, $this->packageTypes)) { + $this->throwErrorAndRemovePackage('Unknown package type.'); + } + + if ($type != $manifestType) { + $uType = ucfirst($type); + + $this->throwErrorAndRemovePackage( + "Wrong package type. You cannot install $manifestType package via $uType Manager."); + } + + return true; + } + + /** + * @param array $dependencyList + */ + protected function checkDependencies(array $dependencyList): bool + { + return true; + } + + /** + * Run a script by a type. + * + * @param string $type Ex. "before", "after". + * @throws Error + */ + protected function runScript(string $type): void + { + $beforeInstallScript = $this->getScriptPath($type); + + if (!$beforeInstallScript) { + return; + } + + $scriptNames = $this->getParam('scriptNames'); + $scriptName = $scriptNames[$type]; + + require_once($beforeInstallScript); + + $script = new $scriptName(); + + try { + assert(method_exists($script, 'run')); + + $script->run($this->getContainer(), $this->scriptParams); + } catch (Throwable $e) { + $this->throwErrorAndRemovePackage(exception: $e); + } + } + + /** + * @throws Error + */ + protected function getScriptPath(string $type): ?string + { + $packagePath = $this->getPackagePath(); + $scriptNames = $this->getParam('scriptNames'); + + $scriptName = $scriptNames[$type]; + + if (!isset($scriptName)) { + return null; + } + + $beforeInstallScript = Util::concatPath([$packagePath, self::SCRIPTS, $scriptName]) . '.php'; + + if (file_exists($beforeInstallScript)) { + return $beforeInstallScript; + } + + return null; + } + + /** + * Get package path, + * + * @throws Error + */ + protected function getPath(string $name, bool $isPackage = false): string + { + $postfix = $isPackage ? $this->packagePostfix : ''; + + $processId = basename($this->getProcessId()); + + $path = Util::concatPath($this->getParam($name), $processId); + + return $path . $postfix; + } + + /** + * @throws Error + */ + protected function getPackagePath(bool $isPackage = false): string + { + return $this->getPath('packagePath', $isPackage); + } + + /** + * @return string[] + * @throws Error + */ + protected function getDeleteList(string $type = 'delete'): array + { + $manifest = $this->getManifest(); + + switch ($type) { + case 'delete': + case 'deleteBeforeCopy': + if (isset($manifest[$type])) { + return $manifest[$type]; + } + + break; + + case 'vendor': + return $this->getVendorFileList('delete'); + } + + return []; + } + + /** + * Get a list of files defined in manifest. + * + * @return string[] + * @throws Error + */ + private function getDeleteFileList(): array + { + if (!isset($this->data['deleteFileList'])) { + $deleteFileList = []; + + /** @noinspection PhpRedundantOptionalArgumentInspection */ + $deleteList = array_merge( + $this->getDeleteList('delete'), + $this->getDeleteList('deleteBeforeCopy'), + $this->getDeleteList('vendor') + ); + + foreach ($deleteList as $itemPath) { + if (is_dir($itemPath)) { + /** @var string[] $fileList */ + $fileList = $this->getFileManager()->getFileList($itemPath, true, '', true, true); + + $fileList = $this->concatStringWithArray($itemPath, $fileList); + + $deleteFileList = array_merge($deleteFileList, $fileList); + + continue; + } + + $deleteFileList[] = $itemPath; + } + + $this->data['deleteFileList'] = $deleteFileList; + } + + return $this->data['deleteFileList']; + } + + /** + * Delete files defined in a manifest. + * + * @throws Error + */ + protected function deleteFiles(string $type = 'delete', bool $withEmptyDirs = false): bool + { + $deleteList = $this->getDeleteList($type); + + if (!empty($deleteList)) { + return $this->getFileManager()->remove($deleteList, null, $withEmptyDirs); + } + + return true; + } + + /** + * @return string[] + * @throws Error + */ + protected function getCopyFileList(): array + { + if (!isset($this->data['fileList'])) { + $packagePath = $this->getPackagePath(); + + $this->data['fileList'] = $this->getFileList($packagePath); + } + + return $this->data['fileList']; + } + + /** + * @return string[] + * @throws Error + */ + protected function getRestoreFileList(): array + { + if (!isset($this->data['restoreFileList'])) { + $backupPath = $this->getPath('backupPath'); + + $this->data['restoreFileList'] = $this->getFileList($backupPath, true); + } + + return $this->data['restoreFileList']; + } + + /** + * Get file directories (files, beforeInstallFiles, afterInstallFiles). + * + * @return string[] + */ + protected function getFileDirs(?string $parentDirPath = null): array + { + $dirNames = $this->getParam('customDirNames'); + + $paths = [self::FILES, $dirNames['before'], $dirNames['after']]; + + if (isset($parentDirPath)) { + foreach ($paths as &$path) { + $path = Util::concatPath($parentDirPath, $path); + } + } + + return $paths; + } + + /** + * Get file list from directories: files, beforeUpgradeFiles, afterUpgradeFiles. + * + * @return string[] + * @throws Error + */ + private function getFileList(string $dirPath, bool $skipVendorFileList = false): array + { + $fileList = []; + + $paths = $this->getFileDirs($dirPath); + + foreach ($paths as $filesPath) { + if (file_exists($filesPath)) { + /** @var string[] $files */ + $files = $this->getFileManager()->getFileList($filesPath, true, '', true, true); + + /** @var string[] $fileList */ + $fileList = array_merge($fileList, $files); + } + } + + if (!$skipVendorFileList) { + /** @noinspection PhpRedundantOptionalArgumentInspection */ + $vendorFileList = $this->getVendorFileList('copy'); + + if (!empty($vendorFileList)) { + /** @var string[] $fileList */ + $fileList = array_merge($fileList, $vendorFileList); + } + } + + return $fileList; + } + + /** + * @param ?string[] $fileList + * @throws Error + */ + protected function copy( + string $sourcePath, + string $destPath, + bool $recursively = false, + ?array $fileList = null, + bool $copyOnlyFiles = false + ): bool { + + try { + return $this->getFileManager()->copy($sourcePath, $destPath, $recursively, $fileList, $copyOnlyFiles); + } catch (Throwable $e) { + $this->throwErrorAndRemovePackage(exception: $e); + } + + return false; + } + + /** + * Copy files from upgrade/extension package. + * + * @throws Error + */ + protected function copyFiles(?string $type = null, string $dest = ''): bool + { + $filesPath = $this->getCopyFilesPath($type); + + if ($filesPath) { + switch ($type) { + case 'vendor': + $dest = $this->vendorDirName; + + break; + } + + return $this->copy($filesPath, $dest, true); + } + + return true; + } + + /** + * Get needed file list based on type. E.g. file list for "beforeCopy" action. + * + * @throws Error + */ + protected function getCopyFilesPath(?string $type = null): ?string + { + switch ($type) { + case 'before': + case 'after': + $dirNames = $this->getParam('customDirNames'); + + $dirPath = $dirNames[$type]; + + break; + + case 'vendor': + $dirNames = $this->getParam('customDirNames'); + + if (isset($dirNames['vendor'])) { + $dirPath = $dirNames['vendor']; + } + + break; + + default: + $dirPath = self::FILES; + + break; + } + + if (isset($dirPath)) { + $packagePath = $this->getPackagePath(); + $filesPath = Util::concatPath($packagePath, $dirPath); + + if (file_exists($filesPath)) { + return $filesPath; + } + } + + return null; + } + + /** + * @return string[] + * @throws Error + */ + private function getVendorFileList(string $type = 'copy'): array + { + $list = []; + + $packagePath = $this->getPackagePath(); + $dirNames = $this->getParam('customDirNames'); + + if (!isset($dirNames['vendor'])) { + return $list; + } + + $filesPath = Util::concatPath($packagePath, $dirNames['vendor']); + + if (!file_exists($filesPath)) { + return $list; + } + + switch ($type) { + case 'copy': + /** @var string[] $list */ + $list = $this->getFileManager()->getFileList($filesPath, true, '', true, true); + + break; + + case 'delete': + /** @var string[] $list */ + $list = $this->getFileManager()->getFileList($filesPath, false, '', null, true); + + break; + } + + foreach ($list as &$path) { + $path = Util::concatPath($this->vendorDirName, $path); + } + + return $list; + } + + /** + * @return array + * @throws Error + */ + public function getManifest(): array + { + if (!isset($this->data['manifest'])) { + $packagePath = $this->getPackagePath(); + + $manifestPath = Util::concatPath($packagePath, $this->manifestName); + + if (!file_exists($manifestPath)) { + $this->throwErrorAndRemovePackage("It's not an Installation package."); + } + + $manifestJson = $this->getFileManager()->getContents($manifestPath); + + $this->data['manifest'] = Json::decode($manifestJson, true); + + if (!$this->data['manifest']) { + $this->throwErrorAndRemovePackage('Syntax error in manifest.json.'); + } + + if (!$this->checkManifest($this->data['manifest'])) { + $this->throwErrorAndRemovePackage('Unsupported package.'); + } + } + + return $this->data['manifest']; + } + + /** + * @param array $manifest + */ + private function checkManifest(array $manifest): bool + { + $requiredFields = [ + 'name', + 'version', + ]; + + foreach ($requiredFields as $fieldName) { + if (empty($manifest[$fieldName])) { + return false; + } + } + + return true; + } + + /** + * @throws Error + */ + protected function getManifestParam(string $name): mixed + { + $manifest = $this->getManifest(); + + if (array_key_exists($name, $manifest)) { + return $manifest[$name]; + } + + return null; + } + + /** + * Unzip a package archive. + * + * @throws Error + */ + protected function unzipArchive(?string $packagePath = null): void + { + $packagePath = $packagePath ?? $this->getPackagePath(); + $packageArchivePath = $this->getPackagePath(true); + + if (!file_exists($packageArchivePath)) { + $this->throwErrorAndRemovePackage('Package archive does not exist.', false, false); + } + + try { + $res = $this->getZipUtil()->unzip($packageArchivePath, $packagePath); + } catch (Throwable $e) { + $message = $e->getMessage(); + + $this->throwErrorAndRemovePackage("Unzip error. $packagePath. $message", false, false); + + return; + } + + if ($res === false) { + $this->throwErrorAndRemovePackage("Unable to unzip the file $packagePath.", false, false); + } + } + + /** + * Delete temporary package files. + * + * @throws Error + */ + protected function deletePackageFiles(): bool + { + $packagePath = $this->getPackagePath(); + + return $this->getFileManager()->removeInDir($packagePath, true); + } + + /** + * Delete temporary package archive. + * + * @throws Error + */ + protected function deletePackageArchive(): bool + { + $packageArchive = $this->getPackagePath(true); + + return $this->getFileManager()->removeFile($packageArchive); + } + + protected function systemRebuild(): bool + { + try { + $dataManager = $this->getContainer()->getByClass(DataManager::class); + + $dataManager->rebuild(); + $dataManager->updateAppTimestamp(); + + return true; + } catch (Throwable $e) { + try { + $this->getLog()->error("Database rebuild failure, details: {$e->getMessage()}.", ['exception' => $e]); + } catch (Throwable) {} + } + + return false; + } + + /** + * Execute an action. For ex., execute uninstall action in install. + * + * @param string|array $data + * @throws Error + */ + protected function executeAction(string $actionName, $data): void + { + $actionManager = $this->actionManager; + + $currentAction = $actionManager->getAction(); + + $actionManager->setAction($actionName); + $actionManager->run($data); + $actionManager->setAction($currentAction); + } + + protected function initialize(): void + {} + + protected function finalize(): void + {} + + protected function beforeRunAction(): void + {} + + protected function afterRunAction(): void + {} + + /** + * @throws Error + */ + protected function clearCache(): void + { + $dataManager = $this->getContainer()->getByClass(DataManager::class); + + $dataManager->clearCache(); + } + + /** + * @throws Error + */ + protected function checkIsWritable(): void + { + $backupPath = $this->getPath('backupPath'); + $fullFileList = array_merge([$backupPath], $this->getDeleteFileList(), $this->getCopyFileList()); + + $result = $this->getFileManager()->isWritableList($fullFileList); + + if ($result) { + return; + } + + $permissionDeniedList = $this->getFileManager()->getLastPermissionDeniedList(); + + $delimiter = $this->isCli() ? "\n" : "
"; + + $this->throwErrorAndRemovePackage( + "Permission denied: " . $delimiter . implode($delimiter, $permissionDeniedList), + false, + false + ); + } + + /** + * @throws Error + */ + protected function backupExistingFiles(): bool + { + $fullFileList = array_merge($this->getDeleteFileList(), $this->getCopyFileList()); + + $backupPath = $this->getPath('backupPath'); + + $destination = Util::concatPath($backupPath, self::FILES); + + return $this->copy('', $destination, false, $fullFileList); + } + + protected function getHelper(): Helper + { + if (!$this->helper) { + $this->helper = new Helper($this->getEntityManager()); + } + + $this->helper->setActionObject($this); + + assert($this->helper !== null); + + return $this->helper; + } + + /** + * @param string $string + * @param string[] $array + * @return string[] + */ + private function concatStringWithArray(string $string, array $array): array + { + foreach ($array as &$value) { + if (!str_ends_with($string, '/')) { + $string .= '/'; + } + + $value = $string . $value; + } + + return $array; + } + + /** + * @throws Error + */ + protected function enableMaintenanceMode(): void + { + $config = $this->getConfig(); + $configWriter = $this->createConfigWriter(); + + $configParamName = $this->getTemporaryConfigParamName(); + $parentConfigParamName = $this->getTemporaryConfigParamName(true); + + if ( + ($configParamName && $config->has($configParamName)) || + ($parentConfigParamName && $config->has($parentConfigParamName)) + ) { + return; + } + + $actualParams = [ + 'maintenanceMode' => $this->getSystemConfig()->isMaintenanceMode(), + 'useCache' => $this->getSystemConfig()->useCache(), + ]; + + if ($configParamName) { + // @todo Maybe to remove this line? + $configWriter->set($configParamName, $actualParams); + } + + $save = false; + + if (!$actualParams['maintenanceMode']) { + $configWriter->set('maintenanceMode', true); + + $save = true; + } + + if ($actualParams['useCache']) { + $configWriter->set('useCache', false); + + $save = true; + } + + if ($save) { + $configWriter->save(); + } + } + + /** + * @throws Error + */ + protected function disableMaintenanceMode(bool $force = false): void + { + $config = $this->getConfig(); + $configWriter = $this->createConfigWriter(); + + $configParamList = [ + $this->getTemporaryConfigParamName(), + ]; + + if ($force && $this->getTemporaryConfigParamName(true)) { + $configParamList[] = $this->getTemporaryConfigParamName(true); + } + + $save = false; + + foreach ($configParamList as $configParamName) { + if ($configParamName === null) { + continue; + } + + if (!$config->has($configParamName)) { + continue; + } + + foreach ($config->get($configParamName, []) as $paramName => $paramValue) { + if ($config->get($paramName) != $paramValue) { + $configWriter->set($paramName, $paramValue); + } + } + + $configWriter->remove($configParamName); + + $save = true; + } + + if ($save) { + $configWriter->save(); + } + } + + /** + * @throws Error + */ + private function getTemporaryConfigParamName(bool $isParentProcess = false): ?string + { + $processId = $this->getProcessId(); + + if ($isParentProcess) { + $processId = $this->getParentProcessId(); + + if (!$processId) { + return null; + } + } + + return 'temporaryUpgradeParams' . $processId; + } + + private function isCli(): bool + { + if (str_starts_with(php_sapi_name() ?: '', 'cli')) { + return true; + } + + return false; + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Base/Delete.php b/application/Espo/Core/Upgrades/Actions/Base/Delete.php new file mode 100644 index 0000000..c33ee0b --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Base/Delete.php @@ -0,0 +1,78 @@ +. + * + * 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\Actions\Base; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Upgrades\Actions\Base; + +class Delete extends Base +{ + /** + * @param array $data + * @throws Error + */ + public function run(mixed $data): mixed + { + $processId = $data['id']; + + $this->getLog()->debug('Delete package process ['.$processId.']: start run.'); + + if (empty($processId)) { + throw new Error('Delete package package ID was not specified.'); + } + + $this->initialize(); + $this->setProcessId($processId); + + if (isset($data['parentProcessId'])) { + $this->setParentProcessId($data['parentProcessId']); + } + + $this->beforeRunAction(); + /* delete a package */ + $this->deletePackage(); + $this->afterRunAction(); + $this->finalize(); + + $this->getLog()->debug('Delete package process ['.$processId.']: end run.'); + + return null; + } + + /** + * @throws Error + */ + protected function deletePackage(): bool + { + $packageArchivePath = $this->getPackagePath(true); + + return $this->getFileManager()->removeFile($packageArchivePath); + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Base/Install.php b/application/Espo/Core/Upgrades/Actions/Base/Install.php new file mode 100644 index 0000000..f22c14c --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Base/Install.php @@ -0,0 +1,341 @@ +. + * + * 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\Actions\Base; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Upgrades\Actions\Base; +use Espo\Core\Utils\Util; +use Throwable; + +class Install extends Base +{ + /** + * Main installation process. + * + * @param array $data + * @throws Error + */ + public function run(mixed $data): mixed + { + $processId = $data['id']; + + $this->getLog()->debug("Installation process [$processId]: start run."); + + $this->stepInit($data); + $this->stepCopyBefore($data); + + if ($this->getCopyFilesPath('before')) { + $this->stepRebuild($data); + } + + $this->stepBeforeInstallScript($data); + + if ($this->getScriptPath('before')) { + $this->stepRebuild($data); + } + + $this->stepCopy($data); + $this->stepRebuild($data); + + $this->stepCopyAfter($data); + + if ($this->getCopyFilesPath('after')) { + $this->stepRebuild($data); + } + + $this->stepAfterInstallScript($data); + if ($this->getScriptPath('after')) { + $this->stepRebuild($data); + } + + $this->stepFinalize($data); + + $this->getLog()->debug("Installation process [$processId]: end run."); + + return null; + } + + /** + * @param array $data + * @throws Error + */ + protected function initPackage(array $data): void + { + $processId = $data['id']; + + if (empty($processId)) { + throw new Error('Installation package ID was not specified.'); + } + + $this->setProcessId($processId); + + if (isset($data['parentProcessId'])) { + $this->setParentProcessId($data['parentProcessId']); + } + + /** check if an archive is unzipped, if no then unzip */ + $packagePath = $this->getPackagePath(); + + if (!file_exists($packagePath)) { + $this->unzipArchive(); + $this->isAcceptable(); + } + } + + /** + * @param array $data + * @throws Error + */ + public function stepInit(array $data): void + { + $this->initPackage($data); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: Start \"init\" step."); + + if (!$this->systemRebuild()) { + $this->throwErrorAndRemovePackage('Rebuild is failed. Fix all errors before upgrade.'); + } + + $this->initialize(); + $this->checkIsWritable(); + $this->enableMaintenanceMode(); + $this->beforeRunAction(); + $this->backupExistingFiles(); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: End \"init\" step."); + } + + /** + * @param array $data + * @throws Error + */ + public function stepCopyBefore(array $data): void + { + $this->initPackage($data); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: Start \"copyBefore\" step."); + + if (!$this->copyFiles('before')) { + $this->throwErrorAndRemovePackage('Cannot copy beforeInstall files.'); + } + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: End \"copyBefore\" step."); + } + + /** + * @param array $data + * @throws Error + */ + public function stepBeforeInstallScript(array $data): void + { + $this->initPackage($data); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: Start \"beforeInstallScript\" step."); + + if (!isset($data['skipBeforeScript']) || !$data['skipBeforeScript']) { + $this->runScript('before'); + } + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: End \"beforeInstallScript\" step."); + } + + /** + * @param array $data + * @throws Error + */ + public function stepCopy(array $data): void + { + $this->initPackage($data); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: Start \"copy\" step."); + + /* remove files defined in a manifest */ + if (!$this->deleteFiles('delete', true)) { + $this->throwErrorAndRemovePackage('Cannot delete files.'); + } + + /* copy files from directory "Files" to EspoCRM files */ + if (!$this->copyFiles()) { + $this->throwErrorAndRemovePackage('Cannot copy files.'); + } + + if (!$this->deleteFiles('vendor')) { + $this->throwErrorAndRemovePackage('Cannot delete vendor files.'); + } + + if (!$this->copyFiles('vendor')) { + $this->throwErrorAndRemovePackage('Cannot copy vendor files.'); + } + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: End \"copy\" step."); + } + + /** + * @param array $data + * @throws Error + */ + public function stepRebuild(array $data): void + { + $this->initPackage($data); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: Start \"rebuild\" step."); + + if (!isset($data['skipSystemRebuild']) || !$data['skipSystemRebuild']) { + if (!$this->systemRebuild()) { + $this->throwErrorAndRemovePackage('Error occurred while EspoCRM rebuild. More detail in the log.'); + } + } + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: End \"rebuild\" step."); + } + + /** + * @param array $data + * @throws Error + */ + public function stepCopyAfter(array $data): void + { + $this->initPackage($data); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: Start \"copyAfter\" step."); + + //afterInstallFiles + if (!$this->copyFiles('after')) { + $this->throwErrorAndRemovePackage('Cannot copy afterInstall files.'); + } + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: End \"copyAfter\" step."); + } + + /** + * @param array $data + * @throws Error + */ + public function stepAfterInstallScript(array $data): void + { + $this->initPackage($data); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: Start \"afterInstallScript\" step."); + + /* run after install script */ + if (!isset($data['skipAfterScript']) || !$data['skipAfterScript']) { + $this->runScript('after'); + } + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: End \"afterInstallScript\" step."); + } + + /** + * @param array $data + * @throws Error + */ + public function stepFinalize(array $data): void + { + $this->initPackage($data); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: Start \"finalize\" step."); + + $this->disableMaintenanceMode(); + $this->afterRunAction(); + $this->finalize(); + + /* delete unzipped files */ + $this->deletePackageFiles(); + + if ($this->getManifestParam('skipBackup')) { + $path = Util::concatPath($this->getPath('backupPath'), self::FILES); + + $this->getFileManager()->removeInDir($path); + } + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: End \"finalize\" step."); + } + + /** + * @param array $data + * @throws Error + */ + public function stepRevert(array $data): void + { + $this->initPackage($data); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: Start \"revert\" step."); + + $this->restoreFiles(); + + $this->getLog()->info("Installation process [{$this->getProcessId()}]: End \"revert\" step."); + } + + /** + * @throws Error + */ + protected function restoreFiles(): bool + { + $this->getLog()->info('Installer: Restore previous files.'); + + $backupPath = $this->getPath('backupPath'); + $backupFilePath = Util::concatPath($backupPath, self::FILES); + + if (!file_exists($backupFilePath)) { + return true; + } + + $backupFileList = $this->getRestoreFileList(); + $copyFileList = $this->getCopyFileList(); + $deleteFileList = array_diff($copyFileList, $backupFileList); + + $res = $this->copy($backupFilePath, '', true); + + if (!empty($deleteFileList)) { + $res &= $this->getFileManager()->remove($deleteFileList, null, true); + } + + if ($res) { + $this->getFileManager()->removeInDir($backupPath, true); + } + + return (bool) $res; + } + + /** + * @throws Error + */ + public function throwErrorAndRemovePackage( + string $errorMessage = '', + bool $deletePackage = true, + bool $systemRebuild = true, + ?Throwable $exception = null + ): void { + + $this->restoreFiles(); + + parent::throwErrorAndRemovePackage($errorMessage, $deletePackage, $systemRebuild, $exception); + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Base/Uninstall.php b/application/Espo/Core/Upgrades/Actions/Base/Uninstall.php new file mode 100644 index 0000000..09c21d8 --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Base/Uninstall.php @@ -0,0 +1,249 @@ +. + * + * 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\Actions\Base; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Upgrades\Actions\Base; +use Espo\Core\Utils\Util; +use Espo\Core\Utils\Json; +use Throwable; + +class Uninstall extends Base +{ + /** + * @param array $data + * @throws Error + */ + public function run(mixed $data): mixed + { + $processId = $data['id']; + + $this->getLog()->debug('Uninstallation process ['.$processId.']: start run.'); + + if (empty($processId)) { + throw new Error('Uninstallation package ID was not specified.'); + } + + $this->setProcessId($processId); + + if (isset($data['parentProcessId'])) { + $this->setParentProcessId($data['parentProcessId']); + } + + $this->initialize(); + $this->checkIsWritable(); + $this->enableMaintenanceMode(); + $this->beforeRunAction(); + + /* run before uninstall script */ + if (!isset($data['skipBeforeScript']) || !$data['skipBeforeScript']) { + $this->runScript('beforeUninstall'); + } + + $backupPath = $this->getPath('backupPath'); + if (file_exists($backupPath)) { + /* copy core files */ + if (!$this->copyFiles()) { + $this->throwErrorAndRemovePackage('Cannot copy files.'); + } + } + + /* remove extension files, saved in fileList */ + if (!$this->deleteFiles('delete', true)) { + $this->throwErrorAndRemovePackage('Permission denied to delete files.'); + } + + $this->disableMaintenanceMode(); + + if (!isset($data['skipSystemRebuild']) || !$data['skipSystemRebuild']) { + if (!$this->systemRebuild()) { + $this->throwErrorAndRemovePackage( + 'Error occurred while EspoCRM rebuild. More detail in the log.' + ); + } + } + + /* run after uninstall script */ + if (!isset($data['skipAfterScript']) || !$data['skipAfterScript']) { + $this->runScript('afterUninstall'); + } + + $this->afterRunAction(); + /* delete backup files */ + $this->deletePackageFiles(); + $this->finalize(); + + $this->getLog()->debug('Uninstallation process ['.$processId.']: end run.'); + + $this->clearCache(); + + return null; + } + + /** + * @throws Error + */ + protected function restoreFiles(): bool + { + $packagePath = $this->getPath('packagePath'); + + $manifestPath = Util::concatPath($packagePath, $this->manifestName); + + if (!file_exists($manifestPath)) { + $this->unzipArchive($packagePath); + } + + $fileDirs = $this->getFileDirs($packagePath); + + $res = true; + + foreach ($fileDirs as $filesPath) { + if (file_exists($filesPath)) { + $res = $this->copy($filesPath, '', true); + } + } + + $manifestJson = $this->getFileManager()->getContents($manifestPath); + $manifest = Json::decode($manifestJson, true); + + if (!empty($manifest['delete'])) { + $res &= $this->getFileManager()->remove($manifest['delete'], null, true); + } + + $res &= $this->getFileManager()->removeInDir($packagePath, true); + + return (bool) $res; + } + + /** + * @param ?string $type + * @param string $dest + * @throws Error + */ + protected function copyFiles($type = null, $dest = ''): bool + { + $backupPath = $this->getPath('backupPath'); + + $source = Util::concatPath($backupPath, self::FILES); + + return $this->copy($source, $dest, true); + } + + /** + * Get backup path. + * + * @param bool $isPackage + * @throws Error + */ + protected function getPackagePath($isPackage = false): string + { + if ($isPackage) { + return $this->getPath('packagePath', $isPackage); + } + + return $this->getPath('backupPath'); + } + + /** + * @throws Error + */ + protected function deletePackageFiles(): bool + { + $backupPath = $this->getPath('backupPath'); + + return $this->getFileManager()->removeInDir($backupPath, true); + } + + /** + * @throws Error + */ + public function throwErrorAndRemovePackage( + string $errorMessage = '', + bool $deletePackage = true, + bool $systemRebuild = true, + ?Throwable $exception = null + ): void { + + $this->restoreFiles(); + + parent::throwErrorAndRemovePackage($errorMessage, false, $systemRebuild, $exception); + } + + /** + * @return string[] + * @throws Error + */ + protected function getCopyFileList(): array + { + if (!isset($this->data['fileList'])) { + $backupPath = $this->getPath('backupPath'); + $filesPath = Util::concatPath($backupPath, self::FILES); + + $this->data['fileList'] = $this->getFileManager()->getFileList($filesPath, true, '', true, true); + } + + return $this->data['fileList']; + } + + /** + * @throws Error + */ + protected function getRestoreFileList(): array + { + if (!isset($this->data['restoreFileList'])) { + $packagePath = $this->getPackagePath(); + $filesPath = Util::concatPath($packagePath, self::FILES); + + if (!file_exists($filesPath)) { + $this->unzipArchive($packagePath); + } + + $this->data['restoreFileList'] = $this->getFileManager()->getFileList($filesPath, true, '', true, true); + } + + return $this->data['restoreFileList']; + } + + /** + * @param string $type + * @throws Error + */ + protected function getDeleteList($type = 'delete'): array + { + if ($type == 'delete') { + $packageFileList = $this->getRestoreFileList(); + $backupFileList = $this->getCopyFileList(); + + return array_diff($packageFileList, $backupFileList); + } + + return []; + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Base/Upload.php b/application/Espo/Core/Upgrades/Actions/Base/Upload.php new file mode 100644 index 0000000..0c24221 --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Base/Upload.php @@ -0,0 +1,78 @@ +. + * + * 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\Actions\Base; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Upgrades\Actions\Base; + +class Upload extends Base +{ + /** + * Upload an upgrade/extension package. + * + * @param string $data + * @return string ID of upgrade/extension process. + * @throws Error + */ + public function run(mixed $data): string + { + $processId = $this->createProcessId(); + + $this->getLog()->debug("Installation process [$processId]: start upload the package."); + + $this->initialize(); + $this->beforeRunAction(); + + $packageArchivePath = $this->getPackagePath(true); + + $contents = null; + + if (!empty($data)) { + [, $contents] = explode(',', $data); + + $contents = base64_decode($contents); + } + + $res = $this->getFileManager()->putContents($packageArchivePath, $contents); + + if ($res === false) { + throw new Error('Could not upload the package.'); + } + + $this->unzipArchive(); + $this->isAcceptable(); + $this->afterRunAction(); + $this->finalize(); + + $this->getLog()->debug("Installation process [$processId]: end upload the package."); + + return $processId; + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Extension/Delete.php b/application/Espo/Core/Upgrades/Actions/Extension/Delete.php new file mode 100644 index 0000000..9cde2aa --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Extension/Delete.php @@ -0,0 +1,68 @@ +. + * + * 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\Actions\Extension; +use Espo\Core\Exceptions\Error; +use Espo\Entities\Extension; + +class Delete extends \Espo\Core\Upgrades\Actions\Base\Delete +{ + protected ?Extension $extensionEntity = null; + + /** + * Get an entity of this extension. + * + * @throws Error + */ + protected function getExtensionEntity(): Extension + { + if (!$this->extensionEntity) { + $processId = $this->getProcessId(); + + $this->extensionEntity = $this->getEntityManager()->getEntityById(Extension::ENTITY_TYPE, $processId); + + if (!$this->extensionEntity) { + throw new Error('Extension entity not found.'); + } + } + + return $this->extensionEntity; + } + + /** + * @throws Error + */ + protected function afterRunAction(): void + { + /** Delete extension entity */ + $extensionEntity = $this->getExtensionEntity(); + + $this->getEntityManager()->removeEntity($extensionEntity); + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Extension/Install.php b/application/Espo/Core/Upgrades/Actions/Extension/Install.php new file mode 100644 index 0000000..79b4c59 --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Extension/Install.php @@ -0,0 +1,245 @@ +. + * + * 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\Actions\Extension; + +use Espo\Core\Upgrades\Base; +use Espo\Core\Utils\Util; +use Espo\Core\Exceptions\Error; + +use Espo\Entities\Extension; +use Espo\ORM\Name\Attribute; +use Throwable; + +class Install extends \Espo\Core\Upgrades\Actions\Base\Install +{ + protected ?Extension $extensionEntity = null; + + /** + * @throws Error + */ + protected function beforeRunAction(): void + { + $this->loadExtension(); + + if (!$this->isNew()) { + $this->scriptParams['isUpgrade'] = true; + + $this->compareVersion(); + $this->uninstallExtension(); + } + } + + /** + * @throws Error + */ + protected function afterRunAction(): void + { + if (!$this->isNew()) { + $this->deleteExtension(); + } + + $this->storeExtension(); + } + + /** + * Copy Existing files to a backup directory. + * + * @throws Error + */ + protected function backupExistingFiles(): bool + { + parent::backupExistingFiles(); + + $backupPath = $this->getPath('backupPath'); + + /** copy scripts files */ + $packagePath = $this->getPackagePath(); + + $source = Util::concatPath($packagePath, self::SCRIPTS); + $destination = Util::concatPath($backupPath, self::SCRIPTS); + + return $this->copy($source, $destination, true); + } + + private function isNew(): bool + { + $extensionEntity = $this->getExtensionEntity(); + + if ($extensionEntity) { + $id = $extensionEntity->get(Attribute::ID); + } + + return !isset($id); + } + + /** + * Get the entity of the extension. + */ + private function getExtensionEntity(): ?Extension + { + return $this->extensionEntity; + } + + /** + * Load the extension entity. + * + * @throws Error + */ + private function loadExtension(): void + { + $manifest = $this->getManifest(); + + $this->extensionEntity = $this->getEntityManager() + ->getRDBRepositoryByClass(Extension::class) + ->where([ + 'name' => $manifest['name'], + 'isInstalled' => true, + ]) + ->findOne(); + } + + /** + * Create a record of an extension entity. + * + * @throws Error + */ + private function storeExtension(): void + { + $entityManager = $this->getEntityManager(); + + $extensionEntity = null; + + if ($this->getProcessId()) { + $extensionEntity = $entityManager->getEntityById(Extension::ENTITY_TYPE, $this->getProcessId()); + } + + if (!$extensionEntity) { + $extensionEntity = $entityManager->getNewEntity(Extension::ENTITY_TYPE); + } + + $manifest = $this->getManifest(); + $fileList = $this->getCopyFileList(); + + $data = [ + 'id' => $this->getProcessId(), + 'name' => trim($manifest['name']), + 'isInstalled' => true, + 'version' => $manifest['version'], + 'fileList' => $fileList, + 'description' => $manifest['description'], + ]; + + if (!empty($manifest['checkVersionUrl'])) { + $data['checkVersionUrl'] = $manifest['checkVersionUrl']; + } + + $extensionEntity->set($data); + + try { + $entityManager->saveEntity($extensionEntity); + } catch (Throwable $e) { + $msg = "Error while saving the extension entity. The error occurred in an existing hook. " . + "{$e->getMessage()} at {$e->getFile()}:{$e->getLine()}"; + + $this->getLog()->error($msg); + $this->throwErrorAndRemovePackage('Error saving extension entity. Check logs for details.', false); + } + } + + /** + * Compare version between installed and a new extensions. + * + * @throws Error + */ + private function compareVersion(): void + { + $manifest = $this->getManifest(); + $extensionEntity = $this->getExtensionEntity(); + + if (!$extensionEntity) { + return; + } + + $comparedVersion = version_compare($manifest['version'], $extensionEntity->getVersion(), '>='); + + if ($comparedVersion <= 0) { + $this->throwErrorAndRemovePackage('You cannot install an older version of this extension.'); + } + } + + /** + * If extension already installed, uninstall an old version. + * + * @throws Error + */ + private function uninstallExtension(): void + { + $extensionEntity = $this->getExtensionEntity(); + + if (!$extensionEntity) { + throw new Error("Can't uninstall not existing extension."); + } + + $this->executeAction(Base::UNINSTALL, [ + 'id' => $extensionEntity->get(Attribute::ID), + 'skipSystemRebuild' => true, + 'skipAfterScript' => true, + 'parentProcessId' => $this->getProcessId(), + ]); + } + + /** + * Delete extension package. + * + * @throws Error + */ + private function deleteExtension(): void + { + $extensionEntity = $this->getExtensionEntity(); + + if (!$extensionEntity) { + throw new Error("Can't delete not existing extension."); + } + + $this->executeAction(Base::DELETE, [ + 'id' => $extensionEntity->get(Attribute::ID), + 'parentProcessId' => $this->getProcessId(), + ]); + } + + /** + * @param array $dependencyList + * @throws Error + */ + protected function checkDependencies(array $dependencyList): bool + { + return $this->getHelper()->checkDependencies($dependencyList); + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Extension/Uninstall.php b/application/Espo/Core/Upgrades/Actions/Extension/Uninstall.php new file mode 100644 index 0000000..67f7de9 --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Extension/Uninstall.php @@ -0,0 +1,96 @@ +. + * + * 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\Actions\Extension; + +use Espo\Core\Exceptions\Error; + +use Espo\Entities\Extension; +use Throwable; + +class Uninstall extends \Espo\Core\Upgrades\Actions\Base\Uninstall +{ + protected ?Extension $extensionEntity = null; + + /** + * Get entity of this extension. + * + * @return Extension + * @throws Error + */ + protected function getExtensionEntity() + { + if (!isset($this->extensionEntity)) { + $processId = $this->getProcessId(); + + $this->extensionEntity = $this->getEntityManager()->getEntityById(Extension::ENTITY_TYPE, $processId); + + if (!$this->extensionEntity) { + throw new Error('Extension entity not found.'); + } + } + + return $this->extensionEntity; + } + + /** + * @throws Error + */ + protected function afterRunAction(): void + { + /** Set extension entity, isInstalled = false */ + $extensionEntity = $this->getExtensionEntity(); + $extensionEntity->set('isInstalled', false); + + try { + $this->getEntityManager()->saveEntity($extensionEntity); + } catch (Throwable $e) { + $this->getLog()->error( + 'Error saving Extension entity. The error occurred by existing Hook, more details: ' . + $e->getMessage() .' at '. $e->getFile() . ':' . $e->getLine() + ); + + $this->throwErrorAndRemovePackage('Error saving Extension entity. Check logs for details.', false); + } + } + + /** + * @return string[] + * @throws Error + */ + protected function getRestoreFileList(): array + { + if (!isset($this->data['restoreFileList'])) { + $extensionEntity = $this->getExtensionEntity(); + $this->data['restoreFileList'] = $extensionEntity->get('fileList'); + } + + return $this->data['restoreFileList'] ?? []; + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Extension/Upload.php b/application/Espo/Core/Upgrades/Actions/Extension/Upload.php new file mode 100644 index 0000000..b9796b2 --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Extension/Upload.php @@ -0,0 +1,46 @@ +. + * + * 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\Actions\Extension; + +use Espo\Core\Exceptions\Error; + +class Upload extends \Espo\Core\Upgrades\Actions\Base\Upload +{ + /** + * Check dependencies. + * + * @param array $dependencyList + * @throws Error + */ + protected function checkDependencies($dependencyList): bool + { + return $this->getHelper()->checkDependencies($dependencyList); + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Helper.php b/application/Espo/Core/Upgrades/Actions/Helper.php new file mode 100644 index 0000000..89ce26b --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Helper.php @@ -0,0 +1,96 @@ +. + * + * 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\Actions; + +use Espo\Core\Exceptions\Error; +use Espo\Entities\Extension; +use Espo\ORM\EntityManager; +use RuntimeException; + +class Helper +{ + private ?Base $actionObject = null; + + public function __construct(private EntityManager $entityManager) + {} + + public function setActionObject(Base $actionObject): void + { + $this->actionObject = $actionObject; + } + + /** + * Check dependencies. + * + * @param array $dependencyList + * @throws Error + */ + public function checkDependencies(mixed $dependencyList): bool + { + if (!$this->actionObject) { + throw new RuntimeException("No action passed."); + } + + if (!is_array($dependencyList)) { + $dependencyList = (array) $dependencyList; + } + + /** @var array $dependencyList */ + + foreach ($dependencyList as $extensionName => $extensionVersion) { + $entity = $this->entityManager + ->getRDBRepositoryByClass(Extension::class) + ->where([ + 'name' => trim($extensionName), + 'isInstalled' => true, + ]) + ->findOne(); + + $versionString = is_array($extensionVersion) ? + implode(', ', $extensionVersion) : + $extensionVersion; + + $errorMessage = "Dependency error: Extension '$extensionName' with version '$versionString' is missing."; + + if ( + !$entity || + !$this->actionObject->checkVersions( + $extensionVersion, + $entity->getVersion(), + $errorMessage + ) + ) { + throw new Error($errorMessage); + } + } + + return true; + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Upgrade/Delete.php b/application/Espo/Core/Upgrades/Actions/Upgrade/Delete.php new file mode 100644 index 0000000..c28e32c --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Upgrade/Delete.php @@ -0,0 +1,44 @@ +. + * + * 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\Actions\Upgrade; + +use Espo\Core\Exceptions\Error; + +class Delete extends \Espo\Core\Upgrades\Actions\Base\Delete +{ + /** + * @param array $data + * @throws Error + */ + public function run(mixed $data): mixed + { + throw new Error('The operation is not permitted.'); + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Upgrade/Install.php b/application/Espo/Core/Upgrades/Actions/Upgrade/Install.php new file mode 100644 index 0000000..55d5bd2 --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Upgrade/Install.php @@ -0,0 +1,139 @@ +. + * + * 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\Actions\Upgrade; + +use Espo\Core\Exceptions\Error; +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\Upgrades\Migration\VersionUtil; +use RuntimeException; + +class Install extends \Espo\Core\Upgrades\Actions\Base\Install +{ + /** + * @param array $data + * @throws Error + */ + public function stepBeforeUpgradeScript(array $data): void + { + $this->stepBeforeInstallScript($data); + } + + /** + * @param array $data + * @throws Error + */ + public function stepAfterUpgradeScript(array $data): void + { + $this->stepAfterInstallScript($data); + $this->runMigrationAfterInstallScript(); + } + + /** + * @throws Error + */ + protected function finalize(): void + { + $configWriter = $this->createConfigWriter(); + $configWriter->set('version', $this->getTargetVersion()); + $configWriter->save(); + } + + /** + * Delete temporary package files. + * + * @throws Error + */ + protected function deletePackageFiles(): bool + { + $res = parent::deletePackageFiles(); + $res &= $this->deletePackageArchive(); + + return (bool) $res; + } + + /** + * @throws Error + */ + private function getTargetVersion(): string + { + $version = $this->getManifest()['version']; + + if (!$version) { + throw new RuntimeException("No 'version' in manifest."); + } + + return $version; + } + + /** + * @throws Error + */ + private function runMigrationAfterInstallScript(): void + { + $targetVersion = $this->getTargetVersion(); + $version = $this->getConfig()->get('version'); + + if (!$version || !is_string($version)) { + throw new RuntimeException("No or bad 'version' in config."); + } + + $script = $this->getMigrationAfterInstallScript($version, $targetVersion); + + if (!$script) { + return; + } + + $script->run(); + } + + private function getMigrationAfterInstallScript(string $version, string $targetVersion): ?Script + { + $isPatch = VersionUtil::isPatch($version, $targetVersion); + $a = VersionUtil::split($targetVersion); + + $dir = $isPatch ? + 'V' . $a[0] . '_' . $a[1] . '_' . $a[2] : + 'V' . $a[0] . '_' . $a[1]; + + $className = "Espo\\Core\\Upgrades\\Migrations\\$dir\\AfterUpgrade"; + + if (!class_exists($className)) { + return null; + } + + $script = $this->getInjectableFactory()->createWith($className, ['isUpgrade' => true]); + + if (!$script instanceof Script) { + throw new RuntimeException("$className does not implement Script interface."); + } + + return $script; + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Upgrade/Uninstall.php b/application/Espo/Core/Upgrades/Actions/Upgrade/Uninstall.php new file mode 100644 index 0000000..afa9266 --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Upgrade/Uninstall.php @@ -0,0 +1,44 @@ +. + * + * 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\Actions\Upgrade; + +use Espo\Core\Exceptions\Error; + +class Uninstall extends \Espo\Core\Upgrades\Actions\Base\Uninstall +{ + /** + * @param array $data + * @throws Error + */ + public function run(mixed $data): mixed + { + throw new Error('The operation is not permitted.'); + } +} diff --git a/application/Espo/Core/Upgrades/Actions/Upgrade/Upload.php b/application/Espo/Core/Upgrades/Actions/Upgrade/Upload.php new file mode 100644 index 0000000..c09b2ee --- /dev/null +++ b/application/Espo/Core/Upgrades/Actions/Upgrade/Upload.php @@ -0,0 +1,34 @@ +. + * + * 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\Actions\Upgrade; + +class Upload extends \Espo\Core\Upgrades\Actions\Base\Upload +{ +} diff --git a/application/Espo/Core/Upgrades/Base.php b/application/Espo/Core/Upgrades/Base.php new file mode 100644 index 0000000..5b0390c --- /dev/null +++ b/application/Espo/Core/Upgrades/Base.php @@ -0,0 +1,147 @@ +. + * + * 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; + +use Espo\Core\Container; +use Espo\Core\Exceptions\Error; + +abstract class Base +{ + protected ActionManager $actionManager; + + protected ?string $name = null; + /** @var array */ + protected array $params = []; + + const UPLOAD = 'upload'; + const INSTALL = 'install'; + const UNINSTALL = 'uninstall'; + const DELETE = 'delete'; + + public function __construct(Container $container) + { + $this->actionManager = new ActionManager($this->name ?? '', $container, $this->params); + } + + /** + * @return array + * @throws Error + */ + public function getManifest(): array + { + return $this->actionManager->getManifest(); + } + + /** + * @return array + * @throws Error + */ + public function getManifestById(string $processId): array + { + $actionClass = $this->actionManager->getActionClass(self::INSTALL); + $actionClass->setProcessId($processId); + + return $actionClass->getManifest(); + } + + /** + * @throws Error + */ + public function upload(string $data): string + { + $this->actionManager->setAction(self::UPLOAD); + + return $this->actionManager->run($data); + } + + /** + * @param array $data + * @throws Error + */ + public function install(array $data): mixed + { + $this->actionManager->setAction(self::INSTALL); + + return $this->actionManager->run($data); + } + + /** + * @param array $data + * @throws Error + */ + public function uninstall(array $data): mixed + { + $this->actionManager->setAction(self::UNINSTALL); + + return $this->actionManager->run($data); + } + + /** + * @param array $data + * @throws Error + */ + public function delete(array $data): mixed + { + $this->actionManager->setAction(self::DELETE); + + return $this->actionManager->run($data); + } + + /** + * @param array $params + * @throws Error + */ + public function runInstallStep(string $stepName, array $params = []): void + { + $this->runActionStep(self::INSTALL, $stepName, $params); + } + + /** + * @param array $params + * @throws Error + * @noinspection PhpSameParameterValueInspection + */ + private function runActionStep(string $actionName, string $stepName, array $params = []): void + { + $actionClass = $this->actionManager->getActionClass($actionName); + $methodName = 'step' . ucfirst($stepName); + + if (!method_exists($actionClass, $methodName)) { + if (!empty($params['id'])) { + $actionClass->setProcessId($params['id']); + $actionClass->throwErrorAndRemovePackage("Step \"$stepName\" is not found."); + } + + throw new Error('Step "'. $stepName .'" is not found.'); + } + + $actionClass->$methodName($params); // throw an Exception on error + } +} diff --git a/application/Espo/Core/Upgrades/ExtensionManager.php b/application/Espo/Core/Upgrades/ExtensionManager.php new file mode 100644 index 0000000..fa67eee --- /dev/null +++ b/application/Espo/Core/Upgrades/ExtensionManager.php @@ -0,0 +1,52 @@ +. + * + * 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; + +class ExtensionManager extends Base +{ + protected ?string $name = 'Extension'; + + /** @var array */ + protected array $params = [ + 'packagePath' => 'data/upload/extensions', + 'backupPath' => 'data/.backup/extensions', + + 'scriptNames' => [ + 'before' => 'BeforeInstall', + 'after' => 'AfterInstall', + 'beforeUninstall' => 'BeforeUninstall', + 'afterUninstall' => 'AfterUninstall', + ], + 'customDirNames' => [ + 'before' => 'beforeInstallFiles', + 'after' => 'afterInstallFiles', + ], + ]; +} diff --git a/application/Espo/Core/Upgrades/Migration/AfterUpgradeRunner.php b/application/Espo/Core/Upgrades/Migration/AfterUpgradeRunner.php new file mode 100644 index 0000000..fa4b2be --- /dev/null +++ b/application/Espo/Core/Upgrades/Migration/AfterUpgradeRunner.php @@ -0,0 +1,70 @@ +. + * + * 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\Migration; + +use Espo\Core\DataManager; +use Espo\Core\Exceptions\Error; +use Espo\Core\InjectableFactory; +use RuntimeException; + +class AfterUpgradeRunner +{ + public function __construct( + private InjectableFactory $injectableFactory, + private DataManager $dataManager + ) {} + + public function run(string $step): void + { + $dir = 'V' . str_replace('.', '_', $step); + + $className = "Espo\\Core\\Upgrades\\Migrations\\$dir\\AfterUpgrade"; + + if (!class_exists($className)) { + throw new RuntimeException("No after-upgrade script $step."); + } + + try { + $this->dataManager->rebuild(); + } catch (Error $e) { + throw new RuntimeException("Error while rebuild: " . $e->getMessage()); + } + + /** @var Script $script */ + $script = $this->injectableFactory->createWith($className, ['isUpgrade' => false]); + $script->run(); + + try { + $this->dataManager->rebuild(); + } catch (Error $e) { + throw new RuntimeException("Error while rebuild: " . $e->getMessage()); + } + } +} diff --git a/application/Espo/Core/Upgrades/Migration/ExtractedStepsProvider.php b/application/Espo/Core/Upgrades/Migration/ExtractedStepsProvider.php new file mode 100644 index 0000000..f8ad18d --- /dev/null +++ b/application/Espo/Core/Upgrades/Migration/ExtractedStepsProvider.php @@ -0,0 +1,53 @@ +. + * + * 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\Migration; + +class ExtractedStepsProvider +{ + public function __construct( + private StepsProvider $stepsProvider + ) {} + + /** + * @return string[] + */ + public function getAfterUpgrade(string $version, string $targetVersion): array + { + return VersionUtil::extractSteps($version, $targetVersion, $this->stepsProvider->getAfterUpgrade()); + } + + /** + * @return string[] + */ + public function getPrepare(string $version, string $targetVersion): array + { + return VersionUtil::extractSteps($version, $targetVersion, $this->stepsProvider->getPrepare()); + } +} diff --git a/application/Espo/Core/Upgrades/Migration/Runner.php b/application/Espo/Core/Upgrades/Migration/Runner.php new file mode 100644 index 0000000..0eed3ca --- /dev/null +++ b/application/Espo/Core/Upgrades/Migration/Runner.php @@ -0,0 +1,139 @@ +. + * + * 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\Migration; + +use Espo\Core\Console\IO; +use Espo\Core\DataManager; +use Espo\Core\Exceptions\Error; +use Espo\Core\Utils\Config\ConfigWriter; +use Exception; +use RuntimeException; + +class Runner +{ + public function __construct( + private ExtractedStepsProvider $stepsProvider, + private VersionDataProvider $versionDataProvider, + private StepRunner $stepRunner, + private DataManager $dataManager, + private ConfigWriter $configWriter + ) {} + + /** + * @throws Error + */ + public function run(IO $io): void + { + $this->dataManager->clearCache(); + + $version = $this->versionDataProvider->getPreviousVersion(); + $targetVersion = $this->versionDataProvider->getTargetVersion(); + + if ($version === $targetVersion) { + $io->writeLine("No migrations to run."); + + return; + } + + $prepareSteps = $this->stepsProvider->getPrepare($version, $targetVersion); + + if ($prepareSteps !== []) { + $io->writeLine("Running prepare migrations..."); + + foreach ($prepareSteps as $step) { + $this->runPrepareStep($io, $step); + } + } + + $afterSteps = $this->stepsProvider->getAfterUpgrade($version, $targetVersion); + + if ($afterSteps === []) { + $io->writeLine("No migrations to run. Updating version..."); + + $this->updateVersion($targetVersion); + $this->dataManager->updateAppTimestamp(); + $this->dataManager->rebuild(); + + $io->writeLine("Completed."); + + return; + } + + $io->writeLine("Running after-upgrade migrations..."); + + foreach ($afterSteps as $step) { + $this->runAfterUpgradeStep($io, $step); + $this->updateVersion(VersionUtil::stepToVersion($step)); + } + + $this->updateVersion($targetVersion); + $this->dataManager->updateAppTimestamp(); + + $io->writeLine("Completed."); + } + + private function runAfterUpgradeStep(IO $io, string $step): void + { + $io->write(" $step..."); + + $isSuccessful = $this->stepRunner->runAfterUpgrade($step); + + if ($isSuccessful) { + $io->writeLine(" DONE"); + + return; + } + + $io->writeLine(" FAIL"); + + throw new RuntimeException("Step process failed."); + } + + private function runPrepareStep(IO $io, string $step): void + { + $io->write(" $step..."); + + try { + $this->stepRunner->runPrepare($step); + } catch (Exception $e) { + $io->writeLine(" FAIL"); + + throw new RuntimeException($e->getMessage()); + } + + $io->writeLine(" DONE"); + } + + private function updateVersion(string $targetVersion): void + { + $this->configWriter->set('version', $targetVersion); + $this->configWriter->save(); + } +} diff --git a/application/Espo/Core/Upgrades/Migration/Script.php b/application/Espo/Core/Upgrades/Migration/Script.php new file mode 100644 index 0000000..71082eb --- /dev/null +++ b/application/Espo/Core/Upgrades/Migration/Script.php @@ -0,0 +1,35 @@ +. + * + * 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\Migration; + +interface Script +{ + public function run(): void; +} diff --git a/application/Espo/Core/Upgrades/Migration/StepRunner.php b/application/Espo/Core/Upgrades/Migration/StepRunner.php new file mode 100644 index 0000000..2d21d46 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migration/StepRunner.php @@ -0,0 +1,100 @@ +. + * + * 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\Migration; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Log; +use RuntimeException; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +class StepRunner +{ + public function __construct( + private Config $config, + private InjectableFactory $injectableFactory, + private Log $log + ) {} + + public function runAfterUpgrade(string $step): bool + { + $phpExecutablePath = $this->getPhpExecutablePath(); + + $command = "command.php"; + + $process = new Process([$phpExecutablePath, $command, 'migration-version-step', "--step=$step"]); + $process->setTimeout(null); + $process->run(); + + $this->processLogging($process); + + return $process->isSuccessful(); + } + + public function runPrepare(string $step): void + { + $dir = 'V' . str_replace('.', '_', $step); + + $className = "Espo\\Core\\Upgrades\\Migrations\\$dir\\Prepare"; + + if (!class_exists($className)) { + throw new RuntimeException("No prepare script $step."); + } + + /** @var Script $script */ + $script = $this->injectableFactory->create($className); + $script->run(); + } + + private function getPhpExecutablePath(): string + { + $phpExecutablePath = $this->config->get('phpExecutablePath'); + + if (!$phpExecutablePath) { + $phpExecutablePath = (new PhpExecutableFinder)->find(); + } + + return $phpExecutablePath; + } + + private function processLogging(Process $process): void + { + if ($process->isSuccessful()) { + return; + } + + $output = $process->getOutput(); + + if ($output) { + $this->log->error("Migration step command: " . $output); + } + } +} diff --git a/application/Espo/Core/Upgrades/Migration/StepsProvider.php b/application/Espo/Core/Upgrades/Migration/StepsProvider.php new file mode 100644 index 0000000..7b36b13 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migration/StepsProvider.php @@ -0,0 +1,79 @@ +. + * + * 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\Migration; + +use Espo\Core\Utils\File\Manager; +use const SORT_STRING; + +class StepsProvider +{ + private string $dir = 'application/Espo/Core/Upgrades/Migrations'; + + public function __construct( + private Manager $fileManager + ) {} + + /** + * @return string[] + */ + public function getPrepare(): array + { + return $this->get('Prepare'); + } + + /** + * @return string[] + */ + public function getAfterUpgrade(): array + { + return $this->get('AfterUpgrade'); + } + + /** + * @return string[] + */ + private function get(string $name): array + { + $list = $this->fileManager->getDirList($this->dir); + + $list = array_filter($list, function ($item) use ($name) { + $dir = $this->dir . '/' . $item; + + return $this->fileManager->isFile("$dir/$name.php"); + }); + + $list = array_values($list); + $list = array_map(fn ($item) => substr(str_replace('_', '.', $item), 1), $list); + + sort($list, SORT_STRING); + + return $list; + } +} diff --git a/application/Espo/Core/Upgrades/Migration/VersionDataProvider.php b/application/Espo/Core/Upgrades/Migration/VersionDataProvider.php new file mode 100644 index 0000000..2a5a88d --- /dev/null +++ b/application/Espo/Core/Upgrades/Migration/VersionDataProvider.php @@ -0,0 +1,72 @@ +. + * + * 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\Migration; + +use Espo\Core\Utils\Config; +use Espo\Core\Utils\File\Manager; +use RuntimeException; + +class VersionDataProvider +{ + private string $defaultConfigPath = 'application/Espo/Resources/defaults/config.php'; + + public function __construct( + private Manager $fileManager, + private Config\SystemConfig $systemConfig, + ) {} + + public function getPreviousVersion(): string + { + $version = $this->systemConfig->getVersion(); + + if (!$version) { + throw new RuntimeException("No or bad 'version' in config."); + } + + return $version; + } + + public function getTargetVersion(): string + { + $data = $this->fileManager->getPhpContents($this->defaultConfigPath); + + if (!is_array($data)) { + throw new RuntimeException("No default config."); + } + + $version = $data['version'] ?? null; + + if (!$version || !is_string($version)) { + throw new RuntimeException("No or bad 'version' parameter in default config."); + } + + return $version; + } +} diff --git a/application/Espo/Core/Upgrades/Migration/VersionUtil.php b/application/Espo/Core/Upgrades/Migration/VersionUtil.php new file mode 100644 index 0000000..6e81cba --- /dev/null +++ b/application/Espo/Core/Upgrades/Migration/VersionUtil.php @@ -0,0 +1,119 @@ +. + * + * 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\Migration; + +use RuntimeException; + +class VersionUtil +{ + /** + * @param string[] $fullList + * @return string[] + */ + public static function extractSteps(string $from, string $to, array $fullList): array + { + usort($fullList, fn ($v1, $v2) => version_compare($v1, $v2)); + + $isPatch = self::isPatch($from, $to); + + $list = []; + $nextMinorIsPassed = false; + + foreach ($fullList as $item) { + $a = self::split($item); + + if ($isPatch && $a[2] === null) { + continue; + } + + $v1 = $a[0] . '.' . $a[1] . '.' . ($a[2] ?? '0'); + + if (version_compare($v1, $from) <= 0) { + continue; + } + + if (version_compare($v1, $to) > 0) { + continue; + } + + $isItemPatch = $a[2] !== null; + + if (!$isPatch && $isItemPatch && $nextMinorIsPassed) { + continue; + } + + if (!$nextMinorIsPassed && !self::isPatch($from, $v1)) { + $nextMinorIsPassed = true; + } + + $list[] = $item; + } + + return $list; + } + + /** + * @return array{string, string, ?string} + */ + public static function split(string $version): array + { + $array = explode('.', $version, 3); + + if (count($array) === 2) { + return [$array[0], $array[1], null]; + } + + if (count($array) !== 3) { + throw new RuntimeException("Bad version number $version."); + } + + /** @var array{string, string, string} */ + return $array; + } + + public static function isPatch(string $from, string $to): bool + { + $aFrom = self::split($from); + $aTo = self::split($to); + + return $aFrom[0] === $aTo[0] && $aFrom[1] === $aTo[1]; + } + + public static function stepToVersion(string $step): string + { + $a = self::split($step); + + if ($a[2] === null) { + $a[2] = '0'; + } + + return implode('.', $a); + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V10_0/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V10_0/AfterUpgrade.php new file mode 100644 index 0000000..e47087f --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V10_0/AfterUpgrade.php @@ -0,0 +1,118 @@ +. + * + * 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\V10_0; + +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\ConfigWriter; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Preferences; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\CaseObj; +use Espo\ORM\EntityManager; + +class AfterUpgrade implements Script +{ + public function __construct( + private EntityManager $entityManager, + private ConfigWriter $configWriter, + private Metadata $metadata, + private Config $config, + ) {} + + public function run(): void + { + $this->updatePreferences(); + $this->updateConfig(); + $this->updateMetadata(); + } + + 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); + } + } + + private function updateConfig(): void + { + $this->configWriter->set('currencyNoJoinMode', true); + + if ($this->config->get('adminUpgradeDisabled')) { + $this->configWriter->set('adminExtensionUpload', false); + } else { + $this->configWriter->set('adminExtensionUpload', true); + } + + $this->configWriter->save(); + } + + private function updateMetadata(): void + { + /** @var array> $clientDefs */ + $clientDefs = $this->metadata->get('clientDefs') ?? []; + + foreach ($clientDefs as $name => $defs) { + if ($name === CaseObj::ENTITY_TYPE) { + continue; + } + + if ($defs['allowInternalNotes'] ?? false) { + $this->metadata->set('streamDefs', $name, [ + 'allowInternalNotes' => true, + ]); + + $this->metadata->delete('clientDefs', $name, ['allowInternalNotes']); + } + } + + $this->metadata->save(); + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V10_0/Prepare.php b/application/Espo/Core/Upgrades/Migrations/V10_0/Prepare.php new file mode 100644 index 0000000..2dc26c6 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V10_0/Prepare.php @@ -0,0 +1,94 @@ +. + * + * 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\V10_0; + +use Espo\Core\Upgrades\Migration\Script; +use Espo\Entities\Extension; +use Espo\ORM\EntityManager; +use RuntimeException; + +class Prepare implements Script +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function run(): void + { + $this->processCheckExtensions(); + } + + private function processCheckExtensions(): void + { + $errorMessageList = []; + + $this->processCheckExtension('Advanced Pack', '3.13.0', $errorMessageList); + $this->processCheckExtension('Real Estate', '1.8.5', $errorMessageList); + $this->processCheckExtension('VoIP Integration', '2.8.0', $errorMessageList); + + if (!count($errorMessageList)) { + return; + } + + $message = implode("\n\n", $errorMessageList); + + throw new RuntimeException($message); + } + + /** + * @param string[] $errorMessageList + */ + private function processCheckExtension(string $name, string $minVersion, array &$errorMessageList): void + { + $extension = $this->entityManager + ->getRDBRepositoryByClass(Extension::class) + ->where([ + 'name' => $name, + 'isInstalled' => true, + ]) + ->findOne(); + + if (!$extension) { + return; + } + + $version = $extension->getVersion(); + + if (version_compare($version, $minVersion, '>=')) { + return; + } + + $message = + "EspoCRM 10.0 is not compatible with '$name' extension of versions lower than $minVersion. " . + "You need to upgrade the extension."; + + $errorMessageList[] = $message; + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php new file mode 100644 index 0000000..5c889f0 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V10_1/AfterUpgrade.php @@ -0,0 +1,147 @@ +. + * + * 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\V10_1; + +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Config\ConfigWriter; +use Espo\Core\Utils\Theme\Direction; +use Espo\Entities\LeadCapture; +use Espo\Entities\Portal; +use Espo\Entities\Preferences; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +class AfterUpgrade implements Script +{ + private const LEGACY_THEME = 'EspoRtl'; + private const THEME = 'Espo'; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + private ConfigWriter $configWriter, + ) {} + + public function run(): void + { + $this->updateConfig(); + $this->updatePreferences(); + $this->updatePortals(); + $this->updateLeadCaptures(); + } + + private function updateConfig(): void + { + if ($this->config->get('theme') !== self::LEGACY_THEME) { + return; + } + + $this->configWriter->set('theme', self::THEME); + $this->configWriter->set('themeParams', $this->prepareThemeParams($this->config->get('themeParams'))); + $this->configWriter->save(); + } + + private function updatePreferences(): void + { + $users = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + 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 || $preferences->get('theme') !== self::LEGACY_THEME) { + continue; + } + + $this->updateThemeEntity($preferences); + } + } + + private function updatePortals(): void + { + $portals = $this->entityManager + ->getRDBRepositoryByClass(Portal::class) + ->where(['theme' => self::LEGACY_THEME]) + ->find(); + + foreach ($portals as $portal) { + $this->updateThemeEntity($portal); + } + } + + private function updateLeadCaptures(): void + { + $leadCaptures = $this->entityManager + ->getRDBRepositoryByClass(LeadCapture::class) + ->where(['formTheme' => self::LEGACY_THEME]) + ->find(); + + foreach ($leadCaptures as $leadCapture) { + $leadCapture->set('formTheme', self::THEME); + $this->entityManager->saveEntity($leadCapture); + } + } + + private function updateThemeEntity(Entity $entity): void + { + $entity->set('theme', self::THEME); + $entity->set('themeParams', $this->prepareThemeParams($entity->get('themeParams'))); + + $this->entityManager->saveEntity($entity); + } + + private function prepareThemeParams(mixed $themeParams): object + { + if (is_object($themeParams)) { + $themeParams = get_object_vars($themeParams); + } + + if (!is_array($themeParams)) { + $themeParams = []; + } + + $themeParams['navbar'] ??= 'top'; + $themeParams['direction'] = Direction::Rtl->value; + + return (object) $themeParams; + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V7_2/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V7_2/AfterUpgrade.php new file mode 100644 index 0000000..61f2887 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V7_2/AfterUpgrade.php @@ -0,0 +1,193 @@ +. + * + * 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\V7_2; + +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Portal; +use Espo\Entities\Preferences; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\KnowledgeBaseArticle; +use Espo\ORM\EntityManager; +use Espo\Core\Utils\Config; + +class AfterUpgrade implements Script +{ + public function __construct( + private Metadata $metadata, + private EntityManager $entityManager, + private Config $config, + private Config\ConfigWriter $configWriter + ) {} + + public function run(): void + { + $this->updateEventMetadata(); + $this->updateTheme(); + $this->updateKbArticles(); + } + + private function updateEventMetadata(): void + { + $metadata = $this->metadata; + + $defs = $metadata->get(['scopes']); + + $toSave = false; + + foreach ($defs as $entityType => $item) { + $isCustom = $item['isCustom'] ?? false; + $type = $item['type'] ?? false; + + if (!$isCustom || $type !== 'Event') { + continue; + } + + $toSave = true; + + $metadata->set('recordDefs', $entityType, [ + 'beforeUpdateHookClassNameList' => [ + "__APPEND__", + "Espo\\Classes\\RecordHooks\\Event\\BeforeUpdatePreserveDuration" + ] + ]); + + $metadata->set('clientDefs', $entityType, [ + 'forcePatchAttributeDependencyMap' => [ + "dateEnd" => ["dateStart"], + "dateEndDate" => ["dateStartDate"] + ] + ]); + + if ($metadata->get(['entityDefs', $entityType, 'fields', 'isAllDay'])) { + $metadata->set('entityDefs', $entityType, [ + 'fields' => [ + 'isAllDay' => [ + 'readOnly' => false, + ], + ] + ]); + } + } + + if ($toSave) { + $metadata->save(); + } + } + + private function updateTheme(): void + { + $themeList = [ + 'EspoVertical', + 'HazyblueVertical', + 'VioletVertical', + 'SakuraVertical', + 'DarkVertical', + ]; + + $theme = $this->config->get('theme'); + $navbar = 'top'; + + if (in_array($theme, $themeList)) { + $theme = substr($theme, 0, -8); + $navbar = 'side'; + } + + $this->configWriter->set('theme', $theme); + $this->configWriter->set('themeParams', (object) ['navbar' => $navbar]); + $this->configWriter->save(); + + $userList = $this->entityManager->getRDBRepository(User::ENTITY_TYPE) + ->where([ + 'type' => ['regular', 'admin'] + ]) + ->find(); + + foreach ($userList as $user) { + $preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $user->getId()); + + if (!$preferences) { + continue; + } + + $theme = $preferences->get('theme'); + $navbar = 'top'; + + if (!$theme) { + continue; + } + + if (in_array($theme, $themeList)) { + $theme = substr($theme, 0, -8); + $navbar = 'side'; + } + + $preferences->set('theme', $theme); + $preferences->set('themeParams', (object) ['navbar' => $navbar]); + + $this->entityManager->saveEntity($preferences); + } + + $portalList = $this->entityManager + ->getRDBRepository(Portal::ENTITY_TYPE) + ->where([ + 'theme!=' => null, + ]) + ->find(); + + foreach ($portalList as $portal) { + $theme = $portal->get('theme'); + $navbar = 'top'; + + if (in_array($theme, $themeList)) { + $theme = substr($theme, 0, -8); + $navbar = 'side'; + } + + $portal->set('theme', $theme); + $portal->set('themeParams', (object) ['navbar' => $navbar]); + + $this->entityManager->saveEntity($portal); + } + } + + private function updateKbArticles(): void + { + $query = $this->entityManager + ->getQueryBuilder() + ->update() + ->in(KnowledgeBaseArticle::ENTITY_TYPE) + ->where(['type' => null]) + ->set(['type' => 'Article']) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V7_4/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V7_4/AfterUpgrade.php new file mode 100644 index 0000000..74c6d49 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V7_4/AfterUpgrade.php @@ -0,0 +1,118 @@ +. + * + * 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\V7_4; + +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\Utils\Metadata; +use Espo\Core\Templates\Entities\Event; + +class AfterUpgrade implements Script +{ + public function __construct( + private Metadata $metadata, + ) {} + + public function run(): void + { + $this->updateMetadata(); + } + + private function updateMetadata(): void + { + $this->metadata->set('app', 'recordId', [ + 'length' => 24, + ]); + + $this->fixParent(); + $this->updateEventMetadata(); + + $this->metadata->save(); + } + + private function fixParent(): void + { + $metadata = $this->metadata; + + foreach ($metadata->get(['entityDefs']) as $scope => $defs) { + foreach ($metadata->get(['entityDefs', $scope, 'fields']) as $field => $fieldDefs) { + $custom = $metadata->getCustom('entityDefs', $scope); + + if (!$custom) { + continue; + } + + if ( + ($fieldDefs['type'] ?? null) === FieldType::LINK_PARENT && + ($fieldDefs['notStorable'] ?? false) + ) { + if ($custom->fields?->$field?->notStorable) { + $metadata->delete('entityDefs', $scope, "fields.$field.notStorable"); + } + } + } + } + } + + private function updateEventMetadata(): void + { + $metadata = $this->metadata; + + $defs = $metadata->get(['scopes']); + + foreach ($defs as $entityType => $item) { + $isCustom = $item['isCustom'] ?? false; + $type = $item['type'] ?? false; + + if (!$isCustom || $type !== Event::TEMPLATE_TYPE) { + continue; + } + + if (!is_string($metadata->get(['entityDefs', $entityType, 'fields', 'duration', 'select']))) { + continue; + } + + $metadata->delete('entityDefs', $entityType, 'fields.duration.orderBy'); + + $metadata->set('entityDefs', $entityType, [ + 'fields' => [ + 'duration' => [ + 'select' => [ + 'select' => "TIMESTAMPDIFF_SECOND:(dateStart, dateEnd)" + ], + 'order' => [ + 'order' => [["TIMESTAMPDIFF_SECOND:(dateStart, dateEnd)", "{direction}"]] + ], + ] + ] + ]); + } + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V7_5/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V7_5/AfterUpgrade.php new file mode 100644 index 0000000..7248edd --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V7_5/AfterUpgrade.php @@ -0,0 +1,83 @@ +. + * + * 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\V7_5; + +use Espo\Core\Templates\Entities\Event; +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\Utils\File\Manager; +use Espo\Core\Utils\Json; +use Espo\Core\Utils\Metadata; + +class AfterUpgrade implements Script +{ + public function __construct( + private Metadata $metadata, + private Manager $fileManger + ) {} + + public function run(): void + { + $this->updateEventMetadata(); + } + + private function updateEventMetadata(): void + { + $metadata = $this->metadata; + $fileManager = $this->fileManger; + + $defs = $metadata->get(['scopes']); + + $path1 = "application/Espo/Core/Templates/Metadata/Event/selectDefs.json"; + $contents1 = $fileManager->getContents($path1); + $data1 = Json::decode($contents1, true); + + $primaryFilterClassNameMap = (object) $data1['primaryFilterClassNameMap']; + + foreach ($defs as $entityType => $item) { + $isCustom = $item['isCustom'] ?? false; + $type = $item['type'] ?? false; + + if (!$isCustom || $type !== Event::TEMPLATE_TYPE) { + continue; + } + + $data1 = $metadata->getCustom('selectDefs', $entityType) ?? (object) []; + $data1->primaryFilterClassNameMap = $primaryFilterClassNameMap; + + $metadata->saveCustom('selectDefs', $entityType, $data1); + + $data2 = $metadata->getCustom('scopes', $entityType) ?? (object) []; + $data2->completedStatusList = ['Held']; + $data2->canceledStatusList = ['Not Held']; + + $metadata->saveCustom('scopes', $entityType, $data2); + } + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V7_5/Prepare.php b/application/Espo/Core/Upgrades/Migrations/V7_5/Prepare.php new file mode 100644 index 0000000..d1760e9 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V7_5/Prepare.php @@ -0,0 +1,53 @@ +. + * + * 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\V7_5; + +use Espo\Core\Upgrades\Migration\Script; +use Espo\Entities\User; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Query\DeleteBuilder; + +class Prepare implements Script +{ + public function __construct( + private EntityManager $entityManager + ) {} + + public function run(): void + { + $query = DeleteBuilder::create() + ->from(User::ENTITY_TYPE) + ->where([Attribute::DELETED => true]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($query); + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V8_0/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V8_0/AfterUpgrade.php new file mode 100644 index 0000000..cd1886a --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V8_0/AfterUpgrade.php @@ -0,0 +1,120 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Upgrades\Migrations\V8_0; + +use Espo\Core\Container; +use Espo\Core\Name\Field; +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Role; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\UpdateBuilder; + +class AfterUpgrade implements Script +{ + public function __construct( + private Container $container + ) {} + + public function run(): void + { + $this->updateRoles( + $this->container->getByClass(EntityManager::class) + ); + + $this->updateMetadata( + $this->container->getByClass(Metadata::class) + ); + } + + private function updateRoles(EntityManager $entityManager): void + { + $query = UpdateBuilder::create() + ->in(Role::ENTITY_TYPE) + ->set(['messagePermission' => Expression::column('assignmentPermission')]) + ->build(); + + $entityManager->getQueryExecutor()->execute($query); + } + + private function updateMetadata(Metadata $metadata): void + { + $defs = $metadata->get(['scopes']); + + foreach ($defs as $entityType => $item) { + $isCustom = $item['isCustom'] ?? false; + $type = $item['type'] ?? false; + + if (!$isCustom) { + continue; + } + + if ($type === 'Event') { + $metadata->set('entityDefs', $entityType, [ + 'fields' => [ + 'dateEnd' => [ + 'suppressValidationList' => ['required'], + ], + ] + ]); + + $metadata->save(); + + continue; + } + + if ( + !in_array($type, [ + 'BasePlus', + 'Base', + 'Company', + 'Person', + ]) + ) { + continue; + } + + $recordDefs = $metadata->getCustom('recordDefs', $entityType) ?? (object) []; + $scopes = $metadata->getCustom('scopes', $entityType) ?? (object) []; + + $recordDefs->duplicateWhereBuilderClassName = "Espo\\Classes\\DuplicateWhereBuilders\\General"; + + $scopes->duplicateCheckFieldList = []; + + if ($type === 'Company' || $type === 'Person') { + $scopes->duplicateCheckFieldList = [Field::NAME, 'emailAddress']; + } + + $metadata->saveCustom('recordDefs', $entityType, $recordDefs); + $metadata->saveCustom('scopes', $entityType, $scopes); + } + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V8_1/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V8_1/AfterUpgrade.php new file mode 100644 index 0000000..53907a5 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V8_1/AfterUpgrade.php @@ -0,0 +1,61 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Upgrades\Migrations\V8_1; + +use Espo\Core\Container; +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; + +class AfterUpgrade implements Script +{ + public function __construct( + private Container $container + ) {} + + public function run(): void + { + $config = $this->container->getByClass(Config::class); + + $configWriter = $this->container->getByClass(InjectableFactory::class) + ->create(Config\ConfigWriter::class); + + $configWriter->setMultiple([ + 'phoneNumberNumericSearch' => false, + 'phoneNumberInternational' => false, + ]); + + if ($config->get('pdfEngine') === 'Tcpdf') { + $configWriter->set('pdfEngine', 'Dompdf'); + } + + $configWriter->save(); + } +} diff --git a/application/Espo/Core/Upgrades/Migrations/V8_2/AfterUpgrade.php b/application/Espo/Core/Upgrades/Migrations/V8_2/AfterUpgrade.php new file mode 100644 index 0000000..cfa3b99 --- /dev/null +++ b/application/Espo/Core/Upgrades/Migrations/V8_2/AfterUpgrade.php @@ -0,0 +1,125 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Upgrades\Migrations\V8_2; + +use Espo\Core\Upgrades\Migration\Script; +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Metadata; +use Espo\Entities\Template; +use Espo\ORM\EntityManager; +use Espo\Tools\Pdf\Template as PdfTemplate; + +class AfterUpgrade implements Script +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Config $config, + private EntityManager $entityManager, + private Metadata $metadata + ) {} + + public function run(): void + { + $configWriter = $this->injectableFactory->create(Config\ConfigWriter::class); + + $configWriter->setMultiple([ + 'jobForceUtc' => true, + ]); + + $configWriter->save(); + + $em = $this->entityManager; + $config = $this->config; + + $this->updateTemplates($em, $config); + $this->updateTargetList($this->metadata); + } + + private function updateTemplates(EntityManager $entityManager, Config $config): void + { + if ($config->get('pdfEngine') !== 'Dompdf') { + return; + } + + /** @var iterable