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', () => {
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', () => {

@ -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<FilesTrashbinConfigState>('files_trashbin', 'config')
if (!config.allow_delete) {
return false
}
return nodes.length > 0 && nodes
.map(node => node.permissions)
.every(permission => (permission & Permission.DELETE) !== 0)

@ -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', () => {

@ -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',

@ -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',

@ -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);

@ -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(),
]
];
}

@ -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_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<LoadAdditionalScriptsEvent> */
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);
}
}

@ -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);
}

@ -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);

@ -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": {
"type": "object",
"required": [
"undelete"
"undelete",
"delete_from_trash"
],
"properties": {
"undelete": {
"type": "boolean"
},
"delete_from_trash": {
"type": "boolean"
}
}
}

@ -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<boolean> => {
try {
@ -42,6 +47,12 @@ export const emptyTrashAction = new FileListAction({
if (view.id !== 'trashbin') {
return false
}
const config = loadState<FilesTrashbinConfigState>('files_trashbin', 'config')
if (!config.allow_delete) {
return false
}
return nodes.length > 0 && folder.path === '/'
},

@ -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,
]
];

@ -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,
];