. * * The interactive user interfaces in modified source and object code versions * of this program must display Appropriate Legal Notices, as required under * Section 5 of the GNU Affero General Public License version 3. * * In accordance with Section 7(b) 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); } }