diff --git a/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php b/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php
new file mode 100644
index 00000000000..27e8a874386
--- /dev/null
+++ b/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php
@@ -0,0 +1,301 @@
+modx->eventMap = [];
+ $this->removeTestResources();
+ }
+
+ /**
+ * @after
+ */
+ public function tearDownFixtures()
+ {
+ $this->removeTestResources();
+ parent::tearDownFixtures();
+ }
+
+ public function testTrashRestoreChildAlsoRestoresDeletedParent()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Restore Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Restore Child', $parent->get('id'));
+
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $parent->get('id'),
+ 'syncsite' => 0,
+ ]);
+ $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed');
+
+ $this->modx->error->reset();
+ $restore = $this->modx->runProcessor(Restore::class, [
+ 'ids' => (string)$child->get('id'),
+ ]);
+ $this->assertTrue($this->checkForSuccess($restore), $restore ? $restore->getMessage() : 'restore failed');
+
+ $parent = $this->modx->getObject(modResource::class, $parent->get('id'));
+ $child = $this->modx->getObject(modResource::class, $child->get('id'));
+ $this->assertSame(0, (int)$parent->get('deleted'), 'deleted parent must be restored with the child');
+ $this->assertSame(0, (int)$child->get('deleted'));
+ }
+
+ public function testPurgeParentMessageIncludesChildren()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Purge Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Purge Child', $parent->get('id'));
+ $parentId = $parent->get('id');
+ $childId = $child->get('id');
+
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $parentId,
+ 'syncsite' => 0,
+ ]);
+ $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed');
+
+ $this->modx->error->reset();
+ $purge = $this->modx->runProcessor(Purge::class, [
+ 'ids' => (string)$parentId,
+ ]);
+ $this->assertTrue($this->checkForSuccess($purge), $purge ? $purge->getMessage() : 'purge failed');
+ $object = $purge->getObject();
+ $this->assertGreaterThanOrEqual(2, (int)$object['count_success']);
+ $this->assertEmpty($this->modx->getObject(modResource::class, $parentId));
+ $this->assertEmpty($this->modx->getObject(modResource::class, $childId));
+ }
+
+ public function testDeleteParentContainingSiteStartIsBlocked()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Protected Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Protected Child', $parent->get('id'));
+ $previous = $this->modx->getOption('site_start');
+ $this->modx->setOption('site_start', $child->get('id'));
+
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $parent->get('id'),
+ 'syncsite' => 0,
+ ]);
+ $this->modx->setOption('site_start', $previous);
+
+ $this->assertFalse($this->checkForSuccess($delete), 'Deleting a container of site_start must fail');
+ $parent = $this->modx->getObject(modResource::class, $parent->get('id'));
+ $child = $this->modx->getObject(modResource::class, $child->get('id'));
+ $this->assertSame(0, (int)$parent->get('deleted'));
+ $this->assertSame(0, (int)$child->get('deleted'));
+ }
+
+ public function testCannotCreateUnderDeletedParent()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Deleted Parent');
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $parent->get('id'),
+ 'syncsite' => 0,
+ ]);
+ $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed');
+
+ $this->modx->error->reset();
+ $create = $this->modx->runProcessor(Create::class, [
+ 'pagetitle' => self::TITLE_PREFIX . ' Under Deleted',
+ 'alias' => 'unit-test-14167-under-deleted',
+ 'parent' => $parent->get('id'),
+ 'template' => 0,
+ 'published' => false,
+ 'context_key' => 'web',
+ 'class_key' => modDocument::class,
+ ]);
+ $this->assertFalse($this->checkForSuccess($create), 'Create under a deleted parent must fail');
+ }
+
+ public function testCannotDeleteSiteStartResource()
+ {
+ $resource = $this->createTestResource(self::TITLE_PREFIX . ' Site Start');
+ $previous = $this->modx->getOption('site_start');
+ $this->modx->setOption('site_start', $resource->get('id'));
+
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $resource->get('id'),
+ 'syncsite' => 0,
+ ]);
+ $this->modx->setOption('site_start', $previous);
+
+ $this->assertFalse($this->checkForSuccess($delete), 'Deleting site_start itself must fail');
+ $resource = $this->modx->getObject(modResource::class, $resource->get('id'));
+ $this->assertSame(0, (int)$resource->get('deleted'));
+ }
+
+ public function testDeleteParentContainingErrorPageIsBlocked()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Error Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Error Child', $parent->get('id'));
+ $previous = $this->modx->getOption('error_page');
+ $this->modx->setOption('error_page', $child->get('id'));
+
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $parent->get('id'),
+ 'syncsite' => 0,
+ ]);
+ $this->modx->setOption('error_page', $previous);
+
+ $this->assertFalse($this->checkForSuccess($delete), 'Deleting a container of error_page must fail');
+ $parent = $this->modx->getObject(modResource::class, $parent->get('id'));
+ $this->assertSame(0, (int)$parent->get('deleted'));
+ }
+
+ public function testPurgeWithParentAndChildIdsCountsEachOnce()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Dual Purge Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Dual Purge Child', $parent->get('id'));
+ $parentId = $parent->get('id');
+ $childId = $child->get('id');
+
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $parentId,
+ 'syncsite' => 0,
+ ]);
+ $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed');
+
+ $this->modx->error->reset();
+ $purge = $this->modx->runProcessor(Purge::class, [
+ 'ids' => $parentId . ',' . $childId,
+ ]);
+ $this->assertTrue($this->checkForSuccess($purge), $purge ? $purge->getMessage() : 'purge failed');
+ $object = $purge->getObject();
+ $this->assertGreaterThanOrEqual(2, (int)$object['count_success']);
+ $this->assertEmpty($this->modx->getObject(modResource::class, $parentId));
+ $this->assertEmpty($this->modx->getObject(modResource::class, $childId));
+ }
+
+ public function testUndeleteProcessorRestoresDeletedParent()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Undelete Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Undelete Child', $parent->get('id'));
+
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $parent->get('id'),
+ 'syncsite' => 0,
+ ]);
+ $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed');
+
+ $this->modx->error->reset();
+ $undelete = $this->modx->runProcessor(Undelete::class, [
+ 'id' => $child->get('id'),
+ 'syncsite' => 0,
+ ]);
+ $this->assertTrue($this->checkForSuccess($undelete), $undelete ? $undelete->getMessage() : 'undelete failed');
+
+ $parent = $this->modx->getObject(modResource::class, $parent->get('id'));
+ $child = $this->modx->getObject(modResource::class, $child->get('id'));
+ $this->assertSame(0, (int)$parent->get('deleted'));
+ $this->assertSame(0, (int)$child->get('deleted'));
+ }
+
+ public function testRestoreChildWithMissingParentStillSucceeds()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Ghost Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Ghost Child', $parent->get('id'));
+
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $parent->get('id'),
+ 'syncsite' => 0,
+ ]);
+ $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed');
+
+ $child = $this->modx->getObject(modResource::class, $child->get('id'));
+ $child->set('parent', 2147483646);
+ $child->save();
+
+ $this->modx->error->reset();
+ $restore = $this->modx->runProcessor(Restore::class, [
+ 'ids' => (string)$child->get('id'),
+ ]);
+ $this->assertTrue($this->checkForSuccess($restore), $restore ? $restore->getMessage() : 'restore failed');
+
+ $child = $this->modx->getObject(modResource::class, $child->get('id'));
+ $this->assertSame(0, (int)$child->get('deleted'));
+ }
+
+ private function createTestResource(string $pagetitle, int $parent = 0): modResource
+ {
+ $this->modx->error->reset();
+ $result = $this->modx->runProcessor(Create::class, [
+ 'pagetitle' => $pagetitle,
+ 'alias' => strtolower(str_replace(' ', '-', $pagetitle)),
+ 'parent' => $parent,
+ 'template' => 0,
+ 'published' => false,
+ 'context_key' => 'web',
+ 'class_key' => modDocument::class,
+ ]);
+ $this->assertTrue(
+ $this->checkForSuccess($result),
+ 'Could not create ' . $pagetitle . ': ' . ($result ? $result->getMessage() : 'no response')
+ );
+
+ $resource = $this->modx->getObject(modResource::class, ['pagetitle' => $pagetitle]);
+ $this->assertNotEmpty($resource, 'Created resource not found: ' . $pagetitle);
+
+ return $resource;
+ }
+
+ private function removeTestResources(): void
+ {
+ if (!($this->modx instanceof modX)) {
+ return;
+ }
+ $resources = $this->modx->getCollection(modResource::class, [
+ 'pagetitle:LIKE' => '%' . self::TITLE_PREFIX . '%',
+ ]);
+ foreach ($resources as $resource) {
+ $resource->remove();
+ }
+ }
+}
diff --git a/_build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php b/_build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php
new file mode 100644
index 00000000000..5af7a5eef06
--- /dev/null
+++ b/_build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php
@@ -0,0 +1,231 @@
+modx->eventMap = [];
+ $this->removeTestResources();
+ }
+
+ /**
+ * @after
+ */
+ public function tearDownFixtures()
+ {
+ $this->removeTestResources();
+ parent::tearDownFixtures();
+ }
+
+ public function testFormDeleteMarksChildrenDeleted()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Child', $parent->get('id'));
+ $grandchild = $this->createTestResource(self::TITLE_PREFIX . ' Grandchild', $child->get('id'));
+
+ $this->saveDeletedCheckbox($parent, 1);
+
+ foreach ([$parent, $child, $grandchild] as $created) {
+ $resource = $this->modx->getObject(modResource::class, $created->get('id'));
+ $this->assertNotEmpty($resource, 'Resource ' . $created->get('pagetitle') . ' missing after form delete');
+ $this->assertSame(
+ 1,
+ (int)$resource->get('deleted'),
+ $resource->get('pagetitle') . ' should be marked deleted'
+ );
+ $this->assertGreaterThan(
+ 0,
+ (int)$resource->get('deletedon'),
+ $resource->get('pagetitle') . ' should have deletedon set'
+ );
+ $this->assertGreaterThan(
+ 0,
+ (int)$resource->get('deletedby'),
+ $resource->get('pagetitle') . ' should record deletedby'
+ );
+ }
+ }
+
+ public function testFormUndeleteRestoresChildren()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Restore Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Restore Child', $parent->get('id'));
+
+ $this->saveDeletedCheckbox($parent, 1);
+
+ $parent = $this->modx->getObject(modResource::class, $parent->get('id'));
+ $child = $this->modx->getObject(modResource::class, $child->get('id'));
+ $this->assertNotEmpty($parent);
+ $this->assertNotEmpty($child);
+ $this->assertSame(1, (int)$parent->get('deleted'));
+ $this->assertSame(1, (int)$child->get('deleted'));
+
+ $this->saveDeletedCheckbox($parent, 0);
+
+ $parent = $this->modx->getObject(modResource::class, $parent->get('id'));
+ $child = $this->modx->getObject(modResource::class, $child->get('id'));
+ $this->assertSame(0, (int)$parent->get('deleted'));
+ $this->assertSame(0, (int)$child->get('deleted'));
+ $this->assertSame(0, (int)$parent->get('deletedon'));
+ $this->assertSame(0, (int)$child->get('deletedon'));
+ $this->assertSame(0, (int)$parent->get('deletedby'));
+ $this->assertSame(0, (int)$child->get('deletedby'));
+ }
+
+ public function testFormSaveWithoutChangingDeletedLeavesResourceLive()
+ {
+ $resource = $this->createTestResource(self::TITLE_PREFIX . ' Unchanged');
+ $this->modx->error->reset();
+ $fields = $resource->toArray();
+ $fields['deleted'] = 0;
+ $fields['syncsite'] = 0;
+ $fields['pagetitle'] = self::TITLE_PREFIX . ' Unchanged Saved';
+ $result = $this->modx->runProcessor(Update::class, $fields);
+ $this->assertTrue($this->checkForSuccess($result), $result ? $result->getMessage() : 'no response');
+
+ $resource = $this->modx->getObject(modResource::class, $resource->get('id'));
+ $this->assertSame(0, (int)$resource->get('deleted'));
+ }
+
+ public function testCannotMoveResourceUnderDeletedParent()
+ {
+ $deletedParent = $this->createTestResource(self::TITLE_PREFIX . ' Deleted Target');
+ $moving = $this->createTestResource(self::TITLE_PREFIX . ' Moving');
+
+ $this->modx->error->reset();
+ $delete = $this->modx->runProcessor(Delete::class, [
+ 'id' => $deletedParent->get('id'),
+ 'syncsite' => 0,
+ ]);
+ $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed');
+
+ $this->modx->error->reset();
+ $fields = $moving->toArray();
+ $fields['parent'] = $deletedParent->get('id');
+ $fields['syncsite'] = 0;
+ $result = $this->modx->runProcessor(Update::class, $fields);
+ $this->assertFalse($this->checkForSuccess($result), 'Move under a deleted parent must fail');
+ }
+
+ public function testFormDeleteFailsWhenChildIsSiteStart()
+ {
+ $parent = $this->createTestResource(self::TITLE_PREFIX . ' Protected Parent');
+ $child = $this->createTestResource(self::TITLE_PREFIX . ' Protected Child', $parent->get('id'));
+ $previous = $this->modx->getOption('site_start');
+ $this->modx->setOption('site_start', $child->get('id'));
+
+ $this->modx->error->reset();
+ $fields = $parent->toArray();
+ $fields['deleted'] = 1;
+ $fields['syncsite'] = 0;
+ $result = $this->modx->runProcessor(Update::class, $fields);
+ $this->modx->setOption('site_start', $previous);
+
+ $this->assertFalse($this->checkForSuccess($result), 'Form delete of a site_start container must fail');
+ $parent = $this->modx->getObject(modResource::class, $parent->get('id'));
+ $child = $this->modx->getObject(modResource::class, $child->get('id'));
+ $this->assertSame(0, (int)$parent->get('deleted'));
+ $this->assertSame(0, (int)$child->get('deleted'));
+ }
+
+ public function testFormDeleteMarksWebLinkDeleted()
+ {
+ $weblink = $this->createTestResource(self::TITLE_PREFIX . ' WebLink', 0, [
+ 'class_key' => modWebLink::class,
+ 'content' => 'https://example.com',
+ ]);
+
+ $this->saveDeletedCheckbox($weblink, 1);
+
+ $weblink = $this->modx->getObject(modResource::class, $weblink->get('id'));
+ $this->assertSame(1, (int)$weblink->get('deleted'));
+ $this->assertGreaterThan(0, (int)$weblink->get('deletedon'));
+ }
+
+ private function saveDeletedCheckbox(modResource $resource, int $deleted): void
+ {
+ $this->modx->error->reset();
+ $fields = $resource->toArray();
+ $fields['deleted'] = $deleted;
+ $fields['syncsite'] = 0;
+ $result = $this->modx->runProcessor(Update::class, $fields);
+ $this->assertTrue(
+ $this->checkForSuccess($result),
+ $result ? $result->getMessage() : 'no response'
+ );
+ }
+
+ private function createTestResource(string $pagetitle, int $parent = 0, array $extra = []): modResource
+ {
+ $this->modx->error->reset();
+ $result = $this->modx->runProcessor(Create::class, array_merge([
+ 'pagetitle' => $pagetitle,
+ 'alias' => strtolower(str_replace(' ', '-', $pagetitle)),
+ 'parent' => $parent,
+ 'template' => 0,
+ 'published' => false,
+ 'context_key' => 'web',
+ 'class_key' => modDocument::class,
+ ], $extra));
+ $this->assertTrue(
+ $this->checkForSuccess($result),
+ 'Could not create ' . $pagetitle . ': ' . ($result ? $result->getMessage() : 'no response')
+ );
+
+ $resource = $this->modx->getObject(modResource::class, ['pagetitle' => $pagetitle]);
+ $this->assertNotEmpty($resource, 'Created resource not found: ' . $pagetitle);
+
+ return $resource;
+ }
+
+ private function removeTestResources(): void
+ {
+ if (!($this->modx instanceof modX)) {
+ return;
+ }
+ $resources = $this->modx->getCollection(modResource::class, [
+ 'pagetitle:LIKE' => '%' . self::TITLE_PREFIX . '%',
+ ]);
+ foreach ($resources as $resource) {
+ $resource->remove();
+ }
+ }
+}
diff --git a/_build/test/phpunit.xml b/_build/test/phpunit.xml
index 6eba756c6c6..50ea15452ba 100644
--- a/_build/test/phpunit.xml
+++ b/_build/test/phpunit.xml
@@ -46,6 +46,8 @@
Tests/Processors/Context
Tests/Processors/Element
Tests/Processors/Resource
+ Tests/Processors/ResourceUpdateDeleteCascadeTest.php
+ Tests/Processors/ResourceTrashHierarchyTest.php
Tests/Transport
diff --git a/core/lexicon/en/resource.inc.php b/core/lexicon/en/resource.inc.php
index 23744a613ab..e8aaa90ead8 100644
--- a/core/lexicon/en/resource.inc.php
+++ b/core/lexicon/en/resource.inc.php
@@ -59,6 +59,8 @@
$_lang['resource_err_delete'] = 'An error occurred while trying to delete the resource.';
$_lang['resource_err_delete_children'] = 'An error occurred while trying to delete the children of the resource.';
$_lang['resource_err_delete_container_sitestart'] = 'The resource you are trying to delete is a container containing resource [[+id]]. This resource is registered as the \'Site start\' resource, and cannot be deleted. Please assign another resource as your \'Site start\' resource and try again.';
+$_lang['resource_err_delete_container_errorpage'] = 'The resource you are trying to delete is a container containing resource [[+id]]. That resource is used as the Error page and cannot be deleted. Assign another Error page and try again.';
+$_lang['resource_err_delete_container_siteunavailable'] = 'The resource you are trying to delete is a container containing resource [[+id]]. That resource is used as the Site unavailable page and cannot be deleted. Assign another Site unavailable page and try again.';
$_lang['resource_err_delete_sitestart'] = 'The resource is \'Site start\' and cannot be deleted!';
$_lang['resource_err_delete_errorpage'] = 'The resource is used as the \'Error page\' and cannot be deleted!';
$_lang['resource_err_delete_siteunavailable'] = 'The resource is used as the \'Site unavailable page\' and cannot be deleted!';
@@ -69,6 +71,7 @@
$_lang['resource_err_nfs'] = 'Resource with ID [[+id]] not found';
$_lang['resource_err_ns'] = 'Resource not specified.';
$_lang['resource_err_own_parent'] = 'The resource cannot be its own parent.';
+$_lang['resource_err_parent_deleted'] = 'You cannot create or move a resource under a deleted parent.';
$_lang['resource_err_preview_no_zero_id'] = 'This Resource (id = [[+source_id]]) is a link to another Resource, but its preview URL can not be created because the target Resource id ([[+target_id]]) is 0 or begins with 0.';
$_lang['resource_err_preview_self_deleted'] = 'Can not create a preview URL for this Resource (id = [[+target_id]]) because it has been marked as deleted.';
$_lang['resource_err_preview_self_not_found'] = 'Can not create a preview URL for this Resource (id = [[+target_id]]) because it does not exist.';
@@ -83,6 +86,7 @@
$_lang['resource_err_symlink_target_nf'] = 'You cannot symlink to a resource that does not exist.';
$_lang['resource_err_symlink_target_self'] = 'You cannot symlink to itself.';
$_lang['resource_err_undelete'] = 'An error occurred while trying to undelete the resource.';
+$_lang['resource_err_undelete_parent'] = 'The parent resource is deleted and could not be restored. Restore the parent first.';
$_lang['resource_err_undelete_children'] = 'An error occurred while trying to undelete the children of the resource.';
$_lang['resource_err_unpublish'] = 'An error occurred while trying to unpublish the resource.';
$_lang['resource_err_unpublish_sitestart'] = 'The resource is linked to the site_start variable and cannot be unpublished!';
diff --git a/core/lexicon/ru/resource.inc.php b/core/lexicon/ru/resource.inc.php
index bac591d4acc..32b5deb0693 100644
--- a/core/lexicon/ru/resource.inc.php
+++ b/core/lexicon/ru/resource.inc.php
@@ -59,6 +59,8 @@
$_lang['resource_err_delete'] = 'Произошла ошибка при попытке удаления ресурса.';
$_lang['resource_err_delete_children'] = 'Произошла ошибка при попытке удалить дочерние ресурсы этого ресурса.';
$_lang['resource_err_delete_container_sitestart'] = 'Ресурс, который вы пытаетесь удалить, является папкой и содержит ресурс с ID [[+id]]. Этот ресурс указан в настройках системы как «Главная страница сайта», и он не может быть удалён. Пожалуйста, укажите другой ресурс в настройках системы как «Главная страница сайта» и повторите попытку удаления.';
+$_lang['resource_err_delete_container_errorpage'] = 'Ресурс, который вы пытаетесь удалить, является папкой и содержит ресурс с ID [[+id]]. Этот ресурс указан как страница «Документ не найден» и не может быть удалён. Укажите другую страницу ошибки и повторите попытку.';
+$_lang['resource_err_delete_container_siteunavailable'] = 'Ресурс, который вы пытаетесь удалить, является папкой и содержит ресурс с ID [[+id]]. Этот ресурс указан как страница «Сайт недоступен» и не может быть удалён. Укажите другую страницу и повторите попытку.';
$_lang['resource_err_delete_sitestart'] = 'Ресурс указан в настройках системы как «Главная страница сайта» и не может быть удалён!';
$_lang['resource_err_delete_errorpage'] = 'Ресурс указан в настройках системы как страница «Документ не найден» и не может быть удалён!';
$_lang['resource_err_delete_siteunavailable'] = 'Ресурс указан в настройках системы как страница «Сайт недоступен» и не может быть удалён!';
@@ -69,6 +71,7 @@
$_lang['resource_err_nfs'] = 'Ресурс с ID [[+id]] не найден';
$_lang['resource_err_ns'] = 'Ресурс не указан.';
$_lang['resource_err_own_parent'] = 'Ресурс не может быть своим собственным родительским ресурсом.';
+$_lang['resource_err_parent_deleted'] = 'Нельзя создать или переместить ресурс в удалённый родительский ресурс.';
$_lang['resource_err_preview_no_zero_id'] = 'This Resource (id = [[+source_id]]) is a link to another Resource, but its preview URL can not be created because the target Resource id ([[+target_id]]) is 0 or begins with 0.';
$_lang['resource_err_preview_self_deleted'] = 'Can not create a preview URL for this Resource (id = [[+target_id]]) because it has been marked as deleted.';
$_lang['resource_err_preview_self_not_found'] = 'Can not create a preview URL for this Resource (id = [[+target_id]]) because it does not exist.';
@@ -83,6 +86,7 @@
$_lang['resource_err_symlink_target_nf'] = 'Вы не можете задать символическую ссылку для несуществующего ресурса.';
$_lang['resource_err_symlink_target_self'] = 'Нельзя указать символическую ссылку на себя.';
$_lang['resource_err_undelete'] = 'Произошла ошибка при попытке восстановить ресурс.';
+$_lang['resource_err_undelete_parent'] = 'Родительский ресурс удалён и не удалось его восстановить. Сначала восстановите родителя.';
$_lang['resource_err_undelete_children'] = 'Произошла ошибка при попытке восстановить дочерние ресурсы этого ресурса.';
$_lang['resource_err_unpublish'] = 'Произошла ошибка при попытке отменить публикацию ресурса.';
$_lang['resource_err_unpublish_sitestart'] = 'Ресурс указан в настройках системы как «Главная страница сайта» и его публикация не может быть отменена!';
diff --git a/core/src/Revolution/Processors/Resource/Create.php b/core/src/Revolution/Processors/Resource/Create.php
index 1737358248d..3475babbd97 100644
--- a/core/src/Revolution/Processors/Resource/Create.php
+++ b/core/src/Revolution/Processors/Resource/Create.php
@@ -355,6 +355,9 @@ public function checkParentPermissions()
if ($parentId > 0) {
$this->parentResource = $this->modx->getObject(modResource::class, $parentId);
if ($this->parentResource) {
+ if ($this->parentResource->get('deleted')) {
+ return $this->modx->lexicon('resource_err_parent_deleted');
+ }
if (!$this->parentResource->checkPolicy('add_children')) {
return $this->modx->lexicon('resource_add_children_access_denied');
}
diff --git a/core/src/Revolution/Processors/Resource/Delete.php b/core/src/Revolution/Processors/Resource/Delete.php
index 077ddf7008a..6c74f0449b3 100644
--- a/core/src/Revolution/Processors/Resource/Delete.php
+++ b/core/src/Revolution/Processors/Resource/Delete.php
@@ -1,4 +1,5 @@
isSitePage('site_start')) {
- return $this->failure($this->modx->lexicon('resource_err_delete_sitestart'));
- }
-
- if ($this->isSitePage('error_page')) {
- return $this->failure($this->modx->lexicon('resource_err_delete_errorpage'));
- }
-
- if ($this->isSitePage('site_unavailable_page')) {
- return $this->failure($this->modx->lexicon('resource_err_delete_siteunavailable'));
+ $protected = $this->getProtectedSitePageInTree();
+ if ($protected !== null) {
+ return $this->failure($this->modx->lexicon($protected['lexicon'], [
+ 'id' => $protected['id'],
+ ]));
}
/* check for locks on resource */
@@ -132,8 +128,59 @@ public function process()
*/
public function isSitePage(string $option)
{
- $workingContext = $this->modx->getContext($this->getProperty('context_key', $this->resource->get('context_key') ? $this->resource->get('context_key') : 'web'));
- return ($this->resource->get('id') == $workingContext->getOption($option) || $this->resource->get('id') == $this->modx->getOption($option));
+ return $this->resourceIsSiteOption($this->resource, $option);
+ }
+
+ /**
+ * Block deleting a resource that is, or contains, site_start / error_page / site_unavailable_page (#14167).
+ *
+ * @return array{lexicon: string, id: int}|null
+ */
+ protected function getProtectedSitePageInTree(): ?array
+ {
+ $selfMap = [
+ 'site_start' => 'resource_err_delete_sitestart',
+ 'error_page' => 'resource_err_delete_errorpage',
+ 'site_unavailable_page' => 'resource_err_delete_siteunavailable',
+ ];
+ $containerMap = [
+ 'site_start' => 'resource_err_delete_container_sitestart',
+ 'error_page' => 'resource_err_delete_container_errorpage',
+ 'site_unavailable_page' => 'resource_err_delete_container_siteunavailable',
+ ];
+
+ foreach ($selfMap as $option => $lexicon) {
+ if ($this->resourceIsSiteOption($this->resource, $option)) {
+ return ['lexicon' => $lexicon, 'id' => (int)$this->resource->get('id')];
+ }
+ }
+
+ $this->children = [];
+ $this->getChildren($this->resource);
+ foreach ($this->children as $child) {
+ foreach ($containerMap as $option => $lexicon) {
+ if ($this->resourceIsSiteOption($child, $option)) {
+ return ['lexicon' => $lexicon, 'id' => (int)$child->get('id')];
+ }
+ }
+ }
+ $this->children = [];
+
+ return null;
+ }
+
+ protected function resourceIsSiteOption(modResource $resource, string $option): bool
+ {
+ $id = (int)$resource->get('id');
+ if ($id <= 0) {
+ return false;
+ }
+ $contextKey = $resource->get('context_key') ?: 'web';
+ $context = $this->modx->getContext($contextKey);
+ $contextValue = $context ? (int)$context->getOption($option) : 0;
+ $systemValue = (int)$this->modx->getOption($option);
+
+ return $id === $contextValue || $id === $systemValue;
}
/**
@@ -184,16 +231,7 @@ protected function getChildren(modResource $parent)
if (count($childResources) > 0) {
/** @var modResource $child */
foreach ($childResources as $child) {
- if ($child->get('id') == $this->modx->getOption('site_start')) {
- continue;
- }
- if ($child->get('id') == $this->modx->getOption('site_unavailable_page')) {
- continue;
- }
-
$this->children[] = $child;
-
- /* recursively loop through tree */
$this->getChildren($child);
}
}
diff --git a/core/src/Revolution/Processors/Resource/Trash/Purge.php b/core/src/Revolution/Processors/Resource/Trash/Purge.php
index a308cb56f61..6bf1cff8c28 100644
--- a/core/src/Revolution/Processors/Resource/Trash/Purge.php
+++ b/core/src/Revolution/Processors/Resource/Trash/Purge.php
@@ -1,4 +1,5 @@
get('id');
+ if (in_array($id, $success, false)) {
+ continue;
+ }
+
+ $descendantIds = $this->collectDescendantIds($resource);
$resourceGroupResources = $resource->getMany('ResourceGroupResources');
$templateVarResources = $resource->getMany('TemplateVarResources');
@@ -158,6 +164,11 @@ public function process()
$this->failures[] = $id;
} else {
$success[] = $id;
+ foreach ($descendantIds as $descendantId) {
+ if (!in_array($descendantId, $success, false)) {
+ $success[] = $descendantId;
+ }
+ }
}
}
@@ -206,4 +217,19 @@ public function process()
'deletedCount' => $deletedCount,
]);
}
+
+ /**
+ * @return int[]
+ */
+ private function collectDescendantIds(modResource $resource): array
+ {
+ $ids = [];
+ $children = $resource->getMany('Children');
+ foreach ($children as $child) {
+ $ids[] = $child->get('id');
+ $ids = array_merge($ids, $this->collectDescendantIds($child));
+ }
+
+ return $ids;
+ }
}
diff --git a/core/src/Revolution/Processors/Resource/Trash/Restore.php b/core/src/Revolution/Processors/Resource/Trash/Restore.php
index b9042a3074f..f4fa05d9e49 100644
--- a/core/src/Revolution/Processors/Resource/Trash/Restore.php
+++ b/core/src/Revolution/Processors/Resource/Trash/Restore.php
@@ -12,6 +12,7 @@
namespace MODX\Revolution\Processors\Resource\Trash;
use MODX\Revolution\Processors\Processor;
+use MODX\Revolution\Processors\RestoresDeletedAncestors;
use MODX\Revolution\modResource;
use MODX\Revolution\modUser;
@@ -24,6 +25,8 @@
*/
class Restore extends Processor
{
+ use RestoresDeletedAncestors;
+
/** @var modResource[] $resources */
private $resources = [];
@@ -95,6 +98,18 @@ public function process()
));
}
+ $ancestors = $this->restoreDeletedAncestors($resource);
+ if ($ancestors === null) {
+ $this->removeLock($resource);
+ $this->failures[] = $id;
+ continue;
+ }
+ foreach ($ancestors as $ancestorId) {
+ if (!in_array($ancestorId, $this->success, false)) {
+ $this->success[] = $ancestorId;
+ }
+ }
+
/* 'undelete' the resource. */
$resource->set('deleted', false);
$resource->set('deletedby', 0);
diff --git a/core/src/Revolution/Processors/Resource/Undelete.php b/core/src/Revolution/Processors/Resource/Undelete.php
index c53543eaa2e..fdaf2610882 100644
--- a/core/src/Revolution/Processors/Resource/Undelete.php
+++ b/core/src/Revolution/Processors/Resource/Undelete.php
@@ -1,4 +1,5 @@
failure($this->modx->lexicon('resource_locked_by', ['id' => $this->resource->get('id'), 'user' => $this->lockedUser->get('username')]));
}
+ $ancestors = $this->restoreDeletedAncestors($this->resource);
+ if ($ancestors === null) {
+ $this->resource->removeLock();
+ return $this->failure($this->modx->lexicon('resource_err_undelete_parent'));
+ }
+
/* 'undelete' the resource. */
$this->resource->set('deleted', false);
$this->resource->set('deletedby', 0);
diff --git a/core/src/Revolution/Processors/Resource/Update.php b/core/src/Revolution/Processors/Resource/Update.php
index 04a7c489ca3..945de3b4803 100644
--- a/core/src/Revolution/Processors/Resource/Update.php
+++ b/core/src/Revolution/Processors/Resource/Update.php
@@ -307,6 +307,13 @@ public function handleParent()
/* convert parent to int */
$this->setProperty('parent', empty($parent) ? 0 : intval($parent));
+ $parentId = (int)$this->getProperty('parent');
+ if ($parentId > 0) {
+ $parentResource = $this->modx->getObject(modResource::class, $parentId);
+ if ($parentResource && $parentResource->get('deleted')) {
+ $this->addFieldError('parent-cmb', $this->modx->lexicon('resource_err_parent_deleted'));
+ }
+ }
}
return $parent;
}
@@ -549,45 +556,39 @@ public function checkForUnPublishOnSitePages()
}
/**
- * Check deleted status and ensure user has permissions to delete resource
+ * Check deleted status and ensure user has permissions to delete resource.
+ *
+ * Revert `deleted` so this save does not flip it. Resource/Delete and
+ * Resource/Undelete apply the change after save and cascade children (#14167).
*/
public function checkDeletedStatus(): bool
{
$proposedDeleted = (bool)$this->getProperty('deleted');
$currentDeleted = (bool)$this->object->get('deleted');
+ if ($proposedDeleted === $currentDeleted) {
+ return $proposedDeleted;
+ }
- if ($proposedDeleted !== $currentDeleted) {
- if ($currentDeleted) {
- // The previously-saved value was 1 (resource was deleted), so attempt to undelete
- if (!$this->modx->hasPermission('undelete_document')) {
- $this->setProperty('deleted', $currentDeleted);
- } else {
- $this->object->set('deleted', false);
- $this->resourceUnDeleted = true;
- }
- } else {
- // The previously-saved value was 0 or null, so attempt to delete
- $hasPermission = $this->modx->hasPermission('delete_document');
-
- $map = [
- modWebLink::class => 'delete_weblink',
- modSymLink::class => 'delete_symlink',
- modStaticResource::class => 'delete_static_resource',
- ];
-
- if (array_key_exists($this->object->get('class_key'), $map)) {
- $permission = $map[$this->object->get('class_key')];
- $hasPermission = $hasPermission && $this->modx->hasPermission($permission);
- }
+ $this->setProperty('deleted', $currentDeleted);
- if (!$hasPermission) {
- $this->setProperty('deleted', $currentDeleted);
- } else {
- $this->object->set('deleted', true);
- $this->resourceDeleted = true;
- }
- }
+ if ($currentDeleted) {
+ $this->resourceUnDeleted = $this->modx->hasPermission('undelete_document')
+ && $this->object->checkPolicy(['save' => true, 'undelete' => true]);
+ return $proposedDeleted;
+ }
+
+ $hasPermission = $this->modx->hasPermission('delete_document');
+ $map = [
+ modWebLink::class => 'delete_weblink',
+ modSymLink::class => 'delete_symlink',
+ modStaticResource::class => 'delete_static_resource',
+ ];
+ $classKey = $this->object->get('class_key');
+ if (array_key_exists($classKey, $map)) {
+ $hasPermission = $hasPermission && $this->modx->hasPermission($map[$classKey]);
}
+ $this->resourceDeleted = $hasPermission
+ && $this->object->checkPolicy(['save' => true, 'delete' => true]);
return $proposedDeleted;
}
@@ -678,11 +679,41 @@ public function afterSave()
$this->saveTemplateVariables();
$this->setResourceGroups();
$this->checkContextOfChildren();
- $this->fireUnDeleteEvent();
- $this->fireDeleteEvent();
+ if ($this->resourceDeleted) {
+ $this->runDeferredLifecycleProcessor(Delete::class);
+ } elseif ($this->resourceUnDeleted) {
+ $this->runDeferredLifecycleProcessor(Undelete::class);
+ }
return parent::afterSave();
}
+ /**
+ * @param class-string $processorClass
+ */
+ protected function runDeferredLifecycleProcessor(string $processorClass): void
+ {
+ $response = $this->modx->runProcessor($processorClass, [
+ 'id' => $this->object->get('id'),
+ 'syncsite' => $this->getProperty('syncsite', false),
+ ]);
+ if (!$response || $response->isError()) {
+ $message = $response ? $response->getMessage() : $this->modx->lexicon('error');
+ $this->addFieldError('deleted', $message);
+ $this->modx->log(modX::LOG_LEVEL_ERROR, sprintf(
+ 'Resource/Update deferred %s failed for resource %s: %s',
+ $processorClass,
+ $this->object->get('id'),
+ $message
+ ));
+ return;
+ }
+
+ $reloaded = $this->modx->getObject(modResource::class, $this->object->get('id'));
+ if ($reloaded instanceof modResource) {
+ $this->object = $reloaded;
+ }
+ }
+
/**
* Set the parents isfolder status based upon remaining children
*
@@ -880,38 +911,6 @@ public function checkContextOfChildren()
}
}
- /**
- * Fire UnDelete event if resource was undeleted
- * @return mixed
- */
- public function fireUnDeleteEvent()
- {
- $response = null;
- if (!empty($this->resourceUnDeleted)) {
- $response = $this->modx->invokeEvent('OnResourceUndelete', [
- 'id' => $this->object->get('id'),
- 'resource' => &$this->object,
- ]);
- }
- return $response;
- }
-
- /**
- * Fire Delete event if resource was deleted
- * @return null
- */
- public function fireDeleteEvent()
- {
- $response = null;
- if (!empty($this->resourceDeleted)) {
- $this->modx->invokeEvent('OnResourceDelete', [
- 'id' => $this->object->get('id'),
- 'resource' => &$this->object,
- ]);
- }
- return $response;
- }
-
/**
* Cleanup the processor and return the resulting object
*
@@ -920,6 +919,10 @@ public function fireDeleteEvent()
public function cleanup()
{
$this->object->removeLock();
+ if ($this->hasErrors()) {
+ return $this->failure();
+ }
+
$this->clearCache();
$returnArray = $this->object->get(array_diff(array_keys($this->object->_fields), ['content', 'ta', 'introtext', 'description', 'link_attributes', 'pagetitle', 'longtitle', 'menutitle', 'properties', 'resource_groups']));
diff --git a/core/src/Revolution/Processors/RestoresDeletedAncestors.php b/core/src/Revolution/Processors/RestoresDeletedAncestors.php
new file mode 100644
index 00000000000..f8ae74f3b3b
--- /dev/null
+++ b/core/src/Revolution/Processors/RestoresDeletedAncestors.php
@@ -0,0 +1,56 @@
+get('parent');
+ $guard = 0;
+ while ($parentId > 0 && $guard < 100) {
+ $guard++;
+ $parent = $this->modx->getObject(modResource::class, $parentId);
+ if (!$parent instanceof modResource) {
+ break;
+ }
+ if ($parent->get('deleted')) {
+ if (!$parent->checkPolicy(['save' => true, 'undelete' => true])) {
+ return null;
+ }
+ $parent->set('deleted', false);
+ $parent->set('deletedby', 0);
+ $parent->set('deletedon', 0);
+ if ($parent->save() === false) {
+ return null;
+ }
+ $this->modx->invokeEvent('OnResourceUndelete', [
+ 'id' => $parent->get('id'),
+ 'resource' => &$parent,
+ ]);
+ $restoredIds[] = (int)$parent->get('id');
+ }
+ $parentId = (int)$parent->get('parent');
+ }
+
+ return $restoredIds;
+ }
+}
diff --git a/phpcs.xml b/phpcs.xml
index 75bcc228a7b..ed506e8ca17 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -39,4 +39,9 @@
manager/controllers/default/
+
+
+ */Processors/Resource/*
+ */Tests/Processors/Resource/*
+