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
RIGHTS = ('view', 'download', 'add', 'modify', 'delete', 'share', 'manage_access', 'create_spaces', 'manage_space')
# A file/folder share never delegates space administration or deletion.
ITEM_SHARE_RIGHTS = ('view', 'download', 'modify', 'add')
def available_item_share_rights(item):
return tuple(right for right in ITEM_SHARE_RIGHTS if right != 'add' or item.is_folder)
def normalized_item_share(value, item):
rights = normalized(value)
if not set(rights).issubset(available_item_share_rights(item)):
raise ValueError('Ce droit ne peut pas être attribué sur ce partage.')
return rights
def assert_item_delegation(issuer_rights, desired, item):
rights = normalized_item_share(desired, item)
if not set(rights).issubset(set(issuer_rights)):
raise ValueError('Tu ne peux pas attribuer des permissions supérieures aux tiennes.')
return rights
PRESETS = {
'reader': ('view', 'download'),
'contributor': ('view', 'download', 'add', 'modify'),
@ -93,7 +115,7 @@ def item_rights(user, item):
seen.add(current.pk)
grant = ItemGrant.objects.filter(user=user, item=current).first()
if grant:
return set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader'])
return (set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader'])) & set(available_item_share_rights(current))
current = current.parent
return set()

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('shares/users/search/',views_shares.search_share_users),
path('items/<uuid:item_id>/shares/',views_shares.manage_item_shares),
path('items/<uuid:item_id>/my-share/',views_shares.leave_item_share),
path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share),
path('items/<uuid:item_id>/shares/<int:grant_id>/permissions/',views_shares.update_item_share),
path('items/<uuid:item_id>/public-links/',views_shares.public_links),

View File

@ -1,6 +1,4 @@
"""Space-bound navigation, folder management and trash endpoints."""
from pathlib import Path
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Sum, Count
@ -11,8 +9,9 @@ from .models import DriveItem, DriveSpace, ItemGrant
from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space
from .permissions import has_space_right, item_rights
from .quotas import quota_state
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination, descendant_files
from .storage_layout import disk_path, file_location, assert_available, prepare_parent, relocate_item, NameConflict
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination
from .storage_layout import assert_available, prepare_parent, relocate_item, NameConflict
from .trash_cleanup import delete_trashed_item
@api_view(['GET'])
@ -71,6 +70,11 @@ def listing(request):
if readable(n,user,allow_trashed=view=='trash'):
info=item_info(n,user)
if view in ('trash','shared'):info['space_name']=n.space.display_name if not n.space.private_owner_id else 'Mes fichiers'
if view=='shared':
for ancestor in chain(n):
if ItemGrant.objects.filter(item=ancestor,user=user).exists():
info['share_root_id']=str(ancestor.pk)
break
items.append(info)
if len(items)>=500:break
trail=[]
@ -138,6 +142,15 @@ def update_item(request,item_id):
except PermissionError as exc:return error(str(exc),403)
if 'is_shared' in data:
return error('Le partage global est désactivé : utilise les espaces et invitations.')
if 'parent' in data and (new_parent.pk if new_parent else None)!=item.parent_id:
# Moving to the space root requires space membership with Add.
# A recipient can reorganize only inside a folder that grants Add.
if new_parent is None and not has_space_right(request.user,item.space,'add'):
return error('Déplacement hors du dossier partagé interdit.',403)
if not has_space_right(request.user,item.space,'modify'):
# Do not allow moving the shared root itself: it belongs to its creator.
if ItemGrant.objects.filter(item=item,user=request.user).exists():
return error('Impossible de déplacer le dossier ou fichier partagé lui-même.',403)
if new_parent and (new_parent.pk==item.pk or item.pk in [n.pk for n in chain(new_parent)]):
return error('Déplacement impossible dans un sous-dossier de cet élément.')
if new_name==item.name and (new_parent.pk if new_parent else None)==item.parent_id:
@ -179,24 +192,8 @@ def delete_item(request,item_id):
item=get_item(item_id)
if not writable(item,request.user,'delete'):return error('Accès refusé.',403)
if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.')
files=descendant_files(item)
folder_rel=file_location(item.space,item.parent,item.name) if item.is_folder else None
with transaction.atomic():
item.delete()
def remove_files():
root=Path(settings.DRIVE_STORAGE_ROOT).resolve()
for key in files:
path=(root/key).resolve()
if path.is_relative_to(root):path.unlink(missing_ok=True)
if folder_rel:
# Remove only empty folders. Untracked data is never deleted.
base=disk_path(folder_rel)
if base.is_dir():
for candidate in sorted(base.rglob('*'),key=lambda p:len(p.parts),reverse=True):
if candidate.is_dir() and not candidate.is_symlink():
try:candidate.rmdir()
except OSError:pass
try:base.rmdir()
except OSError:pass
transaction.on_commit(remove_files)
try:
delete_trashed_item(item)
except (OSError,ValueError) as exc:
return error(f'Suppression impossible : {exc}',409)
return Response(status=204)

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
random bearer tokens and keep only SHA-256 digests in the database.
@ -17,7 +17,7 @@ from rest_framework.response import Response
from .models import DriveItem, ItemGrant, PublicItemLink
from .services import error, get_item, readable, writable, chain, item_info
from .spaces import storage_path
from .permissions import item_rights, assert_delegation, PRESETS, normalized
from .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS
from .views_content import safe_preview_type, read_bytes
@ -27,7 +27,7 @@ def shareable(item, user):
def share_info(grant):
u=grant.user
return {'id': grant.pk,'permissions':grant.permissions if grant.permissions is not None else list(PRESETS['reader']),'user_id':u.pk,'username':u.username,
return {'id': grant.pk,'permissions':sorted((set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader'])) & set(available_item_share_rights(grant.item)),key=('view','download','modify','add').index),'user_id':u.pk,'username':u.username,
'first_name':u.first_name,'last_name':u.last_name}
@ -50,7 +50,7 @@ def manage_item_shares(request,item_id):
item=get_item(item_id)
if not shareable(item,request.user):return error('Partage non autorisé.',403)
if request.method=='GET':
grants=ItemGrant.objects.filter(item=item).select_related('user').order_by('user__username')
grants=ItemGrant.objects.filter(item=item).select_related('user','item').order_by('user__username')
links=PublicItemLink.objects.filter(item=item,revoked_at__isnull=True).order_by('-created_at')
return Response({'users':[share_info(g) for g in grants],
'links':[{'id':l.pk,'created_at':l.created_at.isoformat()} for l in links]})
@ -61,7 +61,7 @@ def manage_item_shares(request,item_id):
except get_user_model().DoesNotExist:return error('Utilisateur introuvable.',404)
if account.pk==request.user.pk:return error('Cet élément t’appartient déjà.')
try:
rights=assert_delegation(item_rights(request.user,item),request.data.get('permissions',list(PRESETS['reader'])))
rights=assert_item_delegation(item_rights(request.user,item),request.data.get('permissions',list(PRESETS['reader'])),item)
except ValueError as exc:return error(str(exc),403)
grant,created=ItemGrant.objects.get_or_create(item=item,user=account,defaults={'created_by':request.user,'permissions':rights})
if not created:return error('Cet élément est déjà partagé avec cet utilisateur.',409)
@ -73,11 +73,11 @@ def update_item_share(request,item_id,grant_id):
item=get_item(item_id)
if not shareable(item,request.user):return error('Partage non autorisé.',403)
try:
rights=assert_delegation(item_rights(request.user,item), request.data.get('permissions'))
rights=assert_item_delegation(item_rights(request.user,item), request.data.get('permissions'),item)
except ValueError as exc:return error(str(exc),403)
changed=ItemGrant.objects.filter(item=item,pk=grant_id).update(permissions=rights)
if not changed:return error('Partage introuvable.',404)
return Response(share_info(ItemGrant.objects.select_related('user').get(pk=grant_id)))
return Response(share_info(ItemGrant.objects.select_related('user','item').get(pk=grant_id)))
@api_view(['DELETE'])
@ -89,6 +89,18 @@ def remove_item_share(request,item_id,grant_id):
return Response(status=204)
@api_view(['DELETE'])
def leave_item_share(request,item_id):
"""Remove only the current recipient's share, never the underlying item."""
item=get_item(item_id)
for node in chain(item):
grant=ItemGrant.objects.filter(item=node,user=request.user).first()
if grant is not None:
grant.delete()
return Response(status=204)
return error('Aucun partage à retirer.',404)
@api_view(['POST'])
def public_links(request,item_id):
item=get_item(item_id)

View File

@ -12,4 +12,11 @@ until python manage.py migrate --noinput; do
sleep 2
done
python manage.py ensure_admin
# Nettoyage automatique au lancement puis chaque heure.
(
while :; do
python manage.py cleanup_trash --days 5 || echo 'Nettoyage de la corbeille reporté.' >&2
sleep 3600
done
) &
exec python manage.py runserver 0.0.0.0:1029

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),
shareItemPermissions:(id,grant_id,permissions)=>api.patch(`/drive/items/${id}/shares/${grant_id}/permissions/`,{permissions}).then(r=>r.data),
unshareItem:(id,grant_id)=>api.delete(`/drive/items/${id}/shares/${grant_id}/`),
leaveItemShare:id=>api.delete(`/drive/items/${id}/my-share/`),
publicItem:id=>api.post(`/drive/items/${id}/public-links/`).then(r=>r.data),
revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`),
publicBrowse:(token,parent)=>publicApi.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data),

View File

@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users, Share2 } from 'lucide-react'
export const fileOptions = (item, trash) => {
export const fileOptions = (item, trash, shared=false) => {
if (!item) return []
const rights = new Set(item.permissions || [])
if (trash) return rights.has('delete') ? [
@ -17,7 +17,7 @@ export const fileOptions = (item, trash) => {
{ id: 'rename', title: 'Renommer', icon: Pencil },
{ id: 'move', title: 'Déplacer', icon: FolderInput },
] : []),
...(rights.has('delete') ? [{id:'trash',title:'Mettre à la corbeille',icon:Trash2,danger:true}] : []),
...(shared && item.share_root_id ? [{id:'remove-share',title:'Retirer de mes partages',icon:Trash2,danger:true}] : rights.has('delete') && !shared ? [{id:'trash',title:'Mettre à la corbeille',icon:Trash2,danger:true}] : []),
]
}

View File

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

View File

@ -1,11 +1,24 @@
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { CircleHelp } from 'lucide-react'
export const RIGHTS = [
['view','Consulter'], ['download','Télécharger'], ['add','Ajouter'],
['modify','Modifier'], ['delete','Supprimer'], ['share','Partager'],
['manage_access','Gérer les accès'], ['create_spaces','Créer des espaces'], ['manage_space','Gérer l’espace'],
]
export const RIGHT_DESCRIPTIONS = {
view: 'Voir les fichiers et parcourir les dossiers.',
download: 'Télécharger les fichiers.',
add: 'Importer des fichiers et créer des dossiers.',
modify: 'Renommer et déplacer des fichiers ou dossiers.',
delete: 'Mettre des éléments à la corbeille et les supprimer définitivement.',
share: 'Partager des fichiers et dossiers avec des utilisateurs ou via un lien public.',
manage_access: 'Inviter des utilisateurs, modifier leurs permissions et retirer leurs accès.',
create_spaces: 'Créer de nouveaux sous-espaces dans les espaces autorisés.',
manage_space: 'Renommer, supprimer et gérer les paramètres d’un espace, dans la limite des droits reçus.',
}
export const PRESETS = {
reader: ['view','download'],
contributor: ['view','download','add','modify'],
@ -14,6 +27,9 @@ export const PRESETS = {
export default function PermissionsEditor({value=[],onChange,maxRights=RIGHTS.map(([id])=>id),compact=false}) {
const maximum = new Set(maxRights || [])
const selected = new Set(value || [])
// A shared file has no management permissions: avoid showing two identical presets.
const presetOptions = Object.entries(PRESETS).map(([key, preset]) => [key, preset.filter(right => maximum.has(right))])
.filter(([key, rights], index, all) => all.findIndex(([, other]) => other.join('|') === rights.join('|')) === index)
const update = name => {
if(!maximum.has(name))return
if(name==='view' && selected.has('view'))return
@ -21,12 +37,21 @@ export default function PermissionsEditor({value=[],onChange,maxRights=RIGHTS.ma
else {selected.add(name);selected.add('view')}
onChange(RIGHTS.map(([id])=>id).filter(k=>selected.has(k)&&maximum.has(k)))
}
return <div className="space-y-3">
<div className="flex flex-wrap gap-1.5">{Object.entries(PRESETS).map(([key,rights])=><Button key={key} type="button" size="sm" variant="outline" onClick={()=>onChange(rights.filter(right=>maximum.has(right)))}>{({reader:'Lecteur',contributor:'Contributeur',manager:'Gestionnaire'})[key]}</Button>)}</div>
<div className={compact?'grid grid-cols-2 gap-2':'grid gap-2 sm:grid-cols-2'}>
{RIGHTS.filter(([key])=>maximum.has(key)).map(([key,label])=><label key={key} className="flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm">
<Checkbox checked={selected.has(key)} disabled={key==='view'} onCheckedChange={()=>update(key)} />{label}
</label>)}
return <TooltipProvider delay={250}>
<div className="space-y-3">
<div className="flex flex-wrap gap-1.5">{presetOptions.map(([key,rights])=><Button key={key} type="button" size="sm" variant="outline" onClick={()=>onChange(rights)}>{({reader:'Lecteur',contributor:'Contributeur',manager:'Gestionnaire'})[key]}</Button>)}</div>
<div className={compact?'grid grid-cols-2 gap-2':'grid gap-2 sm:grid-cols-2'}>
{RIGHTS.filter(([key])=>maximum.has(key)).map(([key,label])=><div key={key} className="flex min-w-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm">
<Checkbox checked={selected.has(key)} disabled={key==='view'} onCheckedChange={()=>update(key)}/>
<Tooltip>
<TooltipTrigger render={<button type="button" className="flex min-w-0 flex-1 items-center justify-between gap-1 text-left outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring" aria-label={`${label} : ${RIGHT_DESCRIPTIONS[key]}`} onClick={()=>update(key)} />}>
<span className="min-w-0 truncate">{label}</span>
<CircleHelp className="size-3.5 shrink-0 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top">{RIGHT_DESCRIPTIONS[key]}</TooltipContent>
</Tooltip>
</div>)}
</div>
</div>
</div>
</TooltipProvider>
}

View File

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

View File

@ -17,6 +17,8 @@ export default function ShareItemDialog({ controller }) {
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [rights,setRights]=useState(PRESETS.reader)
const allowedRights = ['view','download','modify',...(action?.is_folder?['add']:[])]
const maxRights = allowedRights.filter(right => (action?.permissions || ['view','download']).includes(right))
const reload = async (id = itemId) => {
if (!id) return
const data = await drive.itemShares(id)
@ -25,7 +27,7 @@ export default function ShareItemDialog({ controller }) {
useEffect(() => {
if (!itemId) return
let active = true
setRecord({users:[],links:[]}); setLink(''); setQuery(''); setResults([]); setError('')
setRecord({users:[],links:[]}); setLink(''); setQuery(''); setResults([]); setError(''); setRights(PRESETS.reader)
drive.itemShares(itemId).then(data => { if(active)setRecord(data) }).catch(e=>{if(active)setError(message(e))})
return () => {active=false}
}, [itemId])
@ -59,7 +61,7 @@ export default function ShareItemDialog({ controller }) {
<div className="space-y-5">
<section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold"><UserPlus className="size-4"/> Partager avec un utilisateur</h3>
<PermissionsEditor compact value={rights} onChange={setRights} maxRights={action?.permissions || ['view','download']} />
<PermissionsEditor compact value={rights} onChange={setRights} maxRights={maxRights} />
<Input placeholder="Rechercher un utilisateur…" value={query} onChange={e=>setQuery(e.target.value)}/>
{results.length>0 && <div className="max-h-44 overflow-y-auto rounded-lg border p-1">
{results.map(user=><button key={user.id} type="button" disabled={busy || record.users.some(u=>u.user_id===user.id)}
@ -67,7 +69,7 @@ export default function ShareItemDialog({ controller }) {
onClick={()=>addUser(user)}>{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} · @{user.username}</button>)}
</div>}
{record.users.map(user=><div key={user.id} className="flex items-center justify-between gap-2 rounded-lg border p-2 text-sm">
<div className="min-w-0 flex-1 space-y-2"><span className="block truncate">{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} (@{user.username})</span><PermissionsEditor compact value={user.permissions} maxRights={action?.permissions || ['view','download']} onChange={permissions=>perform(()=>drive.shareItemPermissions(itemId,user.id,permissions))}/></div>
<div className="min-w-0 flex-1 space-y-2"><span className="block truncate">{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} (@{user.username})</span><PermissionsEditor compact value={user.permissions} maxRights={maxRights} onChange={permissions=>perform(()=>drive.shareItemPermissions(itemId,user.id,permissions))}/></div>
<Button size="icon-sm" variant="ghost" disabled={busy} title="Retirer l’accès" onClick={()=>perform(()=>drive.unshareItem(itemId,user.id))}><Trash2 className="size-4"/></Button>
</div>)}
</section>

View File

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

View File

@ -40,7 +40,17 @@ echo "Démarrage Django sur :1035"
python3 manage.py migrate --noinput
python3 manage.py ensure_admin
python manage.py ensure_admin
# Purge au démarrage puis une fois par heure (éléments de plus de 5 jours).
trash_cleanup_loop() {
while true; do
python3 manage.py cleanup_trash --days 5 || echo "Échec du nettoyage de la corbeille (nouvel essai dans 1 heure)." >&2
sleep 3600
done
}
trash_cleanup_loop &
CLEANER_PID=$!
python3 manage.py runserver 0.0.0.0:1035 &
BACKEND_PID=$!
@ -73,9 +83,11 @@ cleanup() {
kill "$BACKEND_PID" 2>/dev/null || true
kill "$FRONTEND_PID" 2>/dev/null || true
kill "$CLEANER_PID" 2>/dev/null || true
wait "$BACKEND_PID" 2>/dev/null || true
wait "$FRONTEND_PID" 2>/dev/null || true
wait "$CLEANER_PID" 2>/dev/null || true
}
trap cleanup SIGTERM SIGINT EXIT