parfait
This commit is contained in:
parent
9834626cf4
commit
d94a36d9a7
39
backend/drive/management/commands/cleanup_trash.py
Normal file
39
backend/drive/management/commands/cleanup_trash.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Purge items trashed at least 5 days ago (run by the server hourly)."""
|
||||
from datetime import timedelta
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
from drive.models import DriveItem
|
||||
from drive.trash_cleanup import delete_trashed_item, has_active_uploads_in_tree
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Supprime définitivement les éléments dans la corbeille depuis 5 jours.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--days', type=int, default=5)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = options['days']
|
||||
if days < 1:
|
||||
raise CommandError('Le délai doit être supérieur ou égal à 1 jour.')
|
||||
limit = timezone.now() - timedelta(days=days)
|
||||
purged = skipped = 0
|
||||
# Fetch IDs first: cascading parent deletion can remove children.
|
||||
ids = list(DriveItem.objects.filter(trashed_at__lte=limit).order_by('trashed_at').values_list('pk', flat=True))
|
||||
for pk in ids:
|
||||
item = DriveItem.objects.filter(pk=pk).select_related('space','parent').first()
|
||||
if item is None:
|
||||
continue
|
||||
try:
|
||||
if has_active_uploads_in_tree(item):
|
||||
skipped += 1
|
||||
continue
|
||||
if not options['dry_run']:
|
||||
delete_trashed_item(item)
|
||||
purged += 1
|
||||
except (OSError, ValueError) as exc:
|
||||
skipped += 1
|
||||
self.stderr.write(f'Élément {pk} conservé : {exc}')
|
||||
verb = 'à supprimer' if options['dry_run'] else 'supprimé(s)'
|
||||
self.stdout.write(f'{purged} élément(s) {verb}, {skipped} ignoré(s).')
|
||||
@ -2,6 +2,28 @@
|
||||
from .models import ItemGrant, SpaceGrant
|
||||
|
||||
RIGHTS = ('view', 'download', 'add', 'modify', 'delete', 'share', 'manage_access', 'create_spaces', 'manage_space')
|
||||
# A file/folder share never delegates space administration or deletion.
|
||||
ITEM_SHARE_RIGHTS = ('view', 'download', 'modify', 'add')
|
||||
|
||||
|
||||
def available_item_share_rights(item):
|
||||
return tuple(right for right in ITEM_SHARE_RIGHTS if right != 'add' or item.is_folder)
|
||||
|
||||
|
||||
def normalized_item_share(value, item):
|
||||
rights = normalized(value)
|
||||
if not set(rights).issubset(available_item_share_rights(item)):
|
||||
raise ValueError('Ce droit ne peut pas être attribué sur ce partage.')
|
||||
return rights
|
||||
|
||||
|
||||
def assert_item_delegation(issuer_rights, desired, item):
|
||||
rights = normalized_item_share(desired, item)
|
||||
if not set(rights).issubset(set(issuer_rights)):
|
||||
raise ValueError('Tu ne peux pas attribuer des permissions supérieures aux tiennes.')
|
||||
return rights
|
||||
|
||||
|
||||
PRESETS = {
|
||||
'reader': ('view', 'download'),
|
||||
'contributor': ('view', 'download', 'add', 'modify'),
|
||||
@ -93,7 +115,7 @@ def item_rights(user, item):
|
||||
seen.add(current.pk)
|
||||
grant = ItemGrant.objects.filter(user=user, item=current).first()
|
||||
if grant:
|
||||
return set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader'])
|
||||
return (set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader'])) & set(available_item_share_rights(current))
|
||||
current = current.parent
|
||||
return set()
|
||||
|
||||
|
||||
133
backend/drive/test_share_permissions_and_retention.py
Normal file
133
backend/drive/test_share_permissions_and_retention.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""Tests: USE_SQLITE=true python manage.py test drive.test_share_permissions_and_retention"""
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from .models import DriveItem, DriveSpace, ItemGrant, UploadSession
|
||||
from .permissions import item_rights
|
||||
from .spaces import ensure_space, ensure_private_space
|
||||
|
||||
|
||||
class ShareAndTrashTests(TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
cfg = override_settings(DRIVE_STORAGE_ROOT=self.root,
|
||||
FILE_UPLOAD_TEMP_DIR=str(self.root/'.uploads'))
|
||||
cfg.enable()
|
||||
self.addCleanup(cfg.disable)
|
||||
User = get_user_model()
|
||||
self.owner = User.objects.create_user('share-owner',password='abc1234567890')
|
||||
self.recipient = User.objects.create_user('share-recipient',password='abc1234567890')
|
||||
self.space = DriveSpace.objects.create(
|
||||
user=self.owner,relative_path='',display_name='Partagé')
|
||||
self.private = ensure_private_space(self.owner)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
|
||||
def make_item(self, name, folder=False, parent=None, space=None):
|
||||
space = space or self.private
|
||||
location = '/'.join(filter(None,(space.relative_path, parent.name if parent else '', name)))
|
||||
entry = DriveItem.objects.create(owner=self.owner, space=space, parent=parent,
|
||||
name=name,is_folder=folder, storage_key='' if folder else 'f'*36,
|
||||
storage_location='' if folder else location,size=0 if folder else 4)
|
||||
path = self.root / location
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if folder:path.mkdir(exist_ok=True)
|
||||
else:path.write_bytes(b'data')
|
||||
return entry, path
|
||||
|
||||
def share(self, item, permissions):
|
||||
return self.client.post(f'/api/drive/items/{item.pk}/shares/',
|
||||
{'user_id':self.recipient.pk,'permissions':permissions},format='json')
|
||||
|
||||
def test_file_shares_only_allow_view_download_modify(self):
|
||||
file, _ = self.make_item('photo.jpg')
|
||||
for right in ('add','delete','share','manage_access','create_spaces','manage_space'):
|
||||
result = self.share(file,['view',right])
|
||||
self.assertEqual(result.status_code,403,(right,result.data))
|
||||
success = self.share(file,['view','modify'])
|
||||
self.assertEqual(success.status_code,201,success.data)
|
||||
self.client.force_authenticate(user=self.recipient)
|
||||
self.assertIn('modify',item_rights(self.recipient,file))
|
||||
self.assertNotIn('delete',item_rights(self.recipient,file))
|
||||
self.assertEqual(self.client.post(f'/api/drive/items/{file.pk}/trash/').status_code,403)
|
||||
response=self.client.patch(f'/api/drive/items/{file.pk}/',{'name':'new.jpg'},format='json')
|
||||
self.assertEqual(response.status_code,200,response.data)
|
||||
self.assertEqual(self.client.patch(f'/api/drive/items/{file.pk}/',{'parent':None},format='json').status_code,200)
|
||||
self.assertEqual(self.client.post(f'/api/drive/items/{file.pk}/public-links/').status_code,403)
|
||||
|
||||
def test_folder_add_inherited_but_no_delete_or_sharing(self):
|
||||
folder, _ = self.make_item('docs',folder=True)
|
||||
child, _ = self.make_item('a.txt',parent=folder)
|
||||
response=self.share(folder,['view','download','modify','add'])
|
||||
self.assertEqual(response.status_code,201,response.data)
|
||||
self.client.force_authenticate(user=self.recipient)
|
||||
self.assertIn('add',item_rights(self.recipient,folder))
|
||||
self.assertIn('modify',item_rights(self.recipient,child))
|
||||
self.assertNotIn('share',item_rights(self.recipient,child))
|
||||
self.assertEqual(self.client.post(f'/api/drive/folders/',{
|
||||
'parent':str(folder.pk),'space':self.private.pk,'name':'new'},format='json').status_code,201)
|
||||
self.assertEqual(self.client.post(f'/api/drive/items/{child.pk}/trash/').status_code,403)
|
||||
self.assertEqual(self.client.patch(f'/api/drive/items/{folder.pk}/',{'parent':None},format='json').status_code,200)
|
||||
|
||||
def test_old_unsafe_share_rights_are_clamped_and_removing_share_preserves_file(self):
|
||||
file,path=self.make_item('old.txt')
|
||||
grant=ItemGrant.objects.create(item=file,user=self.recipient,created_by=self.owner,
|
||||
permissions=['view','download','add','delete','share','manage_access','manage_space'])
|
||||
self.assertEqual(item_rights(self.recipient,file),{'view','download'})
|
||||
self.client.force_authenticate(user=self.recipient)
|
||||
shared=self.client.get('/api/drive/items/',{'view':'shared'})
|
||||
self.assertEqual(shared.status_code,200,shared.data)
|
||||
self.assertEqual(shared.data['items'][0]['share_root_id'],str(file.pk))
|
||||
self.assertEqual(self.client.post(f'/api/drive/items/{file.pk}/trash/').status_code,403)
|
||||
gone=self.client.delete(f'/api/drive/items/{file.pk}/my-share/')
|
||||
self.assertEqual(gone.status_code,204)
|
||||
self.assertFalse(ItemGrant.objects.filter(pk=grant.pk).exists())
|
||||
self.assertTrue(DriveItem.objects.filter(pk=file.pk).exists())
|
||||
self.assertEqual(path.read_bytes(),b'data')
|
||||
|
||||
def test_old_unsafe_folder_grant_does_not_allow_space_administration(self):
|
||||
folder,_=self.make_item('shared',folder=True)
|
||||
child,_=self.make_item('x.txt',parent=folder)
|
||||
ItemGrant.objects.create(item=folder,user=self.recipient,created_by=self.owner,
|
||||
permissions=['view','download','add','delete','modify','share','manage_access','create_spaces','manage_space'])
|
||||
self.assertEqual(item_rights(self.recipient,child),{'view','download','modify','add'})
|
||||
self.client.force_authenticate(user=self.recipient)
|
||||
self.assertEqual(self.client.delete(f'/api/drive/items/{child.pk}/my-share/').status_code,204)
|
||||
self.assertTrue(DriveItem.objects.filter(pk=child.pk).exists())
|
||||
|
||||
def test_cleanup_after_five_days_with_recent_item_preserved(self):
|
||||
stale,path_stale=self.make_item('old.bin')
|
||||
recent,path_recent=self.make_item('recent.bin')
|
||||
DriveItem.objects.filter(pk=stale.pk).update(trashed_at=timezone.now()-timedelta(days=6))
|
||||
DriveItem.objects.filter(pk=recent.pk).update(trashed_at=timezone.now()-timedelta(days=4))
|
||||
# TestCase wraps callbacks in a transaction. Flush on_commit manually.
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
call_command('cleanup_trash',days=5)
|
||||
self.assertFalse(DriveItem.objects.filter(pk=stale.pk).exists())
|
||||
self.assertFalse(path_stale.exists())
|
||||
self.assertTrue(DriveItem.objects.filter(pk=recent.pk).exists())
|
||||
self.assertTrue(path_recent.exists())
|
||||
|
||||
def test_cleanup_dry_run_and_active_upload_delay(self):
|
||||
folder,path=self.make_item('pending',folder=True)
|
||||
file,_=self.make_item('part.txt',parent=folder)
|
||||
DriveItem.objects.filter(pk=folder.pk).update(trashed_at=timezone.now()-timedelta(days=7))
|
||||
call_command('cleanup_trash',days=5,dry_run=True)
|
||||
self.assertTrue(DriveItem.objects.filter(pk=folder.pk).exists())
|
||||
session=UploadSession.objects.create(owner=self.owner,space=self.private,parent=folder,
|
||||
name='upload.txt',total_size=5)
|
||||
call_command('cleanup_trash',days=5)
|
||||
self.assertTrue(DriveItem.objects.filter(pk=folder.pk).exists())
|
||||
session.delete()
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
call_command('cleanup_trash',days=5)
|
||||
self.assertFalse(DriveItem.objects.filter(pk=folder.pk).exists())
|
||||
self.assertFalse(DriveItem.objects.filter(pk=file.pk).exists())
|
||||
self.assertFalse(path.exists())
|
||||
46
backend/drive/trash_cleanup.py
Normal file
46
backend/drive/trash_cleanup.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Permanent deletion shared by the trash API and scheduled retention cleanup."""
|
||||
from django.db import transaction
|
||||
from .models import DriveItem, UploadSession
|
||||
from .services import descendant_files
|
||||
from .storage_layout import disk_path, file_location
|
||||
|
||||
|
||||
def has_active_uploads_in_tree(item):
|
||||
"""Do not purge directories while a resumable upload targets a descendant."""
|
||||
if not item.is_folder:
|
||||
return False
|
||||
pending = [item.pk]
|
||||
while pending:
|
||||
batch, pending = pending[:250], pending[250:]
|
||||
if UploadSession.objects.filter(parent_id__in=batch).exists():
|
||||
return True
|
||||
pending.extend(DriveItem.objects.filter(parent_id__in=batch, is_folder=True).values_list('pk', flat=True))
|
||||
return False
|
||||
|
||||
|
||||
def delete_trashed_item(item):
|
||||
"""Delete the SQL tree, then its tracked files after commit.
|
||||
|
||||
Untracked files and directories are not erased. Failures resolving a stored
|
||||
path abort before the SQL delete; missing physical files are tolerated.
|
||||
"""
|
||||
if item.trashed_at is None:
|
||||
raise ValueError('L’élément doit être dans la corbeille.')
|
||||
if has_active_uploads_in_tree(item):
|
||||
raise ValueError('Un transfert est encore en cours dans ce dossier.')
|
||||
files = [disk_path(key) for key in descendant_files(item)]
|
||||
folder = disk_path(file_location(item.space, item.parent, item.name)) if item.is_folder else None
|
||||
with transaction.atomic():
|
||||
item.delete()
|
||||
def remove_files():
|
||||
for path in files:
|
||||
path.unlink(missing_ok=True)
|
||||
if folder and folder.is_dir() and not folder.is_symlink():
|
||||
# Only remove empty directories, never user data untracked by SQL.
|
||||
for candidate in sorted(folder.rglob('*'), key=lambda p:len(p.parts), reverse=True):
|
||||
if candidate.is_dir() and not candidate.is_symlink():
|
||||
try: candidate.rmdir()
|
||||
except OSError: pass
|
||||
try: folder.rmdir()
|
||||
except OSError: pass
|
||||
transaction.on_commit(remove_files)
|
||||
@ -9,6 +9,7 @@ urlpatterns=[
|
||||
path('admin/users/<int:user_id>/access/<str:kind>/<int:space_id>/',views_spaces.admin_revoke_user_access),
|
||||
path('shares/users/search/',views_shares.search_share_users),
|
||||
path('items/<uuid:item_id>/shares/',views_shares.manage_item_shares),
|
||||
path('items/<uuid:item_id>/my-share/',views_shares.leave_item_share),
|
||||
path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share),
|
||||
path('items/<uuid:item_id>/shares/<int:grant_id>/permissions/',views_shares.update_item_share),
|
||||
path('items/<uuid:item_id>/public-links/',views_shares.public_links),
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
"""Space-bound navigation, folder management and trash endpoints."""
|
||||
from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum, Count
|
||||
@ -11,8 +9,9 @@ from .models import DriveItem, DriveSpace, ItemGrant
|
||||
from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_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, descendant_files
|
||||
from .storage_layout import disk_path, file_location, assert_available, prepare_parent, relocate_item, NameConflict
|
||||
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, NameConflict
|
||||
from .trash_cleanup import delete_trashed_item
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@ -71,6 +70,11 @@ def listing(request):
|
||||
if readable(n,user,allow_trashed=view=='trash'):
|
||||
info=item_info(n,user)
|
||||
if view in ('trash','shared'):info['space_name']=n.space.display_name if not n.space.private_owner_id else 'Mes fichiers'
|
||||
if view=='shared':
|
||||
for ancestor in chain(n):
|
||||
if ItemGrant.objects.filter(item=ancestor,user=user).exists():
|
||||
info['share_root_id']=str(ancestor.pk)
|
||||
break
|
||||
items.append(info)
|
||||
if len(items)>=500:break
|
||||
trail=[]
|
||||
@ -138,6 +142,15 @@ def update_item(request,item_id):
|
||||
except PermissionError as exc:return error(str(exc),403)
|
||||
if 'is_shared' in data:
|
||||
return error('Le partage global est désactivé : utilise les espaces et invitations.')
|
||||
if 'parent' in data and (new_parent.pk if new_parent else None)!=item.parent_id:
|
||||
# Moving to the space root requires space membership with Add.
|
||||
# A recipient can reorganize only inside a folder that grants Add.
|
||||
if new_parent is None and not has_space_right(request.user,item.space,'add'):
|
||||
return error('Déplacement hors du dossier partagé interdit.',403)
|
||||
if not has_space_right(request.user,item.space,'modify'):
|
||||
# Do not allow moving the shared root itself: it belongs to its creator.
|
||||
if ItemGrant.objects.filter(item=item,user=request.user).exists():
|
||||
return error('Impossible de déplacer le dossier ou fichier partagé lui-même.',403)
|
||||
if new_parent and (new_parent.pk==item.pk or item.pk in [n.pk for n in chain(new_parent)]):
|
||||
return error('Déplacement impossible dans un sous-dossier de cet élément.')
|
||||
if new_name==item.name and (new_parent.pk if new_parent else None)==item.parent_id:
|
||||
@ -179,24 +192,8 @@ def delete_item(request,item_id):
|
||||
item=get_item(item_id)
|
||||
if not writable(item,request.user,'delete'):return error('Accès refusé.',403)
|
||||
if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.')
|
||||
files=descendant_files(item)
|
||||
folder_rel=file_location(item.space,item.parent,item.name) if item.is_folder else None
|
||||
with transaction.atomic():
|
||||
item.delete()
|
||||
def remove_files():
|
||||
root=Path(settings.DRIVE_STORAGE_ROOT).resolve()
|
||||
for key in files:
|
||||
path=(root/key).resolve()
|
||||
if path.is_relative_to(root):path.unlink(missing_ok=True)
|
||||
if folder_rel:
|
||||
# Remove only empty folders. Untracked data is never deleted.
|
||||
base=disk_path(folder_rel)
|
||||
if base.is_dir():
|
||||
for candidate in sorted(base.rglob('*'),key=lambda p:len(p.parts),reverse=True):
|
||||
if candidate.is_dir() and not candidate.is_symlink():
|
||||
try:candidate.rmdir()
|
||||
except OSError:pass
|
||||
try:base.rmdir()
|
||||
except OSError:pass
|
||||
transaction.on_commit(remove_files)
|
||||
try:
|
||||
delete_trashed_item(item)
|
||||
except (OSError,ValueError) as exc:
|
||||
return error(f'Suppression impossible : {exc}',409)
|
||||
return Response(status=204)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Read-only sharing of individual items and folder descendants.
|
||||
"""Scoped user sharing of individual items and folder descendants.
|
||||
|
||||
User shares grant no space-level privileges; public links use cryptographically
|
||||
random bearer tokens and keep only SHA-256 digests in the database.
|
||||
@ -17,7 +17,7 @@ from rest_framework.response import Response
|
||||
from .models import DriveItem, ItemGrant, PublicItemLink
|
||||
from .services import error, get_item, readable, writable, chain, item_info
|
||||
from .spaces import storage_path
|
||||
from .permissions import item_rights, assert_delegation, PRESETS, normalized
|
||||
from .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS
|
||||
from .views_content import safe_preview_type, read_bytes
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ def shareable(item, user):
|
||||
|
||||
def share_info(grant):
|
||||
u=grant.user
|
||||
return {'id': grant.pk,'permissions':grant.permissions if grant.permissions is not None else list(PRESETS['reader']),'user_id':u.pk,'username':u.username,
|
||||
return {'id': grant.pk,'permissions':sorted((set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader'])) & set(available_item_share_rights(grant.item)),key=('view','download','modify','add').index),'user_id':u.pk,'username':u.username,
|
||||
'first_name':u.first_name,'last_name':u.last_name}
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ def manage_item_shares(request,item_id):
|
||||
item=get_item(item_id)
|
||||
if not shareable(item,request.user):return error('Partage non autorisé.',403)
|
||||
if request.method=='GET':
|
||||
grants=ItemGrant.objects.filter(item=item).select_related('user').order_by('user__username')
|
||||
grants=ItemGrant.objects.filter(item=item).select_related('user','item').order_by('user__username')
|
||||
links=PublicItemLink.objects.filter(item=item,revoked_at__isnull=True).order_by('-created_at')
|
||||
return Response({'users':[share_info(g) for g in grants],
|
||||
'links':[{'id':l.pk,'created_at':l.created_at.isoformat()} for l in links]})
|
||||
@ -61,7 +61,7 @@ def manage_item_shares(request,item_id):
|
||||
except get_user_model().DoesNotExist:return error('Utilisateur introuvable.',404)
|
||||
if account.pk==request.user.pk:return error('Cet élément t’appartient déjà.')
|
||||
try:
|
||||
rights=assert_delegation(item_rights(request.user,item),request.data.get('permissions',list(PRESETS['reader'])))
|
||||
rights=assert_item_delegation(item_rights(request.user,item),request.data.get('permissions',list(PRESETS['reader'])),item)
|
||||
except ValueError as exc:return error(str(exc),403)
|
||||
grant,created=ItemGrant.objects.get_or_create(item=item,user=account,defaults={'created_by':request.user,'permissions':rights})
|
||||
if not created:return error('Cet élément est déjà partagé avec cet utilisateur.',409)
|
||||
@ -73,11 +73,11 @@ def update_item_share(request,item_id,grant_id):
|
||||
item=get_item(item_id)
|
||||
if not shareable(item,request.user):return error('Partage non autorisé.',403)
|
||||
try:
|
||||
rights=assert_delegation(item_rights(request.user,item), request.data.get('permissions'))
|
||||
rights=assert_item_delegation(item_rights(request.user,item), request.data.get('permissions'),item)
|
||||
except ValueError as exc:return error(str(exc),403)
|
||||
changed=ItemGrant.objects.filter(item=item,pk=grant_id).update(permissions=rights)
|
||||
if not changed:return error('Partage introuvable.',404)
|
||||
return Response(share_info(ItemGrant.objects.select_related('user').get(pk=grant_id)))
|
||||
return Response(share_info(ItemGrant.objects.select_related('user','item').get(pk=grant_id)))
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
@ -89,6 +89,18 @@ def remove_item_share(request,item_id,grant_id):
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
def leave_item_share(request,item_id):
|
||||
"""Remove only the current recipient's share, never the underlying item."""
|
||||
item=get_item(item_id)
|
||||
for node in chain(item):
|
||||
grant=ItemGrant.objects.filter(item=node,user=request.user).first()
|
||||
if grant is not None:
|
||||
grant.delete()
|
||||
return Response(status=204)
|
||||
return error('Aucun partage à retirer.',404)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def public_links(request,item_id):
|
||||
item=get_item(item_id)
|
||||
|
||||
@ -12,4 +12,11 @@ until python manage.py migrate --noinput; do
|
||||
sleep 2
|
||||
done
|
||||
python manage.py ensure_admin
|
||||
# Nettoyage automatique au lancement puis chaque heure.
|
||||
(
|
||||
while :; do
|
||||
python manage.py cleanup_trash --days 5 || echo 'Nettoyage de la corbeille reporté.' >&2
|
||||
sleep 3600
|
||||
done
|
||||
) &
|
||||
exec python manage.py runserver 0.0.0.0:1029
|
||||
|
||||
@ -69,6 +69,7 @@ export const drive={
|
||||
shareItem:(id,user_id,permissions)=>api.post(`/drive/items/${id}/shares/`,{user_id,permissions}).then(r=>r.data),
|
||||
shareItemPermissions:(id,grant_id,permissions)=>api.patch(`/drive/items/${id}/shares/${grant_id}/permissions/`,{permissions}).then(r=>r.data),
|
||||
unshareItem:(id,grant_id)=>api.delete(`/drive/items/${id}/shares/${grant_id}/`),
|
||||
leaveItemShare:id=>api.delete(`/drive/items/${id}/my-share/`),
|
||||
publicItem:id=>api.post(`/drive/items/${id}/public-links/`).then(r=>r.data),
|
||||
revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`),
|
||||
publicBrowse:(token,parent)=>publicApi.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data),
|
||||
|
||||
@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users, Share2 } from 'lucide-react'
|
||||
|
||||
export const fileOptions = (item, trash) => {
|
||||
export const fileOptions = (item, trash, shared=false) => {
|
||||
if (!item) return []
|
||||
const rights = new Set(item.permissions || [])
|
||||
if (trash) return rights.has('delete') ? [
|
||||
@ -17,7 +17,7 @@ export const fileOptions = (item, trash) => {
|
||||
{ id: 'rename', title: 'Renommer', icon: Pencil },
|
||||
{ id: 'move', title: 'Déplacer', icon: FolderInput },
|
||||
] : []),
|
||||
...(rights.has('delete') ? [{id:'trash',title:'Mettre à la corbeille',icon:Trash2,danger:true}] : []),
|
||||
...(shared && item.share_root_id ? [{id:'remove-share',title:'Retirer de mes partages',icon:Trash2,danger:true}] : rights.has('delete') && !shared ? [{id:'trash',title:'Mettre à la corbeille',icon:Trash2,danger:true}] : []),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ export default function DriveExplorer({ controller, user }) {
|
||||
const [context, setContext] = useState(null)
|
||||
const closeContext = useCallback(() => setContext(null), [])
|
||||
const openContext = (item,x,y) => {
|
||||
const options=fileOptions(item,isTrash)
|
||||
const options=fileOptions(item,isTrash,view==='shared')
|
||||
if (options.length) setContext({item,x,y,options})
|
||||
}
|
||||
useEffect(() => {
|
||||
@ -115,7 +115,7 @@ export default function DriveExplorer({ controller, user }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ContextActions menu={context && {...context, options:fileOptions(context.item,isTrash)}} onClose={closeContext}
|
||||
<ContextActions menu={context && {...context, options:fileOptions(context.item,isTrash,view==='shared')}} onClose={closeContext}
|
||||
onChoose={type => performItemAction(context.item,type)} />
|
||||
<footer className="flex justify-between gap-2 px-1 pt-3 text-[11px] text-muted-foreground">
|
||||
<span className="hidden md:inline">Clic droit sur un élément pour les actions.</span>
|
||||
|
||||
@ -1,11 +1,24 @@
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
|
||||
export const RIGHTS = [
|
||||
['view','Consulter'], ['download','Télécharger'], ['add','Ajouter'],
|
||||
['modify','Modifier'], ['delete','Supprimer'], ['share','Partager'],
|
||||
['manage_access','Gérer les accès'], ['create_spaces','Créer des espaces'], ['manage_space','Gérer l’espace'],
|
||||
]
|
||||
export const RIGHT_DESCRIPTIONS = {
|
||||
view: 'Voir les fichiers et parcourir les dossiers.',
|
||||
download: 'Télécharger les fichiers.',
|
||||
add: 'Importer des fichiers et créer des dossiers.',
|
||||
modify: 'Renommer et déplacer des fichiers ou dossiers.',
|
||||
delete: 'Mettre des éléments à la corbeille et les supprimer définitivement.',
|
||||
share: 'Partager des fichiers et dossiers avec des utilisateurs ou via un lien public.',
|
||||
manage_access: 'Inviter des utilisateurs, modifier leurs permissions et retirer leurs accès.',
|
||||
create_spaces: 'Créer de nouveaux sous-espaces dans les espaces autorisés.',
|
||||
manage_space: 'Renommer, supprimer et gérer les paramètres d’un espace, dans la limite des droits reçus.',
|
||||
}
|
||||
export const PRESETS = {
|
||||
reader: ['view','download'],
|
||||
contributor: ['view','download','add','modify'],
|
||||
@ -14,6 +27,9 @@ export const PRESETS = {
|
||||
export default function PermissionsEditor({value=[],onChange,maxRights=RIGHTS.map(([id])=>id),compact=false}) {
|
||||
const maximum = new Set(maxRights || [])
|
||||
const selected = new Set(value || [])
|
||||
// A shared file has no management permissions: avoid showing two identical presets.
|
||||
const presetOptions = Object.entries(PRESETS).map(([key, preset]) => [key, preset.filter(right => maximum.has(right))])
|
||||
.filter(([key, rights], index, all) => all.findIndex(([, other]) => other.join('|') === rights.join('|')) === index)
|
||||
const update = name => {
|
||||
if(!maximum.has(name))return
|
||||
if(name==='view' && selected.has('view'))return
|
||||
@ -21,12 +37,21 @@ export default function PermissionsEditor({value=[],onChange,maxRights=RIGHTS.ma
|
||||
else {selected.add(name);selected.add('view')}
|
||||
onChange(RIGHTS.map(([id])=>id).filter(k=>selected.has(k)&&maximum.has(k)))
|
||||
}
|
||||
return <div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-1.5">{Object.entries(PRESETS).map(([key,rights])=><Button key={key} type="button" size="sm" variant="outline" onClick={()=>onChange(rights.filter(right=>maximum.has(right)))}>{({reader:'Lecteur',contributor:'Contributeur',manager:'Gestionnaire'})[key]}</Button>)}</div>
|
||||
<div className={compact?'grid grid-cols-2 gap-2':'grid gap-2 sm:grid-cols-2'}>
|
||||
{RIGHTS.filter(([key])=>maximum.has(key)).map(([key,label])=><label key={key} className="flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm">
|
||||
<Checkbox checked={selected.has(key)} disabled={key==='view'} onCheckedChange={()=>update(key)} />{label}
|
||||
</label>)}
|
||||
return <TooltipProvider delay={250}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-1.5">{presetOptions.map(([key,rights])=><Button key={key} type="button" size="sm" variant="outline" onClick={()=>onChange(rights)}>{({reader:'Lecteur',contributor:'Contributeur',manager:'Gestionnaire'})[key]}</Button>)}</div>
|
||||
<div className={compact?'grid grid-cols-2 gap-2':'grid gap-2 sm:grid-cols-2'}>
|
||||
{RIGHTS.filter(([key])=>maximum.has(key)).map(([key,label])=><div key={key} className="flex min-w-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm">
|
||||
<Checkbox checked={selected.has(key)} disabled={key==='view'} onCheckedChange={()=>update(key)}/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<button type="button" className="flex min-w-0 flex-1 items-center justify-between gap-1 text-left outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring" aria-label={`${label} : ${RIGHT_DESCRIPTIONS[key]}`} onClick={()=>update(key)} />}>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
<CircleHelp className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{RIGHT_DESCRIPTIONS[key]}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ const ActionButton = ({ icon: Icon, children, destructive = false, ...props }) =
|
||||
)
|
||||
|
||||
export default function ItemActionsDialog({ controller: c }) {
|
||||
const { modal, setModal, action, handleAction, isTrash } = c
|
||||
const { modal, setModal, action, handleAction, isTrash, view } = c
|
||||
const rights = new Set(action?.permissions || [])
|
||||
return (
|
||||
<Dialog open={modal === 'actions'} onOpenChange={open => { if (!open) setModal(null) }}>
|
||||
@ -27,7 +27,8 @@ export default function ItemActionsDialog({ controller: c }) {
|
||||
<ActionButton icon={Pencil} onClick={() => handleAction('rename')}>Renommer</ActionButton>
|
||||
<ActionButton icon={FolderInput} onClick={() => handleAction('move')}>Déplacer</ActionButton>
|
||||
</>}
|
||||
{!isTrash && rights.has('delete') && <ActionButton icon={Trash2} destructive onClick={() => handleAction('trash')}>Corbeille</ActionButton>}
|
||||
{!isTrash && view === 'shared' && action.share_root_id && <ActionButton icon={Trash2} destructive onClick={() => handleAction('remove-share')}>Retirer de mes partages</ActionButton>}
|
||||
{!isTrash && view !== 'shared' && rights.has('delete') && <ActionButton icon={Trash2} destructive onClick={() => handleAction('trash')}>Corbeille</ActionButton>}
|
||||
{rights.has('delete') && isTrash && <>
|
||||
<ActionButton icon={RotateCcw} onClick={() => handleAction('restore')}>Restaurer</ActionButton>
|
||||
<ActionButton icon={Trash2} destructive onClick={() => handleAction('confirmdelete')}>Supprimer</ActionButton>
|
||||
|
||||
@ -17,6 +17,8 @@ export default function ShareItemDialog({ controller }) {
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [rights,setRights]=useState(PRESETS.reader)
|
||||
const allowedRights = ['view','download','modify',...(action?.is_folder?['add']:[])]
|
||||
const maxRights = allowedRights.filter(right => (action?.permissions || ['view','download']).includes(right))
|
||||
const reload = async (id = itemId) => {
|
||||
if (!id) return
|
||||
const data = await drive.itemShares(id)
|
||||
@ -25,7 +27,7 @@ export default function ShareItemDialog({ controller }) {
|
||||
useEffect(() => {
|
||||
if (!itemId) return
|
||||
let active = true
|
||||
setRecord({users:[],links:[]}); setLink(''); setQuery(''); setResults([]); setError('')
|
||||
setRecord({users:[],links:[]}); setLink(''); setQuery(''); setResults([]); setError(''); setRights(PRESETS.reader)
|
||||
drive.itemShares(itemId).then(data => { if(active)setRecord(data) }).catch(e=>{if(active)setError(message(e))})
|
||||
return () => {active=false}
|
||||
}, [itemId])
|
||||
@ -59,7 +61,7 @@ export default function ShareItemDialog({ controller }) {
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold"><UserPlus className="size-4"/> Partager avec un utilisateur</h3>
|
||||
<PermissionsEditor compact value={rights} onChange={setRights} maxRights={action?.permissions || ['view','download']} />
|
||||
<PermissionsEditor compact value={rights} onChange={setRights} maxRights={maxRights} />
|
||||
<Input placeholder="Rechercher un utilisateur…" value={query} onChange={e=>setQuery(e.target.value)}/>
|
||||
{results.length>0 && <div className="max-h-44 overflow-y-auto rounded-lg border p-1">
|
||||
{results.map(user=><button key={user.id} type="button" disabled={busy || record.users.some(u=>u.user_id===user.id)}
|
||||
@ -67,7 +69,7 @@ export default function ShareItemDialog({ controller }) {
|
||||
onClick={()=>addUser(user)}>{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} · @{user.username}</button>)}
|
||||
</div>}
|
||||
{record.users.map(user=><div key={user.id} className="flex items-center justify-between gap-2 rounded-lg border p-2 text-sm">
|
||||
<div className="min-w-0 flex-1 space-y-2"><span className="block truncate">{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} (@{user.username})</span><PermissionsEditor compact value={user.permissions} maxRights={action?.permissions || ['view','download']} onChange={permissions=>perform(()=>drive.shareItemPermissions(itemId,user.id,permissions))}/></div>
|
||||
<div className="min-w-0 flex-1 space-y-2"><span className="block truncate">{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} (@{user.username})</span><PermissionsEditor compact value={user.permissions} maxRights={maxRights} onChange={permissions=>perform(()=>drive.shareItemPermissions(itemId,user.id,permissions))}/></div>
|
||||
<Button size="icon-sm" variant="ghost" disabled={busy} title="Retirer l’accès" onClick={()=>perform(()=>drive.unshareItem(itemId,user.id))}><Trash2 className="size-4"/></Button>
|
||||
</div>)}
|
||||
</section>
|
||||
|
||||
@ -289,6 +289,7 @@ export function useDriveController(user) {
|
||||
const performItemAction = (item, type) => {
|
||||
if (type === 'download') return download(item)
|
||||
if (type === 'open') return openEntry(item)
|
||||
if (type === 'remove-share') return run(() => drive.leaveItemShare(item.share_root_id || item.id))
|
||||
if (type === 'trash') return run(() => drive.trash(item.id))
|
||||
if (type === 'restore') return run(() => drive.restore(item.id))
|
||||
startAction(item, type)
|
||||
@ -298,6 +299,7 @@ export function useDriveController(user) {
|
||||
if (!action) return
|
||||
if (type === 'download') { setModal(null); return download(action) }
|
||||
if (type === 'open') { setModal(null); return openEntry(action) }
|
||||
if (type === 'remove-share') return run(() => drive.leaveItemShare(action.share_root_id || action.id))
|
||||
if (type === 'trash') return run(() => drive.trash(action.id))
|
||||
if (type === 'restore') return run(() => drive.restore(action.id))
|
||||
if (type === 'remove') return run(() => drive.remove(action.id))
|
||||
|
||||
14
start.sh
14
start.sh
@ -40,7 +40,17 @@ echo "Démarrage Django sur :1035"
|
||||
|
||||
python3 manage.py migrate --noinput
|
||||
|
||||
python3 manage.py ensure_admin
|
||||
python manage.py ensure_admin
|
||||
|
||||
# Purge au démarrage puis une fois par heure (éléments de plus de 5 jours).
|
||||
trash_cleanup_loop() {
|
||||
while true; do
|
||||
python3 manage.py cleanup_trash --days 5 || echo "Échec du nettoyage de la corbeille (nouvel essai dans 1 heure)." >&2
|
||||
sleep 3600
|
||||
done
|
||||
}
|
||||
trash_cleanup_loop &
|
||||
CLEANER_PID=$!
|
||||
|
||||
python3 manage.py runserver 0.0.0.0:1035 &
|
||||
BACKEND_PID=$!
|
||||
@ -73,9 +83,11 @@ cleanup() {
|
||||
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
kill "$FRONTEND_PID" 2>/dev/null || true
|
||||
kill "$CLEANER_PID" 2>/dev/null || true
|
||||
|
||||
wait "$BACKEND_PID" 2>/dev/null || true
|
||||
wait "$FRONTEND_PID" 2>/dev/null || true
|
||||
wait "$CLEANER_PID" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap cleanup SIGTERM SIGINT EXIT
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user