From 17afb708106ea26dfc88a814389c6b41904bc0c4 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Wed, 14 Oct 2026 18:51:52 +0200 Subject: [PATCH] super --- backend/drive/test_empty_trash.py | 83 +++++++++++++++++++ backend/drive/urls.py | 1 + backend/drive/views.py | 2 +- backend/drive/views_items.py | 35 +++++++- frontend/src/api/client.js | 1 + .../src/components/drive/DriveExplorer.jsx | 28 ++++++- .../drive/dialogs/PreviewDialog.jsx | 66 ++++++++++++--- frontend/src/hooks/useDriveController.js | 25 +++++- frontend/src/pages/PublicSharePage.jsx | 6 +- 9 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 backend/drive/test_empty_trash.py diff --git a/backend/drive/test_empty_trash.py b/backend/drive/test_empty_trash.py new file mode 100644 index 0000000..5c75883 --- /dev/null +++ b/backend/drive/test_empty_trash.py @@ -0,0 +1,83 @@ +"""USE_SQLITE=true python manage.py test drive.test_empty_trash""" +import tempfile +from pathlib import Path +from uuid import uuid4 + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from .models import DriveItem, SpaceGrant +from .spaces import ensure_private_space, ensure_space +from .storage_layout import disk_path + + +class EmptyTrashTests(TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + root = Path(temp.name) + config = override_settings(DRIVE_STORAGE_ROOT=root, FILE_UPLOAD_TEMP_DIR=str(root / '.tmp')) + config.enable() + self.addCleanup(config.disable) + self.admin = get_user_model().objects.create_superuser('admin-trash', 'admin@example.org', 'pass') + self.other = get_user_model().objects.create_user('private-owner', password='pass') + self.shared = ensure_space(self.admin) + SpaceGrant.objects.create(user=self.other, space=self.shared, permissions=['view', 'download']) + self.private = ensure_private_space(self.other) + self.client = APIClient() + self.client.force_authenticate(self.admin) + + def file(self, space, owner, filename, relative_location): + location = '/'.join(p for p in (space.relative_path, relative_location) if p) + path = disk_path(location) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(filename.encode()) + record = DriveItem.objects.create( + space=space, owner=owner, name=filename, mime_type='text/plain', + storage_key=str(uuid4()), storage_location=location, + size=len(filename), trashed_at=timezone.now(), + ) + return record, path + + def test_empty_trash_removes_accessible_files_but_not_other_private_files(self): + item1, path1 = self.file(self.shared, self.admin, 'duplicata.txt', '.gooddrive-trash/id1/duplicata.txt') + item2, path2 = self.file(self.shared, self.admin, 'duplicata.txt', '.gooddrive-trash/id2/duplicata.txt') + secret, secret_path = self.file(self.private, self.other, 'prive.txt', '.gooddrive-trash/private/prive.txt') + with self.captureOnCommitCallbacks(execute=True): + response = self.client.delete('/api/drive/trash/') + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data['deleted'], 2) + self.assertFalse(DriveItem.objects.filter(pk=item1.pk).exists()) + self.assertFalse(DriveItem.objects.filter(pk=item2.pk).exists()) + self.assertTrue(DriveItem.objects.filter(pk=secret.pk).exists()) + self.assertFalse(path1.exists()) + self.assertFalse(path2.exists()) + self.assertTrue(secret_path.exists()) + + def test_empty_trash_requires_authentication(self): + self.client.force_authenticate(user=None) + response = self.client.delete('/api/drive/trash/') + self.assertEqual(response.status_code, 401) + + def test_empty_trash_never_deletes_active_files(self): + active, path = self.file(self.shared, self.admin, 'actif.txt', 'actif.txt') + DriveItem.objects.filter(pk=active.pk).update(trashed_at=None) + response = self.client.delete('/api/drive/trash/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['deleted'], 0) + self.assertTrue(path.exists()) + self.assertTrue(DriveItem.objects.filter(pk=active.pk).exists()) + + def test_reader_cannot_empty_trash(self): + reader = get_user_model().objects.create_user('reader', password='pass') + SpaceGrant.objects.create(user=reader, space=self.shared, permissions=['view', 'download']) + item, path = self.file(self.shared, self.admin, 'lecture.txt', '.gooddrive-trash/reader/lecture.txt') + self.client.force_authenticate(reader) + response = self.client.delete('/api/drive/trash/') + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data['deleted'], 0) + self.assertEqual(response.data['skipped'], 1) + self.assertTrue(path.exists()) + self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists()) diff --git a/backend/drive/urls.py b/backend/drive/urls.py index b8ff2d9..2d3bd8a 100644 --- a/backend/drive/urls.py +++ b/backend/drive/urls.py @@ -26,6 +26,7 @@ urlpatterns=[ path('invitations/',views_spaces.create_invitation), path('invitations//revoke/',views_spaces.revoke_invitation), path('items/',views.listing), + path('trash/',views.empty_trash), path('stats/',views.stats), path('folders/',views.create_folder), path('uploads/',views.upload), diff --git a/backend/drive/views.py b/backend/drive/views.py index 81d1d2e..292930b 100644 --- a/backend/drive/views.py +++ b/backend/drive/views.py @@ -5,7 +5,7 @@ Implementations are grouped by responsibility without changing route names. from .views_items import (me, listing, stats, create_folder, update_item, - trash_item, restore_item, delete_item) + trash_item, restore_item, delete_item, empty_trash) from .views_transfers import (upload, upload_start, upload_status, upload_chunk, upload_finish, upload_cancel, upload_pending) from .views_content import content_url, content diff --git a/backend/drive/views_items.py b/backend/drive/views_items.py index 9d1282a..a5b76a5 100644 --- a/backend/drive/views_items.py +++ b/backend/drive/views_items.py @@ -11,7 +11,7 @@ from .permissions import has_space_right, item_rights from .quotas import quota_state from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination from .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, NameConflict -from .trash_cleanup import delete_trashed_item +from .trash_cleanup import delete_trashed_item, has_active_uploads_in_tree @api_view(['GET']) @@ -224,3 +224,36 @@ def delete_item(request,item_id): except (OSError,ValueError) as exc: return error(f'Suppression impossible : {exc}',409) return Response(status=204) + + +@api_view(['DELETE']) +def empty_trash(request): + """Permanently delete only trashed items for which the caller has Delete. + + Traverse all spaces rather than only the first 500 results in the UI. + An admin never acquires access to another user's private space. + """ + from .spaces import can_read_space + allowed_spaces = [s.pk for s in DriveSpace.objects.all() if can_read_space(request.user, s)] + candidates = list(DriveItem.objects.filter( + space_id__in=allowed_spaces, trashed_at__isnull=False + ).order_by('trashed_at').values_list('pk', flat=True)) + deleted = skipped = 0 + for pk in candidates: + # A parent already purged may have removed this item by cascade. + item = DriveItem.objects.select_related('owner', 'parent', 'space').filter(pk=pk).first() + if item is None: + continue + if not readable(item, request.user, allow_trashed=True) or not writable(item, request.user, 'delete'): + skipped += 1 + continue + if has_active_uploads_in_tree(item): + skipped += 1 + continue + try: + delete_trashed_item(item) + except (ValueError, OSError): + skipped += 1 + else: + deleted += 1 + return Response({'deleted': deleted, 'skipped': skipped}) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index d22d72c..37c6515 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -82,6 +82,7 @@ export const drive={ folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data), patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data), trash:id=>api.post(`/drive/items/${id}/trash/`), + emptyTrash:()=>api.delete('/drive/trash/').then(r=>r.data), restore:id=>api.post(`/drive/items/${id}/restore/`), remove:id=>api.delete(`/drive/items/${id}/delete/`), link:(id,inline=false)=>api.post(`/drive/items/${id}/link/`,{inline}).then(r=>r.data.url), diff --git a/frontend/src/components/drive/DriveExplorer.jsx b/frontend/src/components/drive/DriveExplorer.jsx index 3c4952f..7183fff 100644 --- a/frontend/src/components/drive/DriveExplorer.jsx +++ b/frontend/src/components/drive/DriveExplorer.jsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { - ChevronRight, Clock3, FolderOpen, Grid2X2, List as ListIcon, + ChevronRight, Clock3, FolderOpen, Grid2X2, List as ListIcon, Trash2, LoaderCircle, Search, Upload, } from 'lucide-react' import FileList from './FileList' @@ -10,15 +10,20 @@ import FileGrid from './FileGrid' import Banner from './StatusBanner' import { headerNames } from '@/config/navigation' import ContextActions, {fileOptions} from './ContextActions' +import { + AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, +} from '@/components/ui/alert-dialog' export default function DriveExplorer({ controller, user }) { const { view, setParent, trail, items, stats, query, setQuery, search, loading, error, setError, note, setNote, layout, setLayout, sort, setSort, - display, goRoot, openEntry, startAction, performItemAction, filesInput, isTrash, canWrite, activeSpace, + display, goRoot, openEntry, startAction, performItemAction, filesInput, isTrash, canWrite, activeSpace, emptyTrash, emptyingTrash, } = controller const [context, setContext] = useState(null) + const [confirmEmptyTrash, setConfirmEmptyTrash] = useState(false) const closeContext = useCallback(() => setContext(null), []) const openContext = (item,x,y) => { const options=fileOptions(item,isTrash,view==='shared') @@ -75,6 +80,11 @@ export default function DriveExplorer({ controller, user }) {
+ {isTrash && } Trier :