diff --git a/apps/files/src/actions/deleteAction.spec.ts b/apps/files/src/actions/deleteAction.spec.ts index 224b196d364..4ed625b2412 100644 --- a/apps/files/src/actions/deleteAction.spec.ts +++ b/apps/files/src/actions/deleteAction.spec.ts @@ -127,6 +127,22 @@ describe('Delete action conditions tests', () => { }) describe('Delete action enabled tests', () => { + let initialState: HTMLInputElement + + afterEach(() => { + document.body.removeChild(initialState) + }) + + beforeEach(() => { + initialState = document.createElement('input') + initialState.setAttribute('type', 'hidden') + initialState.setAttribute('id', 'initial-state-files_trashbin-config') + initialState.setAttribute('value', btoa(JSON.stringify({ + allow_delete: true, + }))) + document.body.appendChild(initialState) + }) + test('Enabled with DELETE permissions', () => { const file = new File({ id: 1, @@ -177,6 +193,15 @@ describe('Delete action enabled tests', () => { expect(action.enabled!([folder2], view)).toBe(false) expect(action.enabled!([folder1, folder2], view)).toBe(false) }) + + test('Disabled if not allowed', () => { + initialState.setAttribute('value', btoa(JSON.stringify({ + allow_delete: false, + }))) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) }) describe('Delete action execute tests', () => { diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts index 8d8aa4f9deb..8b3f45196e9 100644 --- a/apps/files/src/actions/deleteAction.ts +++ b/apps/files/src/actions/deleteAction.ts @@ -2,6 +2,9 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { FilesTrashbinConfigState } from '../../../files_trashbin/src/fileListActions/emptyTrashAction.ts' + +import { loadState } from '@nextcloud/initial-state' import { Permission, Node, View, FileAction } from '@nextcloud/files' import { showInfo } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' @@ -34,6 +37,11 @@ export const action = new FileAction({ }, enabled(nodes: Node[]) { + const config = loadState('files_trashbin', 'config') + if (!config.allow_delete) { + return false + } + return nodes.length > 0 && nodes .map(node => node.permissions) .every(permission => (permission & Permission.DELETE) !== 0) diff --git a/apps/files/src/services/HotKeysService.spec.ts b/apps/files/src/services/HotKeysService.spec.ts index dfe9f66601b..3bfdac0bc32 100644 --- a/apps/files/src/services/HotKeysService.spec.ts +++ b/apps/files/src/services/HotKeysService.spec.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { describe, it, vi, expect, beforeEach, beforeAll } from 'vitest' +import { describe, it, vi, expect, beforeEach, beforeAll, afterEach } from 'vitest' import { File, Permission, View } from '@nextcloud/files' import axios from '@nextcloud/axios' @@ -33,6 +33,12 @@ describe('HotKeysService testing', () => { const goToRouteMock = vi.fn() + let initialState: HTMLInputElement + + afterEach(() => { + document.body.removeChild(initialState) + }) + beforeAll(() => { registerHotkeys() }) @@ -57,6 +63,14 @@ describe('HotKeysService testing', () => { window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } } // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation window.OCP = { Files: { Router: { goToRoute: goToRouteMock, params: {}, query: {} } } } + + initialState = document.createElement('input') + initialState.setAttribute('type', 'hidden') + initialState.setAttribute('id', 'initial-state-files_trashbin-config') + initialState.setAttribute('value', btoa(JSON.stringify({ + allow_delete: true, + }))) + document.body.appendChild(initialState) }) it('Pressing d should open the sidebar once', () => { diff --git a/apps/files_trashbin/composer/composer/autoload_classmap.php b/apps/files_trashbin/composer/composer/autoload_classmap.php index 4ff7c561748..23e2e7baff6 100644 --- a/apps/files_trashbin/composer/composer/autoload_classmap.php +++ b/apps/files_trashbin/composer/composer/autoload_classmap.php @@ -23,6 +23,7 @@ return array( 'OCA\\Files_Trashbin\\Expiration' => $baseDir . '/../lib/Expiration.php', 'OCA\\Files_Trashbin\\Helper' => $baseDir . '/../lib/Helper.php', 'OCA\\Files_Trashbin\\Listener\\EventListener' => $baseDir . '/../lib/Listener/EventListener.php', + 'OCA\\Files_Trashbin\\Listeners\\BeforeTemplateRendered' => $baseDir . '/../lib/Listeners/BeforeTemplateRendered.php', 'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => $baseDir . '/../lib/Listeners/LoadAdditionalScripts.php', 'OCA\\Files_Trashbin\\Listeners\\SyncLivePhotosListener' => $baseDir . '/../lib/Listeners/SyncLivePhotosListener.php', 'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => $baseDir . '/../lib/Migration/Version1010Date20200630192639.php', @@ -40,6 +41,7 @@ return array( 'OCA\\Files_Trashbin\\Sabre\\TrashHome' => $baseDir . '/../lib/Sabre/TrashHome.php', 'OCA\\Files_Trashbin\\Sabre\\TrashRoot' => $baseDir . '/../lib/Sabre/TrashRoot.php', 'OCA\\Files_Trashbin\\Sabre\\TrashbinPlugin' => $baseDir . '/../lib/Sabre/TrashbinPlugin.php', + 'OCA\\Files_Trashbin\\Service\\ConfigService' => $baseDir . '/../lib/Service/ConfigService.php', 'OCA\\Files_Trashbin\\Storage' => $baseDir . '/../lib/Storage.php', 'OCA\\Files_Trashbin\\Trash\\BackendNotFoundException' => $baseDir . '/../lib/Trash/BackendNotFoundException.php', 'OCA\\Files_Trashbin\\Trash\\ITrashBackend' => $baseDir . '/../lib/Trash/ITrashBackend.php', diff --git a/apps/files_trashbin/composer/composer/autoload_static.php b/apps/files_trashbin/composer/composer/autoload_static.php index 4b09239aa1e..fc604299261 100644 --- a/apps/files_trashbin/composer/composer/autoload_static.php +++ b/apps/files_trashbin/composer/composer/autoload_static.php @@ -38,6 +38,7 @@ class ComposerStaticInitFiles_Trashbin 'OCA\\Files_Trashbin\\Expiration' => __DIR__ . '/..' . '/../lib/Expiration.php', 'OCA\\Files_Trashbin\\Helper' => __DIR__ . '/..' . '/../lib/Helper.php', 'OCA\\Files_Trashbin\\Listener\\EventListener' => __DIR__ . '/..' . '/../lib/Listener/EventListener.php', + 'OCA\\Files_Trashbin\\Listeners\\BeforeTemplateRendered' => __DIR__ . '/..' . '/../lib/Listeners/BeforeTemplateRendered.php', 'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScripts.php', 'OCA\\Files_Trashbin\\Listeners\\SyncLivePhotosListener' => __DIR__ . '/..' . '/../lib/Listeners/SyncLivePhotosListener.php', 'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630192639.php', @@ -55,6 +56,7 @@ class ComposerStaticInitFiles_Trashbin 'OCA\\Files_Trashbin\\Sabre\\TrashHome' => __DIR__ . '/..' . '/../lib/Sabre/TrashHome.php', 'OCA\\Files_Trashbin\\Sabre\\TrashRoot' => __DIR__ . '/..' . '/../lib/Sabre/TrashRoot.php', 'OCA\\Files_Trashbin\\Sabre\\TrashbinPlugin' => __DIR__ . '/..' . '/../lib/Sabre/TrashbinPlugin.php', + 'OCA\\Files_Trashbin\\Service\\ConfigService' => __DIR__ . '/..' . '/../lib/Service/ConfigService.php', 'OCA\\Files_Trashbin\\Storage' => __DIR__ . '/..' . '/../lib/Storage.php', 'OCA\\Files_Trashbin\\Trash\\BackendNotFoundException' => __DIR__ . '/..' . '/../lib/Trash/BackendNotFoundException.php', 'OCA\\Files_Trashbin\\Trash\\ITrashBackend' => __DIR__ . '/..' . '/../lib/Trash/ITrashBackend.php', diff --git a/apps/files_trashbin/lib/AppInfo/Application.php b/apps/files_trashbin/lib/AppInfo/Application.php index 115e6e418b4..000677de96c 100644 --- a/apps/files_trashbin/lib/AppInfo/Application.php +++ b/apps/files_trashbin/lib/AppInfo/Application.php @@ -8,10 +8,12 @@ namespace OCA\Files_Trashbin\AppInfo; use OCA\DAV\Connector\Sabre\Principal; use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; use OCA\Files_Trashbin\Capabilities; use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent; use OCA\Files_Trashbin\Expiration; use OCA\Files_Trashbin\Listener\EventListener; +use OCA\Files_Trashbin\Listeners\BeforeTemplateRendered; use OCA\Files_Trashbin\Listeners\LoadAdditionalScripts; use OCA\Files_Trashbin\Listeners\SyncLivePhotosListener; use OCA\Files_Trashbin\Trash\ITrashManager; @@ -52,6 +54,11 @@ class Application extends App implements IBootstrap { LoadAdditionalScripts::class ); + $context->registerEventListener( + BeforeTemplateRenderedEvent::class, + BeforeTemplateRendered::class + ); + $context->registerEventListener(BeforeNodeRestoredEvent::class, SyncLivePhotosListener::class); $context->registerEventListener(NodeWrittenEvent::class, EventListener::class); diff --git a/apps/files_trashbin/lib/Capabilities.php b/apps/files_trashbin/lib/Capabilities.php index 863d3692fb6..62be7bcb1a1 100644 --- a/apps/files_trashbin/lib/Capabilities.php +++ b/apps/files_trashbin/lib/Capabilities.php @@ -6,6 +6,7 @@ */ namespace OCA\Files_Trashbin; +use OCA\Files_Trashbin\Service\ConfigService; use OCP\Capabilities\ICapability; /** @@ -18,12 +19,18 @@ class Capabilities implements ICapability { /** * Return this classes capabilities * - * @return array{files: array{undelete: bool}} + * @return array{ + * files: array{ + * undelete: bool, + * delete_from_trash: bool + * } + * } */ public function getCapabilities() { return [ 'files' => [ - 'undelete' => true + 'undelete' => true, + 'delete_from_trash' => ConfigService::getDeleteFromTrashEnabled(), ] ]; } diff --git a/apps/files_trashbin/lib/Listeners/BeforeTemplateRendered.php b/apps/files_trashbin/lib/Listeners/BeforeTemplateRendered.php new file mode 100644 index 00000000000..d62618583f7 --- /dev/null +++ b/apps/files_trashbin/lib/Listeners/BeforeTemplateRendered.php @@ -0,0 +1,32 @@ + */ +class BeforeTemplateRendered implements IEventListener { + public function __construct( + private IInitialState $initialState, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof BeforeTemplateRenderedEvent)) { + return; + } + + ConfigService::injectInitialState($this->initialState); + } +} diff --git a/apps/files_trashbin/lib/Listeners/LoadAdditionalScripts.php b/apps/files_trashbin/lib/Listeners/LoadAdditionalScripts.php index 4bfa6bbef3f..7940b934ace 100644 --- a/apps/files_trashbin/lib/Listeners/LoadAdditionalScripts.php +++ b/apps/files_trashbin/lib/Listeners/LoadAdditionalScripts.php @@ -10,17 +10,26 @@ namespace OCA\Files_Trashbin\Listeners; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files_Trashbin\AppInfo\Application; +use OCA\Files_Trashbin\Service\ConfigService; +use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Util; /** @template-implements IEventListener */ class LoadAdditionalScripts implements IEventListener { + public function __construct( + private IInitialState $initialState, + ) { + } + public function handle(Event $event): void { if (!($event instanceof LoadAdditionalScriptsEvent)) { return; } Util::addInitScript(Application::APP_ID, 'init'); + + ConfigService::injectInitialState($this->initialState); } } diff --git a/apps/files_trashbin/lib/Sabre/AbstractTrash.php b/apps/files_trashbin/lib/Sabre/AbstractTrash.php index 343c8b6be6d..f032395437b 100644 --- a/apps/files_trashbin/lib/Sabre/AbstractTrash.php +++ b/apps/files_trashbin/lib/Sabre/AbstractTrash.php @@ -8,10 +8,12 @@ declare(strict_types=1); */ namespace OCA\Files_Trashbin\Sabre; +use OCA\Files_Trashbin\Service\ConfigService; use OCA\Files_Trashbin\Trash\ITrashItem; use OCA\Files_Trashbin\Trash\ITrashManager; use OCP\Files\FileInfo; use OCP\IUser; +use Sabre\DAV\Exception\Forbidden; abstract class AbstractTrash implements ITrash { public function __construct( @@ -73,6 +75,10 @@ abstract class AbstractTrash implements ITrash { } public function delete() { + if (!ConfigService::getDeleteFromTrashEnabled()) { + throw new Forbidden('Not allowed to delete items from the trash bin'); + } + $this->trashManager->removeItem($this->data); } diff --git a/apps/files_trashbin/lib/Sabre/TrashRoot.php b/apps/files_trashbin/lib/Sabre/TrashRoot.php index d6a4f5cc67e..dd89583d9a1 100644 --- a/apps/files_trashbin/lib/Sabre/TrashRoot.php +++ b/apps/files_trashbin/lib/Sabre/TrashRoot.php @@ -8,6 +8,7 @@ declare(strict_types=1); */ namespace OCA\Files_Trashbin\Sabre; +use OCA\Files_Trashbin\Service\ConfigService; use OCA\Files_Trashbin\Trash\ITrashItem; use OCA\Files_Trashbin\Trash\ITrashManager; use OCA\Files_Trashbin\Trashbin; @@ -26,6 +27,10 @@ class TrashRoot implements ICollection { } public function delete() { + if (!ConfigService::getDeleteFromTrashEnabled()) { + throw new Forbidden('Not allowed to delete items from the trash bin'); + } + Trashbin::deleteAll(); foreach ($this->trashManager->listTrashRoot($this->user) as $trashItem) { $this->trashManager->removeItem($trashItem); diff --git a/apps/files_trashbin/lib/Service/ConfigService.php b/apps/files_trashbin/lib/Service/ConfigService.php new file mode 100644 index 00000000000..9e7826fe580 --- /dev/null +++ b/apps/files_trashbin/lib/Service/ConfigService.php @@ -0,0 +1,27 @@ +getSystemValueBool('files.trash.delete', true); + } + + public static function injectInitialState(IInitialState $initialState): void { + $initialState->provideLazyInitialState('config', function () { + return [ + 'allow_delete' => ConfigService::getDeleteFromTrashEnabled(), + ]; + }); + } +} diff --git a/apps/files_trashbin/openapi.json b/apps/files_trashbin/openapi.json index 6662fe10815..716d34db641 100644 --- a/apps/files_trashbin/openapi.json +++ b/apps/files_trashbin/openapi.json @@ -29,11 +29,15 @@ "files": { "type": "object", "required": [ - "undelete" + "undelete", + "delete_from_trash" ], "properties": { "undelete": { "type": "boolean" + }, + "delete_from_trash": { + "type": "boolean" } } } diff --git a/apps/files_trashbin/src/fileListActions/emptyTrashAction.ts b/apps/files_trashbin/src/fileListActions/emptyTrashAction.ts index 25e3083abb9..a77a4ad89f3 100644 --- a/apps/files_trashbin/src/fileListActions/emptyTrashAction.ts +++ b/apps/files_trashbin/src/fileListActions/emptyTrashAction.ts @@ -19,6 +19,11 @@ import { logger } from '../logger.ts' import { generateRemoteUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' import { emit } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' + +export type FilesTrashbinConfigState = { + allow_delete: boolean; +} const emptyTrash = async (): Promise => { try { @@ -42,6 +47,12 @@ export const emptyTrashAction = new FileListAction({ if (view.id !== 'trashbin') { return false } + + const config = loadState('files_trashbin', 'config') + if (!config.allow_delete) { + return false + } + return nodes.length > 0 && folder.path === '/' }, diff --git a/apps/files_trashbin/tests/CapabilitiesTest.php b/apps/files_trashbin/tests/CapabilitiesTest.php index a2a17ca349e..a5e4e79aefd 100644 --- a/apps/files_trashbin/tests/CapabilitiesTest.php +++ b/apps/files_trashbin/tests/CapabilitiesTest.php @@ -17,11 +17,12 @@ class CapabilitiesTest extends TestCase { parent::setUp(); $this->capabilities = new Capabilities(); } - + public function testGetCapabilities(): void { $capabilities = [ 'files' => [ - 'undelete' => true + 'undelete' => true, + 'delete_from_trash' => true, ] ]; diff --git a/config/config.sample.php b/config/config.sample.php index d5d88b71e4a..ebc8427558f 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -369,7 +369,7 @@ $CONFIG = [ /** * Enable or disable the automatic logout after session_lifetime, even if session * keepalive is enabled. This will make sure that an inactive browser will log itself out - * even if requests to the server might extend the session lifetime. Note: the logout is + * even if requests to the server might extend the session lifetime. Note: the logout is * handled on the client side. This is not a way to limit the duration of potentially * compromised sessions. * @@ -688,7 +688,7 @@ $CONFIG = [ * are generated within Nextcloud using any kind of command line tools (cron or * occ). The value should contain the full base URL: * ``https://www.example.com/nextcloud`` - * Please make sure to set the value to the URL that your users mainly use to access this Nextcloud. + * Please make sure to set the value to the URL that your users mainly use to access this Nextcloud. * Otherwise there might be problems with the URL generation via cron. * * Defaults to ``''`` (empty string) @@ -2606,4 +2606,12 @@ $CONFIG = [ * Defaults to 5. */ 'files.chunked_upload.max_parallel_count' => 5, + +/** + * Allow users to manually delete files from their trashbin. + * Automated deletions are not affected and will continue to work in cases like low remaining quota for example. + * + * Defaults to true. + */ +'files.trash.delete' => true, ];