mirror of
https://github.com/nextcloud/server.git
synced 2026-02-26 11:31:13 -05:00
Merge pull request #50077 from nextcloud/feat/files_trashbin/allow-preventing-trash-permanently
This commit is contained in:
commit
87d7bbf1ca
23 changed files with 189 additions and 20 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(),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
|
|
|||
32
apps/files_trashbin/lib/Listeners/BeforeTemplateRendered.php
Normal file
32
apps/files_trashbin/lib/Listeners/BeforeTemplateRendered.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
27
apps/files_trashbin/lib/Service/ConfigService.php
Normal file
27
apps/files_trashbin/lib/Service/ConfigService.php
Normal 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(),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 === '/'
|
||||
},
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
4
dist/files-init.js
vendored
4
dist/files-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-init.js.map
vendored
2
dist/files-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_trashbin-init.js
vendored
4
dist/files_trashbin-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_trashbin-init.js.map
vendored
2
dist/files_trashbin-init.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue