Initial commit

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

View File

@@ -0,0 +1,796 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\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<Notification> $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);
}
}