Merge pull request #50077 from nextcloud/feat/files_trashbin/allow-preventing-trash-permanently

This commit is contained in:
Kate 2025-01-13 16:09:46 +01:00 committed by GitHub
commit 87d7bbf1ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 189 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 === '/'
},
@ -71,8 +82,9 @@ export const emptyTrashAction = new FileListAction({
const result = await askConfirmation
if (result === true) {
await emptyTrash()
nodes.forEach((node) => emit('files:node:deleted', node))
if (await emptyTrash()) {
nodes.forEach((node) => emit('files:node:deleted', node))
}
return
}

View file

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

View file

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

4
dist/files-init.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -37,7 +37,7 @@ interface IInitialState {
*
* @param string $key
* @param Closure $closure returns a primitive or an object that implements JsonSerializable
* @psalm-param Closure():bool|Closure():int|Closure():float|Closure():string|Closure():\JsonSerializable $closure
* @psalm-param Closure():bool|Closure():int|Closure():float|Closure():string|Closure():array|Closure():\JsonSerializable $closure
*/
public function provideLazyInitialState(string $key, Closure $closure): void;
}