diff --git a/backend/drive/models.py b/backend/drive/models.py index 59fdfd0..8ab84de 100644 --- a/backend/drive/models.py +++ b/backend/drive/models.py @@ -125,6 +125,6 @@ class PublicItemLink(models.Model): class ArchivedDriveAccount(models.Model): - """Account removed from the app while its immutable private space survives.""" + """Inactive, anonymized historical owner of shared files after account removal.""" user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_archive') deleted_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/drive/test_item_sharing.py b/backend/drive/test_item_sharing.py index 1b5cd0a..0686d6f 100644 --- a/backend/drive/test_item_sharing.py +++ b/backend/drive/test_item_sharing.py @@ -109,6 +109,17 @@ class SharesTests(TestCase): self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/public-links/{links[0]["id"]}/').status_code,204) self.assertEqual(anonymous.get(f'/api/public/{token}/').status_code,404) + # A directly shared file must download without either a login or JWT. + direct=self.client.post(f'/api/drive/items/{image.pk}/public-links/',{},format='json') + self.assertEqual(direct.status_code,201,direct.data) + direct_token=direct.data['token'] + direct_preview=anonymous.get(f'/api/public/{direct_token}/') + self.assertEqual(direct_preview.status_code,200,direct_preview.data) + self.assertFalse(direct_preview.data['root']['is_folder']) + direct_file=anonymous.get(f'/api/public/{direct_token}/files/{image.pk}/') + self.assertEqual(direct_file.status_code,200) + self.assertEqual(b''.join(direct_file.streaming_content),b'PNG') + def test_admin_user_list_is_private_to_admin(self): alice=self.invite_user('alice-admin-list') self.client.force_authenticate(user=alice) diff --git a/backend/drive/test_protected_deletions.py b/backend/drive/test_protected_deletions.py index dd99481..052a4a2 100644 --- a/backend/drive/test_protected_deletions.py +++ b/backend/drive/test_protected_deletions.py @@ -96,22 +96,31 @@ class ProtectedDeletionTests(TestCase): self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists()) self.assertTrue((self.storage/'privates').is_dir()) - def test_user_deletion_anonymizes_account_but_keeps_private(self): + def test_user_deletion_removes_private_directory_and_keeps_shared_files(self): item, path = self.private_file() self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/', {'confirm_username':self.admin.username}, format='json').status_code, 403) self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', {'confirm_username':'wrong'}, format='json').status_code, 400) - self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', - {'confirm_username':self.alice.username}, format='json').status_code, 204) + shared = DriveItem.objects.create(owner=self.alice, space=self.root, + name='communal.txt', storage_key=str(uuid.uuid4()), + storage_location='communal.txt', size=4) + shared_path = self.storage / 'communal.txt' + shared_path.write_bytes(b'team') + with self.captureOnCommitCallbacks(execute=True): + self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', + {'confirm_username':self.alice.username}, format='json').status_code, 204) self.alice.refresh_from_db() self.assertFalse(self.alice.is_active) self.assertFalse(self.alice.has_usable_password()) self.assertTrue(ArchivedDriveAccount.objects.filter(user=self.alice).exists()) self.assertNotIn(self.alice.pk, [u['id'] for u in self.client.get('/api/drive/admin/users/').data['users']]) - self.assertTrue(path.exists()) - self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk, private_owner=self.alice).exists()) - self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists()) + self.assertFalse(path.exists()) + self.assertFalse((self.storage / self.private.relative_path).exists()) + self.assertFalse(DriveSpace.objects.filter(pk=self.private.pk).exists()) + self.assertFalse(DriveItem.objects.filter(pk=item.pk).exists()) + self.assertTrue(shared_path.exists()) + self.assertTrue(DriveItem.objects.filter(pk=shared.pk).exists()) self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', {'confirm_username':self.alice.username}, format='json').status_code, 404) @@ -119,3 +128,41 @@ class ProtectedDeletionTests(TestCase): self.client.force_authenticate(user=self.alice) self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/', {'confirm_username':self.admin.username}, format='json').status_code, 403) + + def test_admin_can_revoke_space_grants_without_removing_private_storage(self): + from .models import SpaceGrant + # Access granted explicitly by invitation/existing-account grant. + grant = SpaceGrant.objects.create(user=self.alice, space=self.root, + can_write=True, can_invite=True) + rsp = self.client.get(f'/api/drive/admin/users/{self.alice.pk}/access/') + self.assertEqual(rsp.status_code, 200, rsp.data) + self.assertTrue(any(a['kind'] == 'grant' and a['space_id'] == self.root.pk + for a in rsp.data['accesses'])) + self.assertNotIn(self.private.pk, [a['space_id'] for a in rsp.data['accesses']]) + rsp = self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/access/grant/{self.root.pk}/') + self.assertEqual(rsp.status_code, 204) + self.assertFalse(SpaceGrant.objects.filter(pk=grant.pk).exists()) + self.assertTrue(DriveSpace.objects.filter(pk=self.root.pk).exists()) + self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk).exists()) + + # Older accounts are linked directly to their assigned shared space. + rsp = self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/access/owner/{self.assigned.pk}/') + self.assertEqual(rsp.status_code, 204) + self.assigned.refresh_from_db() + self.assertIsNone(self.assigned.user_id) + self.assertIsNone(ensure_space(self.alice)) + self.client.force_authenticate(user=self.alice) + self.assertEqual(self.client.get('/api/drive/items/', {'space':self.root.pk}).status_code,403) + self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code,200) + self.assertEqual(self.client.get(f'/api/drive/admin/users/{self.alice.pk}/access/').status_code,403) + + def test_account_delete_rejects_private_folder_outside_privates(self): + self.private.relative_path = f'{self.assigned.relative_path}/legacy' + self.private.save(update_fields=['relative_path']) + original = self.storage / self.private.relative_path + original.mkdir(parents=True, exist_ok=True) + rsp=self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', + {'confirm_username':self.alice.username},format='json') + self.assertEqual(rsp.status_code,409) + self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk).exists()) + self.assertTrue(original.exists()) diff --git a/backend/drive/urls.py b/backend/drive/urls.py index a440edf..c9b8c20 100644 --- a/backend/drive/urls.py +++ b/backend/drive/urls.py @@ -5,6 +5,8 @@ urlpatterns=[ path('spaces/',views_spaces.spaces), path('admin/users/',views_spaces.admin_users), path('admin/users//',views_spaces.admin_delete_user), + path('admin/users//access/',views_spaces.admin_user_access), + path('admin/users//access///',views_spaces.admin_revoke_user_access), path('shares/users/search/',views_shares.search_share_users), path('items//shares/',views_shares.manage_item_shares), path('items//shares//',views_shares.remove_item_share), diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py index d6af785..c4301aa 100644 --- a/backend/drive/views_spaces.py +++ b/backend/drive/views_spaces.py @@ -548,41 +548,169 @@ def admin_users(request): return Response({'users':result[:200], 'limited':len(result)>200}) -@api_view(['DELETE']) -def admin_delete_user(request, user_id): - """Remove account access without deleting the non-deletable private space. +@api_view(['GET']) +def admin_user_access(request, user_id): + """Show only shared-space access, never a user's private storage.""" + if not is_admin(request.user): + return error('Accès réservé aux administrateurs.', 403) + User = get_user_model() + try: + target = User.objects.get(pk=user_id, drive_archive__isnull=True) + except User.DoesNotExist: + return error('Utilisateur introuvable.', 404) + if is_admin(target): + return error('Impossible de modifier les accès d’un administrateur.', 403) + accesses = [] + for grant in SpaceGrant.objects.filter(user=target, space__private_owner__isnull=True).select_related('space'): + accesses.append({'kind':'grant', 'space_id':grant.space_id, 'space_name':grant.space.display_name}) + for space in DriveSpace.objects.filter(user=target, private_owner__isnull=True): + accesses.append({'kind':'owner', 'space_id':space.pk, 'space_name':space.display_name}) + for space in DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True): + accesses.append({'kind':'manager', 'space_id':space.pk, 'space_name':space.display_name}) + return Response({'accesses': accesses}) - The user row remains as an anonymized, inactive data owner so PROTECT - relations on private files and the private space never cascade away. + +@api_view(['DELETE']) +def admin_revoke_user_access(request, user_id, kind, space_id): + """Revoke a space membership, legacy assignment, or managed-space grant. + + Never delete the shared space itself, its contents, or the user's private + space. Other independent grants remain intact. """ if not is_admin(request.user): return error('Accès réservé aux administrateurs.', 403) + if kind not in ('grant', 'owner', 'manager'): + return error('Type d’accès inconnu.', 404) User = get_user_model() with transaction.atomic(): try: target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True) except User.DoesNotExist: return error('Utilisateur introuvable.', 404) - if target.pk == request.user.pk or is_admin(target): - return error('Impossible de supprimer un administrateur.', 403) - if request.data.get('confirm_username') != target.username: - return error('Saisis le nom d’utilisateur pour confirmer.') - now = timezone.now() - SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now) - PublicItemLink.objects.filter(Q(created_by=target) | Q(item__space__private_owner=target), - revoked_at__isnull=True).update(revoked_at=now) - ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete() - SpaceGrant.objects.filter(user=target).delete() - unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk', flat=True)) - UploadSession.objects.filter(owner=target).delete() - if unfinished: - transaction.on_commit(lambda: _remove_upload_parts(unfinished)) - target.is_active = False - target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}' - target.first_name = '' - target.last_name = '' - target.email = '' - target.set_unusable_password() - target.save(update_fields=['is_active', 'username', 'first_name', 'last_name', 'email', 'password']) - ArchivedDriveAccount.objects.create(user=target) + if is_admin(target): + return error('Impossible de modifier les accès d’un administrateur.', 403) + qs = DriveSpace.objects.select_for_update().filter(pk=space_id, private_owner__isnull=True) + if kind == 'grant': + deleted, _ = SpaceGrant.objects.filter(space__in=qs, user=target).delete() + if not deleted:return error('Accès introuvable.', 404) + elif kind == 'owner': + if not qs.filter(user=target).update(user=None): + return error('Accès introuvable.', 404) + else: + if not qs.filter(managed_by=target).update(managed_by=None): + return error('Accès introuvable.', 404) + return Response(status=204) + + +def _delete_staged_private(directory): + """Physically remove a private directory after the SQL transaction commits.""" + try: + if directory is not None and directory.exists(): + shutil.rmtree(directory) + except OSError: + _logger.exception('Échec de suppression du dossier privé %s', directory) + + +def _delete_legacy_private_files(locations): + """Remove only legacy UUID-key files stored directly at storage/ab/uuid.""" + import uuid + root = Path(settings.DRIVE_STORAGE_ROOT).resolve() + for location in locations: + try: + parts = Path(location).parts + if len(parts) != 2 or len(parts[0]) != 2 or not all(c in '0123456789abcdef' for c in parts[0]): + continue + if str(uuid.UUID(parts[1])) != parts[1] or parts[0] != parts[1][:2]: + continue + # Never remove a legacy file still referenced by a shared item. + if DriveItem.objects.filter(storage_key=parts[1],storage_location='').exists(): + continue + path = root.joinpath(*parts) + if path.is_symlink() or not path.resolve().is_relative_to(root): + continue + path.unlink(missing_ok=True) + except (ValueError, OSError): + _logger.exception('Impossible de supprimer le fichier privé historique : %s', location) + + +@api_view(['DELETE']) +def admin_delete_user(request, user_id): + """Deactivate an account and PERMANENTLY remove its private files and space. + + Historical shared files keep the archived owner row because their owner + FK is PROTECT; they are not removed by account deletion. + """ + if not is_admin(request.user): + return error('Accès réservé aux administrateurs.', 403) + User = get_user_model() + staged = None + original = None + try: + with transaction.atomic(): + try: + target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True) + except User.DoesNotExist: + return error('Utilisateur introuvable.', 404) + if target.pk == request.user.pk or is_admin(target): + return error('Impossible de supprimer un administrateur.', 403) + if request.data.get('confirm_username') != target.username: + return error('Saisis le nom d’utilisateur pour confirmer.') + + private = DriveSpace.objects.select_for_update().filter(private_owner=target).first() + legacy = [] + if private: + # Never risk deleting an old shared directory when a private + # space has not yet been migrated to storage/privates/. + root = Path(settings.DRIVE_STORAGE_ROOT).resolve() + private_root = root / 'privates' + original = root / private.relative_path + if (not private.relative_path.startswith('privates/') or + original.parent != private_root or original.is_symlink() or + private_root.is_symlink() or not original.resolve().is_relative_to(private_root.resolve())): + return error('Déplace d’abord le stockage privé vers storage/privates.', 409) + if DriveSpace.objects.filter(parent=private).exists(): + return error('Cet espace privé possède encore des sous-espaces.', 409) + # Legacy /ab/UUID paths are outside the numbered private folder. + for loc, key in DriveItem.objects.filter(space=private, is_folder=False).values_list('storage_location','storage_key'): + if key and not loc: + legacy.append(f'{key[:2]}/{key}') + if original.exists(): + # Atomic rename to a location that is never browsable by + # another account. Roll back the rename on DB failure. + staged = private_root / f'.deleting-{private.pk}-{secrets.token_hex(12)}' + original.rename(staged) + # Deleting DriveItems removes their grants and public links. + DriveItem.objects.filter(space=private).delete() + UploadSession.objects.filter(space=private).delete() + private.delete() + + now = timezone.now() + SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now) + PublicItemLink.objects.filter(created_by=target, revoked_at__isnull=True).update(revoked_at=now) + ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete() + SpaceGrant.objects.filter(user=target).delete() + # Shared spaces survive, but their former owner loses access. + DriveSpace.objects.filter(user=target, private_owner__isnull=True).update(user=None) + DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True).update(managed_by=None) + unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk',flat=True)) + UploadSession.objects.filter(owner=target).delete() + target.is_active = False + target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}' + target.first_name = '' + target.last_name = '' + target.email = '' + target.set_unusable_password() + target.save(update_fields=['is_active','username','first_name','last_name','email','password']) + ArchivedDriveAccount.objects.create(user=target) + # Work only after a successful DB commit; TestCase can explicitly + # execute callbacks to validate the physical removal. + def complete_cleanup(): + _delete_staged_private(staged) + _delete_legacy_private_files(legacy) + _remove_upload_parts(unfinished) + transaction.on_commit(complete_cleanup) + except Exception: + if staged is not None and staged.exists() and original is not None and not original.exists(): + staged.rename(original) + raise return Response(status=204) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 57d04ea..487d606 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,9 +4,11 @@ import { api, logoutTokens, savedToken } from '@/api/client' import DrivePage from '@/pages/DrivePage' import LoginPage from '@/pages/LoginPage' import InvitationPage from '@/pages/InvitationPage' +import PublicSharePage from '@/pages/PublicSharePage' /** Application entry point: only restores the session and selects the screen. */ export default function App() { + const publicToken = window.location.pathname.match(/^\/s\/([A-Za-z0-9_-]+)\/?$/)?.[1] const invitationToken = window.location.pathname.match(/^\/invite\/([A-Za-z0-9_-]+)\/?$/)?.[1] const [user,setUser] = useState(null) const [checking,setChecking] = useState(true) @@ -14,6 +16,10 @@ export default function App() { const [attempt,setAttempt] = useState(0) useEffect(() => { + if (publicToken || invitationToken) { + setChecking(false) + return + } let active = true const probe = async () => { if(!savedToken()) {setChecking(false); return} @@ -34,8 +40,9 @@ export default function App() { const expired = () => {setUser(null);setChecking(false)} window.addEventListener('drive:logout', expired) return () => {active = false;window.removeEventListener('drive:logout', expired)} - }, [attempt]) + }, [attempt, publicToken, invitationToken]) + if(publicToken) return if(invitationToken) return if(connectionError) return

