feat(files_trashbin): Allow preventing trash to be deleted permanently

Signed-off-by: provokateurin <kate@provokateurin.de>
pull/50077/head
provokateurin 2025-01-07 17:02:43 +07:00
parent 26fa4da8c2
commit 31c21c7797
No known key found for this signature in database
16 changed files with 176 additions and 8 deletions

@ -127,6 +127,22 @@ describe('Delete action conditions tests', () => {
}) })
describe('Delete action enabled 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', () => { test('Enabled with DELETE permissions', () => {
const file = new File({ const file = new File({
id: 1, id: 1,
@ -177,6 +193,15 @@ describe('Delete action enabled tests', () => {
expect(action.enabled!([folder2], view)).toBe(false) expect(action.enabled!([folder2], view)).toBe(false)
expect(action.enabled!([folder1, 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', () => { describe('Delete action execute tests', () => {

@ -2,6 +2,9 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later * 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 { Permission, Node, View, FileAction } from '@nextcloud/files'
import { showInfo } from '@nextcloud/dialogs' import { showInfo } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n' import { translate as t } from '@nextcloud/l10n'
@ -34,6 +37,11 @@ export const action = new FileAction({
}, },
enabled(nodes: Node[]) { enabled(nodes: Node[]) {
const config = loadState<FilesTrashbinConfigState>('files_trashbin', 'config')
if (!config.allow_delete) {
return false
}
return nodes.length > 0 && nodes return nodes.length > 0 && nodes
.map(node => node.permissions) .map(node => node.permissions)
.every(permission => (permission & Permission.DELETE) !== 0) .every(permission => (permission & Permission.DELETE) !== 0)

@ -2,7 +2,7 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later * 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 { File, Permission, View } from '@nextcloud/files'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
@ -33,6 +33,12 @@ describe('HotKeysService testing', () => {
const goToRouteMock = vi.fn() const goToRouteMock = vi.fn()
let initialState: HTMLInputElement
afterEach(() => {
document.body.removeChild(initialState)
})
beforeAll(() => { beforeAll(() => {
registerHotkeys() registerHotkeys()
}) })
@ -57,6 +63,14 @@ describe('HotKeysService testing', () => {
window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } } window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } }
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation // @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: {} } } } 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', () => { it('Pressing d should open the sidebar once', () => {

@ -23,6 +23,7 @@ return array(
'OCA\\Files_Trashbin\\Expiration' => $baseDir . '/../lib/Expiration.php', 'OCA\\Files_Trashbin\\Expiration' => $baseDir . '/../lib/Expiration.php',
'OCA\\Files_Trashbin\\Helper' => $baseDir . '/../lib/Helper.php', 'OCA\\Files_Trashbin\\Helper' => $baseDir . '/../lib/Helper.php',
'OCA\\Files_Trashbin\\Listener\\EventListener' => $baseDir . '/../lib/Listener/EventListener.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\\LoadAdditionalScripts' => $baseDir . '/../lib/Listeners/LoadAdditionalScripts.php',
'OCA\\Files_Trashbin\\Listeners\\SyncLivePhotosListener' => $baseDir . '/../lib/Listeners/SyncLivePhotosListener.php', 'OCA\\Files_Trashbin\\Listeners\\SyncLivePhotosListener' => $baseDir . '/../lib/Listeners/SyncLivePhotosListener.php',
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => $baseDir . '/../lib/Migration/Version1010Date20200630192639.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\\TrashHome' => $baseDir . '/../lib/Sabre/TrashHome.php',
'OCA\\Files_Trashbin\\Sabre\\TrashRoot' => $baseDir . '/../lib/Sabre/TrashRoot.php', 'OCA\\Files_Trashbin\\Sabre\\TrashRoot' => $baseDir . '/../lib/Sabre/TrashRoot.php',
'OCA\\Files_Trashbin\\Sabre\\TrashbinPlugin' => $baseDir . '/../lib/Sabre/TrashbinPlugin.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\\Storage' => $baseDir . '/../lib/Storage.php',
'OCA\\Files_Trashbin\\Trash\\BackendNotFoundException' => $baseDir . '/../lib/Trash/BackendNotFoundException.php', 'OCA\\Files_Trashbin\\Trash\\BackendNotFoundException' => $baseDir . '/../lib/Trash/BackendNotFoundException.php',
'OCA\\Files_Trashbin\\Trash\\ITrashBackend' => $baseDir . '/../lib/Trash/ITrashBackend.php', 'OCA\\Files_Trashbin\\Trash\\ITrashBackend' => $baseDir . '/../lib/Trash/ITrashBackend.php',

@ -38,6 +38,7 @@ class ComposerStaticInitFiles_Trashbin
'OCA\\Files_Trashbin\\Expiration' => __DIR__ . '/..' . '/../lib/Expiration.php', 'OCA\\Files_Trashbin\\Expiration' => __DIR__ . '/..' . '/../lib/Expiration.php',
'OCA\\Files_Trashbin\\Helper' => __DIR__ . '/..' . '/../lib/Helper.php', 'OCA\\Files_Trashbin\\Helper' => __DIR__ . '/..' . '/../lib/Helper.php',
'OCA\\Files_Trashbin\\Listener\\EventListener' => __DIR__ . '/..' . '/../lib/Listener/EventListener.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\\LoadAdditionalScripts' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScripts.php',
'OCA\\Files_Trashbin\\Listeners\\SyncLivePhotosListener' => __DIR__ . '/..' . '/../lib/Listeners/SyncLivePhotosListener.php', 'OCA\\Files_Trashbin\\Listeners\\SyncLivePhotosListener' => __DIR__ . '/..' . '/../lib/Listeners/SyncLivePhotosListener.php',
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630192639.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\\TrashHome' => __DIR__ . '/..' . '/../lib/Sabre/TrashHome.php',
'OCA\\Files_Trashbin\\Sabre\\TrashRoot' => __DIR__ . '/..' . '/../lib/Sabre/TrashRoot.php', 'OCA\\Files_Trashbin\\Sabre\\TrashRoot' => __DIR__ . '/..' . '/../lib/Sabre/TrashRoot.php',
'OCA\\Files_Trashbin\\Sabre\\TrashbinPlugin' => __DIR__ . '/..' . '/../lib/Sabre/TrashbinPlugin.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\\Storage' => __DIR__ . '/..' . '/../lib/Storage.php',
'OCA\\Files_Trashbin\\Trash\\BackendNotFoundException' => __DIR__ . '/..' . '/../lib/Trash/BackendNotFoundException.php', 'OCA\\Files_Trashbin\\Trash\\BackendNotFoundException' => __DIR__ . '/..' . '/../lib/Trash/BackendNotFoundException.php',
'OCA\\Files_Trashbin\\Trash\\ITrashBackend' => __DIR__ . '/..' . '/../lib/Trash/ITrashBackend.php', 'OCA\\Files_Trashbin\\Trash\\ITrashBackend' => __DIR__ . '/..' . '/../lib/Trash/ITrashBackend.php',

@ -8,10 +8,12 @@ namespace OCA\Files_Trashbin\AppInfo;
use OCA\DAV\Connector\Sabre\Principal; use OCA\DAV\Connector\Sabre\Principal;
use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
use OCA\Files_Trashbin\Capabilities; use OCA\Files_Trashbin\Capabilities;
use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent; use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent;
use OCA\Files_Trashbin\Expiration; use OCA\Files_Trashbin\Expiration;
use OCA\Files_Trashbin\Listener\EventListener; use OCA\Files_Trashbin\Listener\EventListener;
use OCA\Files_Trashbin\Listeners\BeforeTemplateRendered;
use OCA\Files_Trashbin\Listeners\LoadAdditionalScripts; use OCA\Files_Trashbin\Listeners\LoadAdditionalScripts;
use OCA\Files_Trashbin\Listeners\SyncLivePhotosListener; use OCA\Files_Trashbin\Listeners\SyncLivePhotosListener;
use OCA\Files_Trashbin\Trash\ITrashManager; use OCA\Files_Trashbin\Trash\ITrashManager;
@ -52,6 +54,11 @@ class Application extends App implements IBootstrap {
LoadAdditionalScripts::class LoadAdditionalScripts::class
); );
$context->registerEventListener(
BeforeTemplateRenderedEvent::class,
BeforeTemplateRendered::class
);
$context->registerEventListener(BeforeNodeRestoredEvent::class, SyncLivePhotosListener::class); $context->registerEventListener(BeforeNodeRestoredEvent::class, SyncLivePhotosListener::class);
$context->registerEventListener(NodeWrittenEvent::class, EventListener::class); $context->registerEventListener(NodeWrittenEvent::class, EventListener::class);

@ -6,6 +6,7 @@
*/ */
namespace OCA\Files_Trashbin; namespace OCA\Files_Trashbin;
use OCA\Files_Trashbin\Service\ConfigService;
use OCP\Capabilities\ICapability; use OCP\Capabilities\ICapability;
/** /**
@ -18,12 +19,18 @@ class Capabilities implements ICapability {
/** /**
* Return this classes capabilities * Return this classes capabilities
* *
* @return array{files: array{undelete: bool}} * @return array{
* files: array{
* undelete: bool,
* delete_from_trash: bool
* }
* }
*/ */
public function getCapabilities() { public function getCapabilities() {
return [ return [
'files' => [ 'files' => [
'undelete' => true 'undelete' => true,
'delete_from_trash' => ConfigService::getDeleteFromTrashEnabled(),
] ]
]; ];
} }

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Trashbin\Listeners;
use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent;
use OCA\Files_Trashbin\Service\ConfigService;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
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);
}
}

@ -10,17 +10,26 @@ namespace OCA\Files_Trashbin\Listeners;
use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files_Trashbin\AppInfo\Application; use OCA\Files_Trashbin\AppInfo\Application;
use OCA\Files_Trashbin\Service\ConfigService;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\Event; use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener; use OCP\EventDispatcher\IEventListener;
use OCP\Util; use OCP\Util;
/** @template-implements IEventListener<LoadAdditionalScriptsEvent> */ /** @template-implements IEventListener<LoadAdditionalScriptsEvent> */
class LoadAdditionalScripts implements IEventListener { class LoadAdditionalScripts implements IEventListener {
public function __construct(
private IInitialState $initialState,
) {
}
public function handle(Event $event): void { public function handle(Event $event): void {
if (!($event instanceof LoadAdditionalScriptsEvent)) { if (!($event instanceof LoadAdditionalScriptsEvent)) {
return; return;
} }
Util::addInitScript(Application::APP_ID, 'init'); Util::addInitScript(Application::APP_ID, 'init');
ConfigService::injectInitialState($this->initialState);
} }
} }

@ -8,10 +8,12 @@ declare(strict_types=1);
*/ */
namespace OCA\Files_Trashbin\Sabre; namespace OCA\Files_Trashbin\Sabre;
use OCA\Files_Trashbin\Service\ConfigService;
use OCA\Files_Trashbin\Trash\ITrashItem; use OCA\Files_Trashbin\Trash\ITrashItem;
use OCA\Files_Trashbin\Trash\ITrashManager; use OCA\Files_Trashbin\Trash\ITrashManager;
use OCP\Files\FileInfo; use OCP\Files\FileInfo;
use OCP\IUser; use OCP\IUser;
use Sabre\DAV\Exception\Forbidden;
abstract class AbstractTrash implements ITrash { abstract class AbstractTrash implements ITrash {
public function __construct( public function __construct(
@ -73,6 +75,10 @@ abstract class AbstractTrash implements ITrash {
} }
public function delete() { public function delete() {
if (!ConfigService::getDeleteFromTrashEnabled()) {
throw new Forbidden('Not allowed to delete items from the trash bin');
}
$this->trashManager->removeItem($this->data); $this->trashManager->removeItem($this->data);
} }

@ -8,6 +8,7 @@ declare(strict_types=1);
*/ */
namespace OCA\Files_Trashbin\Sabre; namespace OCA\Files_Trashbin\Sabre;
use OCA\Files_Trashbin\Service\ConfigService;
use OCA\Files_Trashbin\Trash\ITrashItem; use OCA\Files_Trashbin\Trash\ITrashItem;
use OCA\Files_Trashbin\Trash\ITrashManager; use OCA\Files_Trashbin\Trash\ITrashManager;
use OCA\Files_Trashbin\Trashbin; use OCA\Files_Trashbin\Trashbin;
@ -26,6 +27,10 @@ class TrashRoot implements ICollection {
} }
public function delete() { public function delete() {
if (!ConfigService::getDeleteFromTrashEnabled()) {
throw new Forbidden('Not allowed to delete items from the trash bin');
}
Trashbin::deleteAll(); Trashbin::deleteAll();
foreach ($this->trashManager->listTrashRoot($this->user) as $trashItem) { foreach ($this->trashManager->listTrashRoot($this->user) as $trashItem) {
$this->trashManager->removeItem($trashItem); $this->trashManager->removeItem($trashItem);

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace OCA\Files_Trashbin\Service;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\Server;
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
class ConfigService {
public static function getDeleteFromTrashEnabled(): bool {
return Server::get(IConfig::class)->getSystemValueBool('files.trash.delete', true);
}
public static function injectInitialState(IInitialState $initialState): void {
$initialState->provideLazyInitialState('config', function () {
return [
'allow_delete' => ConfigService::getDeleteFromTrashEnabled(),
];
});
}
}

@ -29,11 +29,15 @@
"files": { "files": {
"type": "object", "type": "object",
"required": [ "required": [
"undelete" "undelete",
"delete_from_trash"
], ],
"properties": { "properties": {
"undelete": { "undelete": {
"type": "boolean" "type": "boolean"
},
"delete_from_trash": {
"type": "boolean"
} }
} }
} }

@ -19,6 +19,11 @@ import { logger } from '../logger.ts'
import { generateRemoteUrl } from '@nextcloud/router' import { generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth' import { getCurrentUser } from '@nextcloud/auth'
import { emit } from '@nextcloud/event-bus' import { emit } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
export type FilesTrashbinConfigState = {
allow_delete: boolean;
}
const emptyTrash = async (): Promise<boolean> => { const emptyTrash = async (): Promise<boolean> => {
try { try {
@ -42,6 +47,12 @@ export const emptyTrashAction = new FileListAction({
if (view.id !== 'trashbin') { if (view.id !== 'trashbin') {
return false return false
} }
const config = loadState<FilesTrashbinConfigState>('files_trashbin', 'config')
if (!config.allow_delete) {
return false
}
return nodes.length > 0 && folder.path === '/' return nodes.length > 0 && folder.path === '/'
}, },

@ -17,11 +17,12 @@ class CapabilitiesTest extends TestCase {
parent::setUp(); parent::setUp();
$this->capabilities = new Capabilities(); $this->capabilities = new Capabilities();
} }
public function testGetCapabilities(): void { public function testGetCapabilities(): void {
$capabilities = [ $capabilities = [
'files' => [ 'files' => [
'undelete' => true 'undelete' => true,
'delete_from_trash' => true,
] ]
]; ];

@ -369,7 +369,7 @@ $CONFIG = [
/** /**
* Enable or disable the automatic logout after session_lifetime, even if session * 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 * 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 * handled on the client side. This is not a way to limit the duration of potentially
* compromised sessions. * compromised sessions.
* *
@ -688,7 +688,7 @@ $CONFIG = [
* are generated within Nextcloud using any kind of command line tools (cron or * are generated within Nextcloud using any kind of command line tools (cron or
* occ). The value should contain the full base URL: * occ). The value should contain the full base URL:
* ``https://www.example.com/nextcloud`` * ``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. * Otherwise there might be problems with the URL generation via cron.
* *
* Defaults to ``''`` (empty string) * Defaults to ``''`` (empty string)
@ -2606,4 +2606,12 @@ $CONFIG = [
* Defaults to 5. * Defaults to 5.
*/ */
'files.chunked_upload.max_parallel_count' => 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,
]; ];