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
|
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()
|
||||||
|
|
||||||
|
|||||||
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('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),
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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}] : []),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
14
start.sh
14
start.sh
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user