This commit is contained in:
Louis Mazin 2026-10-14 18:09:57 +02:00
parent 9834626cf4
commit d94a36d9a7
16 changed files with 349 additions and 49 deletions

View 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).')

View File

@ -2,6 +2,28 @@
from .models import ItemGrant, SpaceGrant from .models import ItemGrant, SpaceGrant
RIGHTS = ('view', 'download', 'add', 'modify', 'delete', 'share', 'manage_access', 'create_spaces', 'manage_space') 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 = { PRESETS = {
'reader': ('view', 'download'), 'reader': ('view', 'download'),
'contributor': ('view', 'download', 'add', 'modify'), 'contributor': ('view', 'download', 'add', 'modify'),
@ -93,7 +115,7 @@ def item_rights(user, item):
seen.add(current.pk) seen.add(current.pk)
grant = ItemGrant.objects.filter(user=user, item=current).first() grant = ItemGrant.objects.filter(user=user, item=current).first()
if grant: 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 current = current.parent
return set() return set()

View 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())

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

View File

@ -9,6 +9,7 @@ urlpatterns=[
path('admin/users/<int:user_id>/access/<str:kind>/<int:space_id>/',views_spaces.admin_revoke_user_access), 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('shares/users/search/',views_shares.search_share_users),
path('items/<uuid:item_id>/shares/',views_shares.manage_item_shares), 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>/',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>/shares/<int:grant_id>/permissions/',views_shares.update_item_share),
path('items/<uuid:item_id>/public-links/',views_shares.public_links), path('items/<uuid:item_id>/public-links/',views_shares.public_links),

View File

@ -1,6 +1,4 @@
"""Space-bound navigation, folder management and trash endpoints.""" """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.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.db.models import Sum, Count 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 .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 .permissions import has_space_right, item_rights
from .quotas import quota_state 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 .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination
from .storage_layout import disk_path, file_location, assert_available, prepare_parent, relocate_item, NameConflict from .storage_layout import assert_available, prepare_parent, relocate_item, NameConflict
from .trash_cleanup import delete_trashed_item
@api_view(['GET']) @api_view(['GET'])
@ -71,6 +70,11 @@ def listing(request):
if readable(n,user,allow_trashed=view=='trash'): if readable(n,user,allow_trashed=view=='trash'):
info=item_info(n,user) 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 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) items.append(info)
if len(items)>=500:break if len(items)>=500:break
trail=[] trail=[]
@ -138,6 +142,15 @@ def update_item(request,item_id):
except PermissionError as exc:return error(str(exc),403) except PermissionError as exc:return error(str(exc),403)
if 'is_shared' in data: if 'is_shared' in data:
return error('Le partage global est désactivé : utilise les espaces et invitations.') 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)]): 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.') 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: 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) item=get_item(item_id)
if not writable(item,request.user,'delete'):return error('Accès refusé.',403) 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.') if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.')
files=descendant_files(item) try:
folder_rel=file_location(item.space,item.parent,item.name) if item.is_folder else None delete_trashed_item(item)
with transaction.atomic(): except (OSError,ValueError) as exc:
item.delete() return error(f'Suppression impossible : {exc}',409)
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)
return Response(status=204) return Response(status=204)

View File

@ -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 User shares grant no space-level privileges; public links use cryptographically
random bearer tokens and keep only SHA-256 digests in the database. 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 .models import DriveItem, ItemGrant, PublicItemLink
from .services import error, get_item, readable, writable, chain, item_info from .services import error, get_item, readable, writable, chain, item_info
from .spaces import storage_path 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 from .views_content import safe_preview_type, read_bytes
@ -27,7 +27,7 @@ def shareable(item, user):
def share_info(grant): def share_info(grant):
u=grant.user 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} '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) item=get_item(item_id)
if not shareable(item,request.user):return error('Partage non autorisé.',403) if not shareable(item,request.user):return error('Partage non autorisé.',403)
if request.method=='GET': 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') links=PublicItemLink.objects.filter(item=item,revoked_at__isnull=True).order_by('-created_at')
return Response({'users':[share_info(g) for g in grants], return Response({'users':[share_info(g) for g in grants],
'links':[{'id':l.pk,'created_at':l.created_at.isoformat()} for l in links]}) '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) 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à.') if account.pk==request.user.pk:return error('Cet élément t’appartient déjà.')
try: 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) 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}) 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) 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) item=get_item(item_id)
if not shareable(item,request.user):return error('Partage non autorisé.',403) if not shareable(item,request.user):return error('Partage non autorisé.',403)
try: 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) except ValueError as exc:return error(str(exc),403)
changed=ItemGrant.objects.filter(item=item,pk=grant_id).update(permissions=rights) changed=ItemGrant.objects.filter(item=item,pk=grant_id).update(permissions=rights)
if not changed:return error('Partage introuvable.',404) 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']) @api_view(['DELETE'])
@ -89,6 +89,18 @@ def remove_item_share(request,item_id,grant_id):
return Response(status=204) 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']) @api_view(['POST'])
def public_links(request,item_id): def public_links(request,item_id):
item=get_item(item_id) item=get_item(item_id)

View File

@ -12,4 +12,11 @@ until python manage.py migrate --noinput; do
sleep 2 sleep 2
done done
python manage.py ensure_admin 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 exec python manage.py runserver 0.0.0.0:1029

View File

@ -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), 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), 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}/`), 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), 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}/`), 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), publicBrowse:(token,parent)=>publicApi.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data),

View File

@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users, Share2 } from 'lucide-react' 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 [] if (!item) return []
const rights = new Set(item.permissions || []) const rights = new Set(item.permissions || [])
if (trash) return rights.has('delete') ? [ if (trash) return rights.has('delete') ? [
@ -17,7 +17,7 @@ export const fileOptions = (item, trash) => {
{ id: 'rename', title: 'Renommer', icon: Pencil }, { id: 'rename', title: 'Renommer', icon: Pencil },
{ id: 'move', title: 'Déplacer', icon: FolderInput }, { 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}] : []),
] ]
} }

View File

@ -21,7 +21,7 @@ export default function DriveExplorer({ controller, user }) {
const [context, setContext] = useState(null) const [context, setContext] = useState(null)
const closeContext = useCallback(() => setContext(null), []) const closeContext = useCallback(() => setContext(null), [])
const openContext = (item,x,y) => { 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}) if (options.length) setContext({item,x,y,options})
} }
useEffect(() => { useEffect(() => {
@ -115,7 +115,7 @@ export default function DriveExplorer({ controller, user }) {
)} )}
</div> </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)} /> onChoose={type => performItemAction(context.item,type)} />
<footer className="flex justify-between gap-2 px-1 pt-3 text-[11px] text-muted-foreground"> <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> <span className="hidden md:inline">Clic droit sur un élément pour les actions.</span>

View File

@ -1,11 +1,24 @@
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { CircleHelp } from 'lucide-react'
export const RIGHTS = [ export const RIGHTS = [
['view','Consulter'], ['download','Télécharger'], ['add','Ajouter'], ['view','Consulter'], ['download','Télécharger'], ['add','Ajouter'],
['modify','Modifier'], ['delete','Supprimer'], ['share','Partager'], ['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'], ['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 = { export const PRESETS = {
reader: ['view','download'], reader: ['view','download'],
contributor: ['view','download','add','modify'], 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}) { export default function PermissionsEditor({value=[],onChange,maxRights=RIGHTS.map(([id])=>id),compact=false}) {
const maximum = new Set(maxRights || []) const maximum = new Set(maxRights || [])
const selected = new Set(value || []) 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 => { const update = name => {
if(!maximum.has(name))return if(!maximum.has(name))return
if(name==='view' && selected.has('view'))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')} else {selected.add(name);selected.add('view')}
onChange(RIGHTS.map(([id])=>id).filter(k=>selected.has(k)&&maximum.has(k))) onChange(RIGHTS.map(([id])=>id).filter(k=>selected.has(k)&&maximum.has(k)))
} }
return <div className="space-y-3"> return <TooltipProvider delay={250}>
<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="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'}> <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"> {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)} />{label} <Checkbox checked={selected.has(key)} disabled={key==='view'} onCheckedChange={()=>update(key)}/>
</label>)} <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> </div>
</TooltipProvider>
} }

View File

@ -9,7 +9,7 @@ const ActionButton = ({ icon: Icon, children, destructive = false, ...props }) =
) )
export default function ItemActionsDialog({ controller: c }) { 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 || []) const rights = new Set(action?.permissions || [])
return ( return (
<Dialog open={modal === 'actions'} onOpenChange={open => { if (!open) setModal(null) }}> <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={Pencil} onClick={() => handleAction('rename')}>Renommer</ActionButton>
<ActionButton icon={FolderInput} onClick={() => handleAction('move')}>Déplacer</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 && <> {rights.has('delete') && isTrash && <>
<ActionButton icon={RotateCcw} onClick={() => handleAction('restore')}>Restaurer</ActionButton> <ActionButton icon={RotateCcw} onClick={() => handleAction('restore')}>Restaurer</ActionButton>
<ActionButton icon={Trash2} destructive onClick={() => handleAction('confirmdelete')}>Supprimer</ActionButton> <ActionButton icon={Trash2} destructive onClick={() => handleAction('confirmdelete')}>Supprimer</ActionButton>

View File

@ -17,6 +17,8 @@ export default function ShareItemDialog({ controller }) {
const [error, setError] = useState('') const [error, setError] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [rights,setRights]=useState(PRESETS.reader) 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) => { const reload = async (id = itemId) => {
if (!id) return if (!id) return
const data = await drive.itemShares(id) const data = await drive.itemShares(id)
@ -25,7 +27,7 @@ export default function ShareItemDialog({ controller }) {
useEffect(() => { useEffect(() => {
if (!itemId) return if (!itemId) return
let active = true 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))}) drive.itemShares(itemId).then(data => { if(active)setRecord(data) }).catch(e=>{if(active)setError(message(e))})
return () => {active=false} return () => {active=false}
}, [itemId]) }, [itemId])
@ -59,7 +61,7 @@ export default function ShareItemDialog({ controller }) {
<div className="space-y-5"> <div className="space-y-5">
<section className="space-y-3"> <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> <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)}/> <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.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)} {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>)} onClick={()=>addUser(user)}>{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} · @{user.username}</button>)}
</div>} </div>}
{record.users.map(user=><div key={user.id} className="flex items-center justify-between gap-2 rounded-lg border p-2 text-sm"> {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> <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>)} </div>)}
</section> </section>

View File

@ -289,6 +289,7 @@ export function useDriveController(user) {
const performItemAction = (item, type) => { const performItemAction = (item, type) => {
if (type === 'download') return download(item) if (type === 'download') return download(item)
if (type === 'open') return openEntry(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 === 'trash') return run(() => drive.trash(item.id))
if (type === 'restore') return run(() => drive.restore(item.id)) if (type === 'restore') return run(() => drive.restore(item.id))
startAction(item, type) startAction(item, type)
@ -298,6 +299,7 @@ export function useDriveController(user) {
if (!action) return if (!action) return
if (type === 'download') { setModal(null); return download(action) } if (type === 'download') { setModal(null); return download(action) }
if (type === 'open') { setModal(null); return openEntry(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 === 'trash') return run(() => drive.trash(action.id))
if (type === 'restore') return run(() => drive.restore(action.id)) if (type === 'restore') return run(() => drive.restore(action.id))
if (type === 'remove') return run(() => drive.remove(action.id)) if (type === 'remove') return run(() => drive.remove(action.id))

View File

@ -40,7 +40,17 @@ echo "Démarrage Django sur :1035"
python3 manage.py migrate --noinput 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 & python3 manage.py runserver 0.0.0.0:1035 &
BACKEND_PID=$! BACKEND_PID=$!
@ -73,9 +83,11 @@ cleanup() {
kill "$BACKEND_PID" 2>/dev/null || true kill "$BACKEND_PID" 2>/dev/null || true
kill "$FRONTEND_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 "$BACKEND_PID" 2>/dev/null || true
wait "$FRONTEND_PID" 2>/dev/null || true wait "$FRONTEND_PID" 2>/dev/null || true
wait "$CLEANER_PID" 2>/dev/null || true
} }
trap cleanup SIGTERM SIGINT EXIT trap cleanup SIGTERM SIGINT EXIT