GoodDrive est momentanément inaccessible. Ta session est conservée.

if(checking) return
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 500fbf6..82ae4ed 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -6,6 +6,12 @@ export const api = axios.create({ headers: { Accept: 'application/json' }, }); const raw = axios.create({baseURL:'/api', headers:{Accept:'application/json'}}) +// Public links must remain accessible without a session, even if a browser +// has a previously expired access token stored locally. +const publicApi = axios.create({ + baseURL: api.defaults.baseURL, + headers: { Accept: 'application/json' }, +}) export const savedToken = () => sessionStorage.getItem(ACCESS) export const storeTokens = data => { if (data.access) sessionStorage.setItem(ACCESS,data.access) @@ -64,9 +70,11 @@ export const drive={ unshareItem:(id,grant_id)=>api.delete(`/drive/items/${id}/shares/${grant_id}/`), 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)=>api.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), adminUsers:q=>api.get('/drive/admin/users/',{params:{q}}).then(r=>r.data), adminDeleteUser:(id,confirm_username)=>api.delete(`/drive/admin/users/${id}/`,{data:{confirm_username}}), + adminUserAccess:id=>api.get(`/drive/admin/users/${id}/access/`).then(r=>r.data.accesses), + revokeUserAccess:(id,kind,spaceId)=>api.delete(`/drive/admin/users/${id}/access/${kind}/${spaceId}/`), stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data), folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data), patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data), @@ -117,4 +125,4 @@ export const drive={ } export function message(error){let d=error?.response?.data;if(typeof d?.detail==='string')return d.detail;if(typeof d==='object' && d!==null)return Object.entries(d).map(([k,v])=>`${k}: ${Array.isArray(v)?v.join(', '):v}`).join(' · ');return 'Une erreur est survenue. Réessaie.'} -export const publicFileUrl = (token,id) => `${api.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/` +export const publicFileUrl = (token,id) => `${publicApi.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/` diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index c91e34b..d369d25 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { Users, LoaderCircle, Trash2 } from 'lucide-react' +import { Users, LoaderCircle, Trash2, KeyRound } from 'lucide-react' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -11,6 +11,10 @@ export default function AdminUsersPage(){ const [busy,setBusy] = useState(true) const [error,setError] = useState('') const [selected,setSelected] = useState(null) + const [accessUser,setAccessUser] = useState(null) + const [accesses,setAccesses] = useState([]) + const [accessBusy,setAccessBusy] = useState(false) + const [accessError,setAccessError] = useState('') const [confirmation,setConfirmation] = useState('') const [deleting,setDeleting] = useState(false) const [deleteError,setDeleteError] = useState('') @@ -27,6 +31,21 @@ export default function AdminUsersPage(){ return ()=>{active=false;clearTimeout(timer)} },[query,revision]) const close=()=>{if(!deleting){setSelected(null);setConfirmation('');setDeleteError('')}} + const openAccesses=async user=>{ + setAccessUser(user);setAccesses([]);setAccessError('');setAccessBusy(true) + try {setAccesses(await drive.adminUserAccess(user.id))} + catch(e){setAccessError(message(e))} + finally{setAccessBusy(false)} + } + const revokeAccess=async access=>{ + if(!accessUser || !window.confirm(`Retirer l’accès à « ${access.space_name} » pour @${accessUser.username} ?`))return + setAccessBusy(true);setAccessError('') + try{ + await drive.revokeUserAccess(accessUser.id,access.kind,access.space_id) + setAccesses(await drive.adminUserAccess(accessUser.id));refresh() + }catch(e){setAccessError(message(e))} + finally{setAccessBusy(false)} + } const remove=async(event)=>{ event.preventDefault() if(!selected||deleting||confirmation!==selected.username)return @@ -46,16 +65,35 @@ export default function AdminUsersPage(){ {[user.first_name,user.last_name].filter(Boolean).join(' ') || '—'} @{user.username}{user.email||'—'}{user.space_name||'—'} {user.is_active?'Actif':'Désactivé'}{user.is_admin?' · Admin':''} - {!user.is_admin && } +
{!user.is_admin && <> + + + }
)} {!record.users.length &&

Aucun utilisateur.

} } {record.limited &&

200 résultats affichés. Précise la recherche.

} + {if(!open&&!accessBusy)setAccessUser(null)}}> + + Accès de @{accessUser?.username} + Retirer l’accès à un espace partagé sans supprimer cet espace. + + {accessError &&

{accessError}

} + {accessBusy && } +
+ {accesses.map(access=>
+
{access.space_name}
{access.kind==='grant'?'Accès attribué':access.kind==='owner'?'Espace attribué':'Gestionnaire'}
+ +
)} + {!accessBusy && !accesses.length &&

Aucun accès à un espace partagé.

} +
+
+
{if(!open)close()}}> Supprimer l’utilisateur - Le compte sera désactivé et retiré de la liste. Son espace privé sera conservé. + Le compte sera désactivé. Son espace privé et tous ses fichiers seront définitivement supprimés.