This commit is contained in:
Louis Mazin 2026-10-14 18:57:05 +02:00
parent 17afb70810
commit 26e0e05754
3 changed files with 74 additions and 1 deletions

View File

@ -137,6 +137,23 @@ def trash_location(item):
return f'.gooddrive-trash/{item.pk}/{item.name}'
def cleanup_empty_trash_container(item):
"""Remove only this item's empty UUID container after restoration.
A non-empty directory is deliberately kept: it may contain unrelated data
and must never be removed recursively.
"""
container = disk_path(f'.gooddrive-trash/{item.pk}')
try:
container.rmdir()
except FileNotFoundError:
pass
except OSError:
# Non-empty or inaccessible: leave it intact. A later trash operation
# will refuse to overwrite any content that remains here.
pass
def move_to_trash(item, *, migrate_existing=False):
"""Atomic disk/SQL move. Preserve the original name/parent for restoration."""
from django.utils import timezone
@ -155,6 +172,11 @@ def move_to_trash(item, *, migrate_existing=False):
raise FileNotFoundError('Élément absent du stockage, déplacement impossible.')
if target.exists() or target.is_symlink():
raise NameConflict('La destination de la corbeille est déjà utilisée.')
# Previous versions left the UUID container behind when restoring a file.
# Remove only empty legacy containers so the same item can be trashed again.
cleanup_empty_trash_container(item)
if target.parent.exists() or target.parent.is_symlink():
raise NameConflict('Le dossier de corbeille de cet élément contient encore des données.')
target.parent.mkdir(parents=True, exist_ok=False)
moved = False
try:

View File

@ -75,6 +75,54 @@ class TrashIsolationTests(TestCase):
child.refresh_from_db()
self.assertEqual(disk_path(child.storage_location).read_bytes(), b'present')
def test_file_can_be_trashed_again_after_restore(self):
item = self.new_file(name='cycle.txt', content=b'never lose me')
container = disk_path(f'.gooddrive-trash/{item.pk}')
for _ in range(3):
response = self.client.post(f'/api/drive/items/{item.pk}/trash/')
self.assertEqual(response.status_code, 200, response.data)
item.refresh_from_db()
self.assertTrue(container.is_dir())
self.assertEqual(disk_path(item.storage_location).read_bytes(), b'never lose me')
response = self.client.post(f'/api/drive/items/{item.pk}/restore/')
self.assertEqual(response.status_code, 200, response.data)
item.refresh_from_db()
self.assertIsNone(item.trashed_at)
self.assertFalse(container.exists(), 'La restauration doit nettoyer le dossier ID vide')
self.assertEqual(disk_path(item.storage_location).read_bytes(), b'never lose me')
def test_stale_empty_id_folder_from_old_restore_is_reused(self):
item = self.new_file(name='existing.txt')
container = disk_path(f'.gooddrive-trash/{item.pk}')
container.mkdir(parents=True)
response = self.client.post(f'/api/drive/items/{item.pk}/trash/')
self.assertEqual(response.status_code, 200, response.data)
item.refresh_from_db()
self.assertEqual(disk_path(item.storage_location).read_bytes(), b'original')
def test_nonempty_legacy_container_is_never_overwritten(self):
item = self.new_file(name='important.txt', content=b'original')
container = disk_path(f'.gooddrive-trash/{item.pk}')
container.mkdir(parents=True)
(container / 'untracked.txt').write_bytes(b'KEEP')
response = self.client.post(f'/api/drive/items/{item.pk}/trash/')
self.assertEqual(response.status_code, 409)
self.assertEqual((container / 'untracked.txt').read_bytes(), b'KEEP')
item.refresh_from_db()
self.assertIsNone(item.trashed_at)
self.assertEqual(disk_path(item.storage_location).read_bytes(), b'original')
def test_folder_can_be_trashed_again_after_restore(self):
response = self.client.post('/api/drive/folders/', {'name': 'Recettes', 'space': self.space.pk}, format='json')
self.assertEqual(response.status_code, 201, response.data)
item = DriveItem.objects.get(pk=response.data['id'])
container = disk_path(f'.gooddrive-trash/{item.pk}')
for _ in range(2):
self.assertEqual(self.client.post(f'/api/drive/items/{item.pk}/trash/').status_code, 200)
self.assertTrue(container.is_dir())
self.assertEqual(self.client.post(f'/api/drive/items/{item.pk}/restore/').status_code, 200)
self.assertFalse(container.exists())
def test_migrate_existing_trashed_without_resetting_retention(self):
item = self.new_file()
when = timezone.now() - timedelta(days=4)

View File

@ -10,7 +10,7 @@ from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space,
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 .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, cleanup_empty_trash_container, NameConflict
from .trash_cleanup import delete_trashed_item, has_active_uploads_in_tree
@ -209,6 +209,9 @@ def restore_item(request,item_id):
relocate_item(item, parent, name)
item.trashed_at = None
item.save(update_fields=['trashed_at','updated_at'])
# The restored item is no longer in its .gooddrive-trash/<ID>/ folder.
# Leaving that empty directory prevents the next trash operation.
cleanup_empty_trash_container(item)
except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc:
return error(f'Restauration impossible : {exc}',409)
return Response(item_info(item,request.user))