From ac44c6ed8b3dda951f66b264cc2d2c8a8f5aee37 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Tue, 13 Oct 2026 23:47:19 +0200 Subject: [PATCH] nice --- ARCHITECTURE.md | 66 -- CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md | 42 ++ backend/core/urls.py | 3 + backend/drive/admin.py | 49 +- backend/drive/migrations/0004_spaces.py | 69 ++ ...drive_drive_space_i_fc801a_idx_and_more.py | 23 + backend/drive/migrations/0005_space_quotas.py | 12 + .../migrations/0006_merge_20261013_2159.py | 14 + .../0007_private_and_direct_access.py | 41 ++ .../migrations/0008_alter_spacegrant_id.py | 18 + .../drive/migrations/0009_managed_spaces.py | 7 + ...alter_spacegrant_id_0009_managed_spaces.py | 14 + backend/drive/models.py | 122 +++- backend/drive/quotas.py | 206 ++++++ backend/drive/services.py | 50 +- backend/drive/spaces.py | 169 +++++ backend/drive/tests.py | 587 +++++++++++++++--- backend/drive/urls.py | 14 + backend/drive/views.py | 2 +- backend/drive/views_content.py | 105 +++- backend/drive/views_items.py | 121 ++-- backend/drive/views_spaces.py | 358 +++++++++++ backend/drive/views_transfers.py | 96 ++- frontend/src/App.jsx | 19 +- frontend/src/api/client.js | 121 +++- .../src/components/drive/ContextActions.jsx | 65 ++ .../src/components/drive/DriveDialogs.jsx | 4 + .../src/components/drive/DriveExplorer.jsx | 53 +- .../src/components/drive/DriveSidebar.jsx | 105 +++- .../src/components/drive/DriveToolbar.jsx | 10 +- frontend/src/components/drive/FileGrid.jsx | 14 +- frontend/src/components/drive/FileList.jsx | 11 +- frontend/src/components/drive/QuotaField.jsx | 18 + frontend/src/components/drive/UploadTray.jsx | 76 +++ .../drive/dialogs/ItemActionsDialog.jsx | 44 +- .../drive/dialogs/ItemFormDialog.jsx | 2 +- .../drive/dialogs/MembersDialog.jsx | 16 + .../drive/dialogs/PreviewDialog.jsx | 32 +- .../drive/dialogs/SpaceSettingsDialog.jsx | 79 +++ frontend/src/config/navigation.js | 20 +- frontend/src/hooks/useDriveController.js | 180 ++++-- frontend/src/hooks/useUploadQueue.js | 226 +++++++ frontend/src/lib/quota-utils.js | 11 + frontend/src/lib/transfer-metrics.js | 41 ++ frontend/src/pages/DrivePage.jsx | 8 +- frontend/src/pages/InvitationPage.jsx | 55 ++ frontend/src/pages/LoginPage.jsx | 8 +- frontend/src/pages/MembersPage.jsx | 217 +++++++ frontend/tests/drive-fixes.test.js | 27 + frontend/tests/quotas.test.js | 19 + frontend/tests/simplification.test.js | 31 + frontend/tests/transfer-metrics.test.js | 29 + frontend/tests/transfers-live.test.js | 40 ++ frontend/tests/upload-multipart.test.js | 75 +++ frontend/vite.config.js | 11 + 55 files changed, 3365 insertions(+), 490 deletions(-) delete mode 100644 ARCHITECTURE.md create mode 100644 CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md create mode 100644 backend/drive/migrations/0004_spaces.py create mode 100644 backend/drive/migrations/0005_rename_drive_space_trash_idx_drive_drive_space_i_fc801a_idx_and_more.py create mode 100644 backend/drive/migrations/0005_space_quotas.py create mode 100644 backend/drive/migrations/0006_merge_20261013_2159.py create mode 100644 backend/drive/migrations/0007_private_and_direct_access.py create mode 100644 backend/drive/migrations/0008_alter_spacegrant_id.py create mode 100644 backend/drive/migrations/0009_managed_spaces.py create mode 100644 backend/drive/migrations/0010_merge_0008_alter_spacegrant_id_0009_managed_spaces.py create mode 100644 backend/drive/quotas.py create mode 100644 backend/drive/spaces.py create mode 100644 backend/drive/views_spaces.py create mode 100644 frontend/src/components/drive/ContextActions.jsx create mode 100644 frontend/src/components/drive/QuotaField.jsx create mode 100644 frontend/src/components/drive/UploadTray.jsx create mode 100644 frontend/src/components/drive/dialogs/MembersDialog.jsx create mode 100644 frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx create mode 100644 frontend/src/hooks/useUploadQueue.js create mode 100644 frontend/src/lib/quota-utils.js create mode 100644 frontend/src/lib/transfer-metrics.js create mode 100644 frontend/src/pages/InvitationPage.jsx create mode 100644 frontend/src/pages/MembersPage.jsx create mode 100644 frontend/tests/drive-fixes.test.js create mode 100644 frontend/tests/quotas.test.js create mode 100644 frontend/tests/simplification.test.js create mode 100644 frontend/tests/transfer-metrics.test.js create mode 100644 frontend/tests/transfers-live.test.js create mode 100644 frontend/tests/upload-multipart.test.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 99a7223..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,66 +0,0 @@ -# GoodDrive — organisation du projet - -Cette refonte **réorganise le code uniquement**. Elle ne modifie pas les URLs, les permissions, -les modèles, les migrations, les composants `shadcn/ui`, les variables `.env`, Docker ni le stockage. - -## Frontend (`frontend/src/`) - -| Emplacement | Responsabilité | -| --- | --- | -| `App.jsx` | Point d'entrée React : restauration de session et choix de la page | -| `pages/LoginPage.jsx` | Connexion et présentation de l'espace privé | -| `pages/DrivePage.jsx` | Assemblage de la page Drive, dépôt de fichiers et modales | -| `hooks/useDriveController.js` | État du navigateur de fichiers, recherche, actions, uploads et API | -| `config/navigation.js` | Sections et titres de navigation | -| `lib/drive-format.js` | Formatage des tailles/dates, types et couleurs de fichiers | -| `components/drive/DriveSidebar.jsx` | Navigation latérale, stockage utilisé, déconnexion | -| `components/drive/DriveToolbar.jsx` | Barre supérieure, recherche, thème, actualisation, import | -| `components/drive/DriveExplorer.jsx` | Fil d'Ariane, filtres/tri, états et liste des fichiers | -| `components/drive/FileList.jsx` | Affichage tableau | -| `components/drive/FileGrid.jsx` | Affichage grille | -| `components/drive/FileGlyph.jsx` | Icônes de fichiers/dossiers | -| `components/drive/StatusBanner.jsx` | Messages d'information et d'erreur | -| `components/drive/DriveDialogs.jsx` | Assemblage des fenêtres | -| `components/drive/dialogs/ItemFormDialog.jsx` | Nouveau dossier, renommage et déplacement | -| `components/drive/dialogs/ItemActionsDialog.jsx` | Choix des actions sur un élément | -| `components/drive/dialogs/DeleteDialog.jsx` | Confirmation de suppression définitive | -| `components/drive/dialogs/PreviewDialog.jsx` | Aperçu image, PDF et audio | -| `api/client.js` | Client Axios existant — inchangé | -| `components/ui/` | Composants shadcn existants — inchangés | - -**Règle pratique :** modifier l'affichage dans `components/drive`, le comportement dans -`hooks/useDriveController.js`, et les appels HTTP dans `api/client.js`. - -## Backend (`backend/drive/`) - -| Emplacement | Responsabilité | -| --- | --- | -| `views.py` | Réexport des endpoints existants, pour conserver `urls.py` inchangé | -| `services.py` | Droits, arborescence, validation des noms, sérialisation | -| `views_items.py` | Listes, stats, dossiers, renommage, partage, favoris et corbeille | -| `views_transfers.py` | Imports classiques, imports par blocs et reprise | -| `views_content.py` | Liens de téléchargement temporaires et envoi du contenu | -| `models.py`, `urls.py`, `migrations/` | Inchangés | - -## Installation - -Copier les fichiers du ZIP en respectant l'arborescence du dossier `GoodDrive/`, puis -reconstruire/redémarrer **frontend et backend**. Aucune migration SQL n'est nécessaire : - -```bash -docker compose up -d --build frontend backend -``` - -Dans Pterodactyl, utiliser le redéploiement habituel de l'image/site à la place. - -## Contrôles effectués - -- Syntaxe JavaScript/JSX analysée, imports locaux résolus et références JavaScript vérifiées. -- Python compilé avec `compileall`. -- Présence des 25 fonctions Django et comparaison de leurs corps avec l'archive source. -- Le build Vite complet et les tests Django sur MySQL ne peuvent pas être exécutés ici. - -> Remarque : `useDriveController` préserve l'appel d'origine à `drive.uploadResumable` -> pour les fichiers de 8 Mio et plus, mais cette méthode est absente de `api/client.js` -> dans l'archive fournie. Cela préexistait au refactoring et doit être corrigé -> séparément avant de considérer les gros imports comme opérationnels. diff --git a/CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md b/CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md new file mode 100644 index 0000000..2b5c2ec --- /dev/null +++ b/CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md @@ -0,0 +1,42 @@ +# GoodDrive — Correctifs aperçus, sessions et quotas + +## Aperçus + +- Les appels `/api` passent par le proxy Vite, du port frontend `1028` vers le service `backend:1029` ; l'URL signée pour l'aperçu reste sur le **même domaine**. +- L'API vérifie les permissions lors de la création du lien **et** de chaque demande de lecture, y compris les plages `Range` des vidéos. +- PDF et texte peuvent être affichés dans une iframe **de même origine** ; les autres pages restent protégées des intégrations externes. +- Les liens d'aperçu expirent après deux heures et ne dispensent jamais de la vérification des permissions. Une URL déjà ouverte pourra expirer pendant une vidéo très longue : rouvrir l'aperçu pour régénérer son lien. +- Une vérification HEAD signale un aperçu inaccessible au lieu d'afficher une page vide. Formats visés : images, PDF, audio, vidéo et textes courants ; les codecs vidéo dépendent du navigateur. + +## Connexion + +- `api` et renouvellement JWT utilisent la même origine `/api`. +- Le renouvellement est partagé entre requêtes concurrentes (une seule requête en cours). +- On déconnecte uniquement lorsque le serveur rejette **explicitement** le refresh token. Un problème réseau ou un backend indisponible conserve la session et permet de réessayer. + +## Navigation et gestion des espaces + +- Suppression de la rubrique « Espaces & invitations » de la navigation. +- Clic droit sur l'espace (bureau) ou `…` (mobile) : paramètres, accès et invitations via fenêtres dédiées. +- Le bouton `+` à côté d'« Espaces partagés » reste disponible pour créer un espace enfant. + +## Quotas imbriqués + +- Le calcul distingue **occupation réelle** (fichiers actifs, corbeille et uploads réservés) et **quotas attribués** aux sous-espaces. +- Chaque espace enfant avec quota réserve cette capacité au sein du quota de son parent ; les réservations ne sont jamais additionnées deux fois. +- Une limite est refusée si elle dépasse la capacité non attribuée restante du parent, ou si elle devient inférieure aux engagements des descendants. +- Un espace sans quota propre affiche son occupation et la capacité restante héritée de ses ancêtres. +- Les créations d'espaces, modifications de quotas et uploads utilisent des verrous transactionnels sur les ancêtres ; les paramètres de quota restent stockés dans les champs déjà présents, donc **aucune migration**. +- Les invitations non utilisées ne constituent pas des espaces physiques réservés. Un quota est revérifié lors de l'acceptation, au cas où la capacité disponible aurait changé. + +## Déploiement + +Remplacer les fichiers de l'archive puis reconstruire : + +```bash +docker compose up -d --build frontend backend +``` + +Après redémarrage, tester en conditions réelles : un aperçu PDF, une image, une vidéo MP4 compatible navigateur, le rafraîchissement du jeton après 30 minutes, et un parent de 10 Go avec deux sous-espaces de 7 et 4 Go (le second doit être refusé). + +Tests locaux effectués : analyse syntaxique JS/JSX, compilation syntaxique Python, tests Node frontend. Les tests Django avec base de données et le build Vite complet requièrent les dépendances non présentes dans cet environnement. diff --git a/backend/core/urls.py b/backend/core/urls.py index 702436c..9259728 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -2,7 +2,10 @@ from django.contrib import admin from django.urls import path, include from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from drive.views import me +from drive import views_spaces urlpatterns = [ + path('api/invitations//',views_spaces.invitation_preview), + path('api/invitations//accept/',views_spaces.accept_invitation), path('django-admin/', admin.site.urls), path('api/auth/login/', TokenObtainPairView.as_view()), path('api/auth/refresh/', TokenRefreshView.as_view()), diff --git a/backend/drive/admin.py b/backend/drive/admin.py index 366b515..7ef82fc 100644 --- a/backend/drive/admin.py +++ b/backend/drive/admin.py @@ -1,8 +1,43 @@ +"""Administration of users and shared spaces, without access to personal files.""" from django.contrib import admin -from .models import DriveItem -@admin.register(DriveItem) -class DriveItemAdmin(admin.ModelAdmin): - list_display=('name','owner','is_folder','is_shared','size','trashed_at','created_at') - list_filter=('is_folder','is_shared','trashed_at') - search_fields=('name','owner__username') - readonly_fields=('id','storage_key','size','created_at','updated_at') +from django.db.models import Q +from .models import DriveSpace, SpaceInvitation, SpaceGrant + + +@admin.register(DriveSpace) +class SpaceAdmin(admin.ModelAdmin): + list_display = ('id', 'display_name', 'user', 'parent', 'can_view_parent', 'can_write_parent', 'can_invite', 'quota_bytes') + search_fields = ('display_name', 'user__username') + readonly_fields = ('directory_name', 'relative_path') + + def get_queryset(self, request): + # Private spaces from other users never appear, even for a superuser. + return super().get_queryset(request).filter( + Q(private_owner__isnull=True) | Q(private_owner=request.user) + ) + + def _personal_other(self, request, obj): + return bool(obj and obj.private_owner_id and obj.private_owner_id != request.user.pk) + + def has_view_permission(self, request, obj=None): + return not self._personal_other(request, obj) and super().has_view_permission(request, obj) + + def has_change_permission(self, request, obj=None): + return not self._personal_other(request, obj) and super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + return not self._personal_other(request, obj) and super().has_delete_permission(request, obj) + + +@admin.register(SpaceInvitation) +class InvitationAdmin(admin.ModelAdmin): + list_display = ('id', 'parent_space', 'created_by', 'expires_at', 'quota_bytes', 'used_at', 'revoked_at') + readonly_fields = ('token_hash',) + + +@admin.register(SpaceGrant) +class SpaceGrantAdmin(admin.ModelAdmin): + list_display = ('space', 'user', 'can_write', 'can_invite') + +# DriveItem and UploadSession must NOT be exposed in the Django admin: +# they contain personal file names and private upload metadata. diff --git a/backend/drive/migrations/0004_spaces.py b/backend/drive/migrations/0004_spaces.py new file mode 100644 index 0000000..1c50aab --- /dev/null +++ b/backend/drive/migrations/0004_spaces.py @@ -0,0 +1,69 @@ +"""Introduce numbered user spaces without relocating any existing file bytes.""" +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +import uuid + + +def assign_existing(apps, schema_editor): + User = apps.get_model(*settings.AUTH_USER_MODEL.split('.')) + Space = apps.get_model('drive', 'DriveSpace') + Item = apps.get_model('drive', 'DriveItem') + Upload = apps.get_model('drive', 'UploadSession') + users = list(User.objects.order_by('-is_superuser', '-is_staff', 'pk')) + if not users: + return + root_user = users[0] + root = Space.objects.create(user_id=root_user.pk, relative_path='root', display_name='Racine') + for user in users[1:]: + s = Space.objects.create(user_id=user.pk, parent_id=root.pk, + relative_path='pending/%s' % uuid.uuid4().hex, + display_name=user.username) + s.directory_name = f'{s.pk - 1:04d}' + s.relative_path = f'root/{s.directory_name}' + s.save(update_fields=['directory_name','relative_path']) + for s in Space.objects.all(): + Item.objects.filter(owner_id=s.user_id).update(space_id=s.pk, is_shared=False) + Upload.objects.filter(owner_id=s.user_id).update(space_id=s.pk) + + +class Migration(migrations.Migration): + dependencies = [('drive','0003_rename_drive_owner_parent_idx_drive_drive_owner_i_b49586_idx_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.CreateModel(name='DriveSpace',fields=[ + ('id',models.BigAutoField(primary_key=True,serialize=False)), + ('directory_name',models.CharField(max_length=24,unique=True,null=True,blank=True)), + ('relative_path',models.CharField(max_length=700,unique=True)), + ('display_name',models.CharField(max_length=120)), + ('can_view_parent',models.BooleanField(default=True)), + ('can_write_parent',models.BooleanField(default=False)), + ('can_invite',models.BooleanField(default=False)), + ('created_at',models.DateTimeField(auto_now_add=True)), + ('user',models.OneToOneField(to=settings.AUTH_USER_MODEL,on_delete=django.db.models.deletion.PROTECT,related_name='drive_space')), + ('parent',models.ForeignKey(to='drive.drivespace',on_delete=django.db.models.deletion.PROTECT,related_name='children',null=True,blank=True)), + ]), + migrations.CreateModel(name='SpaceInvitation',fields=[ + ('id',models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)), + ('token_hash',models.CharField(max_length=64,unique=True)), + ('can_view_parent',models.BooleanField(default=True)), + ('can_write_parent',models.BooleanField(default=False)), + ('can_invite',models.BooleanField(default=False)), + ('expires_at',models.DateTimeField()), + ('used_at',models.DateTimeField(null=True,blank=True)), + ('revoked_at',models.DateTimeField(null=True,blank=True)), + ('created_at',models.DateTimeField(auto_now_add=True)), + ('created_by',models.ForeignKey(to=settings.AUTH_USER_MODEL,on_delete=django.db.models.deletion.PROTECT)), + ('used_by',models.ForeignKey(to=settings.AUTH_USER_MODEL,on_delete=django.db.models.deletion.SET_NULL,related_name='redeemed_invitations',null=True,blank=True)), + ('parent_space',models.ForeignKey(to='drive.drivespace',on_delete=django.db.models.deletion.CASCADE,related_name='invitations')), + ]), + migrations.AddField(model_name='driveitem',name='space',field=models.ForeignKey(to='drive.drivespace',on_delete=django.db.models.deletion.PROTECT,related_name='items',null=True)), + migrations.AddField(model_name='driveitem',name='storage_location',field=models.CharField(max_length=1200,blank=True)), + migrations.AddField(model_name='uploadsession',name='space',field=models.ForeignKey(to='drive.drivespace',on_delete=django.db.models.deletion.CASCADE,null=True)), + migrations.RunPython(assign_existing,migrations.RunPython.noop), + migrations.AlterField(model_name='driveitem',name='space',field=models.ForeignKey(to='drive.drivespace',on_delete=django.db.models.deletion.PROTECT,related_name='items')), + migrations.AlterField(model_name='uploadsession',name='space',field=models.ForeignKey(to='drive.drivespace',on_delete=django.db.models.deletion.CASCADE)), + migrations.AlterField(model_name='driveitem',name='owner',field=models.ForeignKey(to=settings.AUTH_USER_MODEL,on_delete=django.db.models.deletion.PROTECT,related_name='drive_items')), + migrations.RemoveIndex(model_name='driveitem',name='drive_drive_owner_i_a6138a_idx'), + migrations.AddIndex(model_name='driveitem',index=models.Index(fields=['space','trashed_at'],name='drive_space_trash_idx')), + ] diff --git a/backend/drive/migrations/0005_rename_drive_space_trash_idx_drive_drive_space_i_fc801a_idx_and_more.py b/backend/drive/migrations/0005_rename_drive_space_trash_idx_drive_drive_space_i_fc801a_idx_and_more.py new file mode 100644 index 0000000..8a2d2fb --- /dev/null +++ b/backend/drive/migrations/0005_rename_drive_space_trash_idx_drive_drive_space_i_fc801a_idx_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.18 on 2026-10-13 19:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('drive', '0004_spaces'), + ] + + operations = [ + migrations.RenameIndex( + model_name='driveitem', + new_name='drive_drive_space_i_fc801a_idx', + old_name='drive_space_trash_idx', + ), + migrations.AlterField( + model_name='drivespace', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/drive/migrations/0005_space_quotas.py b/backend/drive/migrations/0005_space_quotas.py new file mode 100644 index 0000000..b027fa1 --- /dev/null +++ b/backend/drive/migrations/0005_space_quotas.py @@ -0,0 +1,12 @@ +"""Per-space quota, and quota embedded in one-time invitations.""" +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [('drive', '0004_spaces')] + operations = [ + migrations.AddField(model_name='drivespace', name='quota_bytes', + field=models.BigIntegerField(null=True, blank=True, default=None)), + migrations.AddField(model_name='spaceinvitation', name='quota_bytes', + field=models.BigIntegerField(null=True, blank=True, default=None)), + ] diff --git a/backend/drive/migrations/0006_merge_20261013_2159.py b/backend/drive/migrations/0006_merge_20261013_2159.py new file mode 100644 index 0000000..de348a3 --- /dev/null +++ b/backend/drive/migrations/0006_merge_20261013_2159.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.18 on 2026-10-13 19:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('drive', '0005_rename_drive_space_trash_idx_drive_drive_space_i_fc801a_idx_and_more'), + ('drive', '0005_space_quotas'), + ] + + operations = [ + ] diff --git a/backend/drive/migrations/0007_private_and_direct_access.py b/backend/drive/migrations/0007_private_and_direct_access.py new file mode 100644 index 0000000..0515cf8 --- /dev/null +++ b/backend/drive/migrations/0007_private_and_direct_access.py @@ -0,0 +1,41 @@ +"""Add an independent private directory per user, retaining legacy files.""" +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +def provision_private_spaces(apps, schema_editor): + Space = apps.get_model('drive','DriveSpace') + # The previous shared spaces and their files remain intact. No path changes. + for shared in Space.objects.filter(user__isnull=False).order_by('pk'): + if Space.objects.filter(private_owner_id=shared.user_id).exists(): + continue + private = Space.objects.create( + user_id=None, private_owner_id=shared.user_id, + parent_id=shared.pk, relative_path=f'pending/{uuid.uuid4().hex}', + display_name='Mes fichiers') + private.directory_name=f'{private.pk-1:04d}' + private.relative_path=f'{shared.relative_path}/{private.directory_name}' + private.save(update_fields=['directory_name','relative_path']) + + +class Migration(migrations.Migration): + dependencies = [('drive','0006_merge_20261013_2159'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.AlterField(model_name='drivespace',name='user',field=models.OneToOneField( + to=settings.AUTH_USER_MODEL,on_delete=django.db.models.deletion.PROTECT, + related_name='drive_space',null=True,blank=True)), + migrations.AddField(model_name='drivespace',name='private_owner',field=models.OneToOneField( + to=settings.AUTH_USER_MODEL,on_delete=django.db.models.deletion.PROTECT, + related_name='private_drive_space',null=True,blank=True)), + migrations.CreateModel(name='SpaceGrant',fields=[ + ('id',models.BigAutoField(primary_key=True,serialize=False)), + ('can_write',models.BooleanField(default=False)), + ('can_invite',models.BooleanField(default=False)), + ('created_at',models.DateTimeField(auto_now_add=True)), + ('user',models.ForeignKey(to=settings.AUTH_USER_MODEL,on_delete=django.db.models.deletion.CASCADE,related_name='granted_spaces')), + ('space',models.ForeignKey(to='drive.drivespace',on_delete=django.db.models.deletion.CASCADE,related_name='access_grants')), + ],options={'constraints':[models.UniqueConstraint(fields=['user','space'],name='drive_unique_space_grant')]}), + migrations.RunPython(provision_private_spaces,migrations.RunPython.noop), + ] diff --git a/backend/drive/migrations/0008_alter_spacegrant_id.py b/backend/drive/migrations/0008_alter_spacegrant_id.py new file mode 100644 index 0000000..d17dead --- /dev/null +++ b/backend/drive/migrations/0008_alter_spacegrant_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.18 on 2026-10-13 20:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('drive', '0007_private_and_direct_access'), + ] + + operations = [ + migrations.AlterField( + model_name='spacegrant', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/drive/migrations/0009_managed_spaces.py b/backend/drive/migrations/0009_managed_spaces.py new file mode 100644 index 0000000..d073ce1 --- /dev/null +++ b/backend/drive/migrations/0009_managed_spaces.py @@ -0,0 +1,7 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +class Migration(migrations.Migration): + dependencies = [('drive', '0007_private_and_direct_access'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [migrations.AddField(model_name='drivespace', name='managed_by', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='managed_drive_spaces', to=settings.AUTH_USER_MODEL))] diff --git a/backend/drive/migrations/0010_merge_0008_alter_spacegrant_id_0009_managed_spaces.py b/backend/drive/migrations/0010_merge_0008_alter_spacegrant_id_0009_managed_spaces.py new file mode 100644 index 0000000..a9f24a6 --- /dev/null +++ b/backend/drive/migrations/0010_merge_0008_alter_spacegrant_id_0009_managed_spaces.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.18 on 2026-10-13 21:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('drive', '0008_alter_spacegrant_id'), + ('drive', '0009_managed_spaces'), + ] + + operations = [ + ] diff --git a/backend/drive/models.py b/backend/drive/models.py index 8613811..62f7e23 100644 --- a/backend/drive/models.py +++ b/backend/drive/models.py @@ -1,44 +1,104 @@ +"""GoodDrive's logical folders and delegated user spaces. + +A DriveSpace is a numbered physical directory. DriveItems remain DB-managed, +so renaming a space never renames its physical path. +""" import uuid from django.conf import settings from django.db import models from django.core.exceptions import ValidationError -class DriveItem(models.Model): - id=models.UUIDField(primary_key=True,default=uuid.uuid4,editable=False) - owner=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,related_name='drive_items') - parent=models.ForeignKey('self',null=True,blank=True,on_delete=models.CASCADE,related_name='children') - name=models.CharField(max_length=255) - is_folder=models.BooleanField(default=False) - storage_key=models.CharField(max_length=120,blank=True) - size=models.BigIntegerField(default=0) - mime_type=models.CharField(max_length=180,default='application/octet-stream') - is_shared=models.BooleanField(default=False) # share a ROOT item and its descendants with signed-in users - is_starred=models.BooleanField(default=False) - trashed_at=models.DateTimeField(null=True,blank=True) - created_at=models.DateTimeField(auto_now_add=True) - updated_at=models.DateTimeField(auto_now=True) + +class DriveSpace(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_space', null=True, blank=True) + managed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name='managed_drive_spaces') + private_owner = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='private_drive_space', null=True, blank=True) + parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.PROTECT, related_name='children') + directory_name = models.CharField(max_length=24, unique=True, null=True, blank=True) + relative_path = models.CharField(max_length=700, unique=True) + display_name = models.CharField(max_length=120) + can_view_parent = models.BooleanField(default=True) + can_write_parent = models.BooleanField(default=False) + can_invite = models.BooleanField(default=False) + quota_bytes = models.BigIntegerField(null=True, blank=True, default=None) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f'{self.display_name} ({self.relative_path})' + + +class SpaceGrant(models.Model): + """Grant access to an EXISTING account, without changing its hierarchy.""" + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='granted_spaces') + space = models.ForeignKey(DriveSpace, on_delete=models.CASCADE, related_name='access_grants') + can_write = models.BooleanField(default=False) + can_invite = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) class Meta: - ordering=['-is_folder','name','id'] - indexes=[models.Index(fields=['owner','parent']),models.Index(fields=['owner','trashed_at'])] + constraints = [models.UniqueConstraint(fields=['user', 'space'], name='drive_unique_space_grant')] + + +class SpaceInvitation(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + token_hash = models.CharField(max_length=64, unique=True) + parent_space = models.ForeignKey(DriveSpace, on_delete=models.CASCADE, related_name='invitations') + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + can_view_parent = models.BooleanField(default=True) + can_write_parent = models.BooleanField(default=False) + can_invite = models.BooleanField(default=False) + quota_bytes = models.BigIntegerField(null=True, blank=True, default=None) + expires_at = models.DateTimeField() + used_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='redeemed_invitations') + used_at = models.DateTimeField(null=True, blank=True) + revoked_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + +class DriveItem(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_items') + space = models.ForeignKey(DriveSpace, on_delete=models.PROTECT, related_name='items') + parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, related_name='children') + name = models.CharField(max_length=255) + is_folder = models.BooleanField(default=False) + storage_key = models.CharField(max_length=120, blank=True) + # Existing files keep a blank storage_location (legacy /files/ab/). + # New files go under /files/root/0001/.../ab/. + storage_location = models.CharField(max_length=1200, blank=True) + size = models.BigIntegerField(default=0) + mime_type = models.CharField(max_length=180, default='application/octet-stream') + is_shared = models.BooleanField(default=False) # legacy; no global sharing in hierarchical mode + is_starred = models.BooleanField(default=False) + trashed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-is_folder', 'name', 'id'] + indexes = [models.Index(fields=['owner','parent']), models.Index(fields=['space','trashed_at'])] def clean(self): - if not self.name or self.name in {'.','..'} or any(c in self.name for c in '/\\\x00\r\n'): + if not self.name or self.name in {'.', '..'} or any(c in self.name for c in '/\\\x00\r\n'): raise ValidationError('Nom de fichier ou dossier invalide.') - if len(self.name)>255: raise ValidationError('Nom trop long.') - if self.parent_id and not self.parent.is_folder: raise ValidationError('Le parent doit être un dossier.') - if self.parent_id and self.parent.owner_id != self.owner_id: raise ValidationError('Le propriétaire du dossier doit être identique.') - if self.is_shared and self.parent_id: raise ValidationError('Seuls les éléments racine sont partageables.') + if len(self.name) > 255: + raise ValidationError('Nom trop long.') + if self.parent_id and not self.parent.is_folder: + raise ValidationError('Le parent doit être un dossier.') + if self.parent_id and self.parent.space_id != self.space_id: + raise ValidationError('Le dossier de destination appartient à un autre espace.') + + def __str__(self): + return self.name - def __str__(self): return self.name class UploadSession(models.Model): - """Persisted resumable upload. Temporary bytes live on the storage volume.""" - id=models.UUIDField(primary_key=True,default=uuid.uuid4,editable=False) - owner=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE) - parent=models.ForeignKey(DriveItem,null=True,blank=True,on_delete=models.SET_NULL) - name=models.CharField(max_length=255) - total_size=models.BigIntegerField() - received=models.BigIntegerField(default=0) - created_at=models.DateTimeField(auto_now_add=True) - updated_at=models.DateTimeField(auto_now=True) + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + space = models.ForeignKey(DriveSpace, on_delete=models.CASCADE) + parent = models.ForeignKey(DriveItem, null=True, blank=True, on_delete=models.SET_NULL) + name = models.CharField(max_length=255) + total_size = models.BigIntegerField() + received = models.BigIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/drive/quotas.py b/backend/drive/quotas.py new file mode 100644 index 0000000..5517326 --- /dev/null +++ b/backend/drive/quotas.py @@ -0,0 +1,206 @@ +"""Space quotas, including descendants, trash, and reserved resumable uploads. + +A child can be constrained by both its own allocation and its ancestors'. +Call assert_space_capacity() inside transaction.atomic() before reserving/writing. +""" +from contextlib import contextmanager +from django.db import transaction +from django.db.models import Q, Sum +from .models import DriveItem, DriveSpace, UploadSession + +MAX_QUOTA_BYTES = 9_007_199_254_740_991 # exact integer in JS too + + +class QuotaExceeded(Exception): + pass + + +def parse_quota(value): + """None = unlimited. Accept only an integer number of bytes (0 is valid).""" + if value is None: + return None + if isinstance(value, bool) or isinstance(value, float): + raise ValueError('Le quota doit être un entier en octets, ou null pour illimité.') + if isinstance(value, str) and (not value.strip().isdigit()): + raise ValueError('Le quota doit être un entier en octets, ou null pour illimité.') + try: + quota = int(value) + except (TypeError, ValueError, OverflowError): + raise ValueError('Quota invalide.') from None + if quota < 0 or quota > MAX_QUOTA_BYTES: + raise ValueError('Quota hors limites (0 à 9 Po environ).') + return quota + + +def ancestry(space): + result = [] + seen = set() + current = space + while current is not None: + if current.pk in seen: + raise ValueError('Hiérarchie des espaces invalide.') + seen.add(current.pk) + result.append(current) + current = current.parent + return result + + +def subtree_space_ids(space): + """Use the stable DB path rather than a filesystem traversal.""" + return DriveSpace.objects.filter( + Q(relative_path=space.relative_path) | Q(relative_path__startswith=space.relative_path + '/') + ).values_list('pk', flat=True) + + +def occupancy(space): + """Count ALL files, even in the trash. Pending sessions reserve their full size.""" + ids = subtree_space_ids(space) + files = DriveItem.objects.filter(space_id__in=ids, is_folder=False).aggregate(n=Sum('size'))['n'] or 0 + pending = UploadSession.objects.filter(space_id__in=ids).aggregate(n=Sum('total_size'))['n'] or 0 + return int(files), int(pending) + + +def trash_occupancy(space): + """Bytes still on disk in the trash, including descendants of trashed folders. + + Ordinary (not trashed) folders require no traversal. Only traverse the + descendants of explicitly trashed folders; avoid counting trashed files twice. + """ + ids = subtree_space_ids(space) + bytes_in_trash = DriveItem.objects.filter( + space_id__in=ids, is_folder=False, trashed_at__isnull=False + ).aggregate(n=Sum('size'))['n'] or 0 + folder_ids = list(DriveItem.objects.filter( + space_id__in=ids, is_folder=True, trashed_at__isnull=False + ).values_list('pk', flat=True)) + visited = set(folder_ids) + while folder_ids: + next_folders = [] + for start in range(0, len(folder_ids), 300): + for pk, is_folder, size, trashed in DriveItem.objects.filter( + space_id__in=ids, parent_id__in=folder_ids[start:start + 300] + ).values_list('pk', 'is_folder', 'size', 'trashed_at'): + if is_folder: + if pk not in visited: + visited.add(pk) + next_folders.append(pk) + elif trashed is None: + bytes_in_trash += size + folder_ids = next_folders + return int(bytes_in_trash) + + +def _budget_tree(space): + """One DB snapshot of all descendant spaces and their direct physical usage. + + A child's fixed quota reserves that much capacity for it; a child without + a quota reserves only its actual consumption (including its descendants). + Thus quotas of siblings cannot silently overcommit their common parent. + """ + ids=list(subtree_space_ids(space)) + nodes={node.pk:node for node in DriveSpace.objects.filter(pk__in=ids)} + children={pk:[] for pk in nodes} + for node in nodes.values(): + if node.parent_id in children:children[node.parent_id].append(node.pk) + direct={pk:0 for pk in nodes} + for pk,amount in DriveItem.objects.filter(space_id__in=ids,is_folder=False).values('space_id').annotate(total=Sum('size')).values_list('space_id','total'): + direct[pk]+=int(amount or 0) + for pk,amount in UploadSession.objects.filter(space_id__in=ids).values('space_id').annotate(total=Sum('total_size')).values_list('space_id','total'): + direct[pk]+=int(amount or 0) + memo={} + def committed(pk): + if pk not in memo: + memo[pk]=direct[pk]+sum(max(nodes[c].quota_bytes or 0,committed(c)) for c in children[pk]) + return memo[pk] + committed(space.pk) + return memo + + +def _committed(space): + """Allocated bytes inside an entire subtree, including nested quota reservations.""" + return _budget_tree(space)[space.pk] + + +def available_for_growth(space): + """Bytes that can be added directly to space without breaking any ancestor allocation. + + Capacity already reserved for this child does not count against its parent's + available budget a second time. A zero-byte quota correctly permits no growth. + """ + maximum=MAX_QUOTA_BYTES + absorbed=0 + chain=ancestry(space) + committed_tree=_budget_tree(chain[-1]) + for ancestor in chain: + committed=committed_tree[ancestor.pk] + if ancestor.quota_bytes is not None: + maximum=min(maximum,ancestor.quota_bytes-committed+absorbed) + absorbed+=max(0,ancestor.quota_bytes-committed) + return None if maximum==MAX_QUOTA_BYTES else max(0,int(maximum)) + + +def assert_new_child_quota(parent, quota): + """A new nested space cannot reserve more than its parent's unallocated capacity.""" + if quota is None:return + remaining=available_for_growth(parent) + if remaining is not None and quota>remaining: + raise ValueError(f'Le quota dépasse les {remaining} octets disponibles dans l’espace parent.') + + +def assert_updated_quota(space, quota): + """Validate nested allocations, including sibling limits and actual disk use. + + The old reservation is replaced atomically with the requested value. Parent + quotas must be large enough for their direct content and all child quotas. + """ + chain=ancestry(space) + committed_tree=_budget_tree(chain[-1]) + committed=committed_tree[space.pk] + if quota is not None and quotaancestor.quota_bytes: + raise ValueError(f'Quota impossible : capacité de « {ancestor.display_name} » dépassée.') + old_child=max(ancestor.quota_bytes or 0,committed_tree[ancestor.pk]) + new_child=max(ancestor.quota_bytes or 0,amount) + + +def quota_state(space): + used,reserved=occupancy(space) + trashed=trash_occupancy(space) + available=available_for_growth(space) + return { + 'quota_bytes':space.quota_bytes, + 'used_bytes':used, + 'active_bytes':max(0,used-trashed), + 'trashed_bytes':trashed, + 'reserved_bytes':reserved, + 'available_bytes':available, + 'effective_limit_bytes':None if available is None else used+reserved+available, + } + + +@contextmanager +def locked_quota_tree(space): + """Lock ancestors in deterministic order so uploads and limit changes serialize.""" + if not transaction.get_connection().in_atomic_block: + raise RuntimeError('locked_quota_tree must run inside transaction.atomic()') + ids = sorted(a.pk for a in ancestry(space)) + list(DriveSpace.objects.select_for_update().filter(pk__in=ids).order_by('pk')) + yield + + +def assert_space_capacity(space, additional_bytes): + """Called with ancestor locks; include sibling allocations when checking uploads.""" + available=available_for_growth(space) + if available is not None and additional_bytes>available: + raise QuotaExceeded( + f'Espace insuffisant : {available} octets encore disponibles après les quotas des sous-espaces.' + ) diff --git a/backend/drive/services.py b/backend/drive/services.py index 4366310..97551fc 100644 --- a/backend/drive/services.py +++ b/backend/drive/services.py @@ -1,33 +1,33 @@ -"""Reusable file-tree permission, validation and serialization helpers.""" +"""Shared file-tree access and serialization helpers.""" import uuid from django.core.exceptions import ValidationError from django.http import Http404 -from .models import DriveItem - - -from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from .models import DriveItem +from .spaces import can_read_space, can_write_space def error(message, code=400): return Response({'detail':message}, status=code) -def item_info(item): - return { +def item_info(item, user=None): + data = { 'id':str(item.id), 'name':item.name, 'is_folder':item.is_folder, 'size':item.size,'mime_type':item.mime_type,'owner_id':item.owner_id, 'owner':item.owner.username,'parent':str(item.parent_id) if item.parent_id else None, - 'is_shared':item.is_shared,'is_starred':item.is_starred, + 'space_id':item.space_id, 'is_shared':False, 'trashed_at':item.trashed_at.isoformat() if item.trashed_at else None, 'created_at':item.created_at.isoformat(),'updated_at':item.updated_at.isoformat(), } + if user is not None: + data['can_write'] = can_write_space(user, item.space) + return data def get_item(pk): try: - return DriveItem.objects.select_related('owner','parent').get(pk=pk) + return DriveItem.objects.select_related('owner','parent','space').get(pk=pk) except (DriveItem.DoesNotExist,ValueError,ValidationError): raise Http404 @@ -45,16 +45,15 @@ def chain(item): def readable(item, user, allow_trashed=False): - ancestors=chain(item) - if not allow_trashed and any(n.trashed_at is not None for n in ancestors): + if not can_read_space(user,item.space): return False - if user.is_staff or user.is_superuser or item.owner_id == user.pk: - return True - return bool(ancestors[-1].is_shared and ancestors[-1].owner_id == item.owner_id) + if not allow_trashed and any(n.trashed_at is not None for n in chain(item)): + return False + return True def writable(item,user): - return user.is_staff or user.is_superuser or item.owner_id == user.pk + return can_write_space(user,item.space) def valid_name(name): @@ -63,32 +62,33 @@ def valid_name(name): name=name.strip() if name in ('.','..') or any(c in name for c in ('/','\\','\x00','\r','\n')): raise ValueError('Ce nom contient des caractères interdits.') - return name.strip() + return name -def target_parent(parent_id, user, owner_id=None): +def target_parent(parent_id, user, space): if not parent_id: return None try: - parent=DriveItem.objects.select_related('parent').get(pk=uuid.UUID(str(parent_id))) + parent=DriveItem.objects.select_related('parent','space').get(pk=uuid.UUID(str(parent_id))) except (ValueError,DriveItem.DoesNotExist,AttributeError): raise ValueError('Dossier de destination introuvable.') + if parent.space_id != space.id: + raise PermissionError('Impossible de déplacer un élément entre deux espaces.') if not parent.is_folder or not readable(parent,user) or not writable(parent,user): raise PermissionError('Dossier de destination inaccessible en écriture.') - if owner_id is not None and parent.owner_id != owner_id: - raise PermissionError('Le propriétaire doit être identique.') return parent def descendant_files(node): - # Collect all physical paths before cascade delete. files=[] pending=[node.pk] while pending: batch=pending[:500];pending=pending[500:] - children=list(DriveItem.objects.filter(parent_id__in=batch).values_list('pk','is_folder','storage_key')) + children=list(DriveItem.objects.filter(parent_id__in=batch).values_list('pk','is_folder','storage_key','storage_location')) pending.extend(row[0] for row in children) - for _,is_folder,key in children: - if not is_folder and key: files.append(key) - if not node.is_folder and node.storage_key:files.append(node.storage_key) + for _,is_folder,key,location in children: + if not is_folder and key: files.append(location or f'{key[:2]}/{key}') + if not node.is_folder and node.storage_key: + key=node.storage_key + files.append(node.storage_location or f'{key[:2]}/{key}') return files diff --git a/backend/drive/spaces.py b/backend/drive/spaces.py new file mode 100644 index 0000000..048dea1 --- /dev/null +++ b/backend/drive/spaces.py @@ -0,0 +1,169 @@ +"""Physical numbered directories and strict permissions between personal/shared spaces.""" +import hashlib +import secrets +from pathlib import Path +from django.conf import settings +from django.db import transaction +from django.http import Http404 +from .models import DriveSpace, SpaceGrant + + +def is_admin(user): + return user.is_authenticated and (user.is_superuser or user.is_staff) + + +def ensure_space(user): + """One shared/assigned space for every user; the administrator owns root.""" + obj = DriveSpace.objects.filter(user=user, private_owner__isnull=True).first() + if obj: + (Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True) + return obj + with transaction.atomic(): + obj = DriveSpace.objects.select_for_update().filter(user=user, private_owner__isnull=True).first() + if obj: + return obj + root = DriveSpace.objects.filter(parent__isnull=True, private_owner__isnull=True).order_by('id').first() + if root is None: + if not is_admin(user): + raise PermissionError('Un administrateur doit initialiser le Drive.') + obj = DriveSpace.objects.create(user=user, relative_path='root', display_name='Racine') + else: + obj = create_space(user, root, user.username) + return obj + + +def _make_numbered_space(parent, **fields): + if len(parent.relative_path) + 1 + 24 > 700: + raise ValueError('Hiérarchie trop profonde.') + obj = DriveSpace.objects.create(parent=parent, relative_path=f'pending/{secrets.token_hex(10)}', **fields) + obj.directory_name = f'{obj.pk - 1:04d}' + obj.relative_path = f'{parent.relative_path}/{obj.directory_name}' + obj.save(update_fields=['directory_name', 'relative_path']) + (Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True) + return obj + + +def create_space(user, parent, display_name, **permissions): + """Create a delegated (shared) space owned by a new user.""" + if parent.private_owner_id: + raise ValueError('Un espace privé ne peut pas accueillir de sous-utilisateur.') + return _make_numbered_space(parent, user=user, display_name=display_name, **permissions) + + +def ensure_private_space(user): + """Every account has another, strictly private, numbered directory.""" + obj = DriveSpace.objects.filter(private_owner=user).first() + if obj: + (Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True) + return obj + with transaction.atomic(): + obj = DriveSpace.objects.select_for_update().filter(private_owner=user).first() + if obj: + return obj + shared = ensure_space(user) + return _make_numbered_space(shared, private_owner=user, display_name='Mes fichiers') + + +def my_space(user): + return ensure_space(user) + + +def _grant(user, space): + if not user.is_authenticated or not user.is_active: + return None + return SpaceGrant.objects.filter(user=user, space=space).first() + + +def can_read_space(user, space): + if not user.is_authenticated or not user.is_active: + return False + # Personal files are never subject to administrative or inherited access. + if space.private_owner_id: + return space.private_owner_id == user.pk + if is_admin(user) or space.managed_by_id == user.pk: + return True + own = ensure_space(user) + if own.pk == space.pk: + return True + if own.parent_id == space.pk and own.can_view_parent: + return True + return _grant(user, space) is not None + + +def can_write_space(user, space): + if not user.is_authenticated or not user.is_active: + return False + # Personal files are never subject to administrative or inherited access. + if space.private_owner_id: + return space.private_owner_id == user.pk + if is_admin(user) or space.managed_by_id == user.pk: + return True + own = ensure_space(user) + if own.pk == space.pk: + return True + if own.parent_id == space.pk and own.can_view_parent and own.can_write_parent: + return True + grant = _grant(user, space) + return bool(grant and grant.can_write) + + +def can_manage_space(user, space): + if not user.is_authenticated or not user.is_active or space.private_owner_id: + return False + if is_admin(user) or space.managed_by_id == user.pk: + return True + if space.user_id == user.pk and space.can_invite: + return True + grant = _grant(user, space) + return bool(grant and grant.can_invite) + + +def resolve_space(user, space_id=None, write=False): + if not space_id: + space = ensure_private_space(user) + else: + try: + space = DriveSpace.objects.select_related('user', 'private_owner', 'parent').get(pk=int(space_id)) + except (ValueError, TypeError, DriveSpace.DoesNotExist): + raise Http404 + permitted = can_write_space(user, space) if write else can_read_space(user, space) + if not permitted: + raise PermissionError('Vous n’avez pas accès à cet espace.') + return space + + +def space_info(space, user): + from .quotas import quota_state + owner = space.private_owner or space.user or space.managed_by + return { + 'id': space.pk, 'user_id': owner.pk, 'username': owner.username, + 'first_name': owner.first_name, 'last_name': owner.last_name, + 'parent_id': space.parent_id, 'display_name': space.display_name, + 'is_private': bool(space.private_owner_id), + 'can_read': can_read_space(user, space), 'can_write': can_write_space(user, space), + 'can_invite': can_manage_space(user, space), 'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True, + **quota_state(space), + 'can_view_parent': space.can_view_parent, 'can_write_parent': space.can_write_parent, + 'created_at': space.created_at.isoformat(), + } + + +def storage_path(item): + key = item.storage_key + if not key or len(key) != 36: + raise Http404 + relative = item.storage_location or f'{key[:2]}/{key}' + path = Path(settings.DRIVE_STORAGE_ROOT) / relative + root = Path(settings.DRIVE_STORAGE_ROOT).resolve() + resolved = path.resolve() + if not resolved.is_relative_to(root): + raise Http404 + return resolved + + +def new_storage_location(space, key): + return f'{space.relative_path}/{key[:2]}/{key}' + + +def hash_token(token): + return hashlib.sha256(token.encode('utf-8')).hexdigest() diff --git a/backend/drive/tests.py b/backend/drive/tests.py index c509971..db185bb 100644 --- a/backend/drive/tests.py +++ b/backend/drive/tests.py @@ -1,81 +1,528 @@ -import io +"""Regression tests for private spaces, delegation and legacy Drive operations. + +Run with: USE_SQLITE=true python manage.py test drive +""" import tempfile from pathlib import Path -from unittest.mock import patch from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import TestCase,override_settings +from django.test import TestCase, override_settings from rest_framework.test import APIClient -from .models import DriveItem +from .models import DriveItem, DriveSpace, SpaceInvitation, SpaceGrant -class DriveTests(TestCase): + +class HierarchicalDriveTests(TestCase): def setUp(self): - User=get_user_model() - self.a=User.objects.create_user('alice',password='Some-password12') - self.b=User.objects.create_user('bob',password='Some-password12') self.tmp=tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) - self.override=override_settings(DRIVE_STORAGE_ROOT=Path(self.tmp.name)) - self.override.enable();self.addCleanup(self.override.disable) - self.client=APIClient();self.client.force_authenticate(user=self.a) - def folder(self,name,parent=None): - return self.client.post('/api/drive/folders/',{'name':name,'parent':parent},format='json') - def test_owner_and_shared_access(self): - r=self.folder('Photos');self.assertEqual(r.status_code,201) - folder=r.data['id'] - self.client.force_authenticate(user=self.b) - self.assertEqual(self.client.get(f'/api/drive/items/?parent={folder}').status_code,403) - self.client.force_authenticate(user=self.a) - self.client.patch(f'/api/drive/items/{folder}/',{'is_shared':True},format='json') - self.client.force_authenticate(user=self.b) - self.assertEqual(self.client.get(f'/api/drive/items/?parent={folder}').status_code,200) - self.assertEqual(self.folder('Nope',folder).status_code,403) - def test_upload_download_and_trash(self): - res=self.client.post('/api/drive/uploads/',{'files':[SimpleUploadedFile('hello.txt',b'hello world')]},format='multipart') + self.overrides=override_settings(DRIVE_STORAGE_ROOT=Path(self.tmp.name), + FILE_UPLOAD_TEMP_DIR=str(Path(self.tmp.name)/'tmp')) + self.overrides.enable();self.addCleanup(self.overrides.disable) + User=get_user_model() + self.admin=User.objects.create_superuser('admin', 'admin@example.test','Safe-password123!') + self.client=APIClient() + self.as_user(self.admin) + self.root=self.client.get('/api/auth/me/').data['space_id'] + + def as_user(self,user): + self.client.force_authenticate(user=user) + + def invite(self,**rights): + result=self.client.post('/api/drive/invitations/',rights,format='json') + self.assertEqual(result.status_code,201,result.data) + return result.data['token'] + + def accept(self,token,username): + self.as_user(None) + result=self.client.post(f'/api/invitations/{token}/accept/', + {'username':username,'password':'Safe-password123!', 'display_name':username+' perso', + 'first_name':username.title(), 'last_name':'Test'},format='json') + self.assertEqual(result.status_code,201,result.data) + return get_user_model().objects.get(username=username) + + def folder(self,space_id=None,parent=None,name='Documents'): + data={'name':name,'parent':parent} + if space_id:data['space']=space_id + return self.client.post('/api/drive/folders/',data,format='json') + + def test_global_trash_across_visible_spaces_without_private_leaks(self): + self.as_user(self.admin) + token=self.invite(can_view_parent=True) + alice=self.accept(token,'alice') + token=self.invite() + bob=self.accept(token,'bob') + self.as_user(self.admin) + root_item=self.folder(space_id=self.root,name='root deleted').data['id'] + self.client.post(f'/api/drive/items/{root_item}/trash/') + self.as_user(alice) + personal_item=self.folder(name='alice deleted').data['id'] + self.client.post(f'/api/drive/items/{personal_item}/trash/') + self.as_user(bob) + secret_item=self.folder(name='bob secret').data['id'] + self.client.post(f'/api/drive/items/{secret_item}/trash/') + self.as_user(alice) + # Selecting any space still shows the entire readable trash. + result=self.client.get('/api/drive/items/',{'view':'trash','space':self.root}) + self.assertEqual(result.status_code,200,result.data) + names={item['name'] for item in result.data['items']} + self.assertIn('root deleted',names) + self.assertIn('alice deleted',names) + self.assertNotIn('bob secret',names) + self.as_user(self.admin) + names={item['name'] for item in self.client.get('/api/drive/items/',{'view':'trash'}).data['items']} + self.assertNotIn('alice deleted',names) + self.assertNotIn('bob secret',names) + + def test_standalone_shared_space_creation_and_management(self): + self.as_user(self.admin) + created=self.client.post('/api/drive/spaces/create/', + {'display_name':'Projets communs','parent_space':self.root,'quota_bytes':None},format='json') + self.assertEqual(created.status_code,201,created.data) + space_id=created.data['id'] + space=DriveSpace.objects.get(pk=space_id) + self.assertEqual(space.managed_by,self.admin) + self.assertRegex(space.directory_name,r'^\d{4,}$') + self.assertTrue((Path(self.tmp.name)/space.relative_path).is_dir()) + self.assertEqual(self.folder(space_id,name='Équipe').status_code,201) + updated=self.client.patch(f'/api/drive/spaces/{space_id}/',{'display_name':'Archives équipe'},format='json') + self.assertEqual(updated.status_code,200,updated.data) + self.assertEqual(updated.data['display_name'],'Archives équipe') + alice=self.accept(self.invite(),'alice') + self.as_user(alice) + self.assertEqual(self.client.get('/api/drive/items/',{'space':space_id}).status_code,403) + self.assertEqual(self.client.patch(f'/api/drive/spaces/{space_id}/',{'display_name':'Piraté'},format='json').status_code,403) + + def test_signed_preview_supports_byte_ranges_and_checks_revoked_permissions(self): + self.as_user(self.admin) + new=self.client.post('/api/drive/spaces/create/',{'display_name':'Vidéos'},format='json').data + created=self.client.post('/api/drive/uploads/',{'space':new['id'],'files':[SimpleUploadedFile('clip.mp4',b'0123456789')]},format='multipart') + self.assertEqual(created.status_code,201,created.data) + item_id=created.data['items'][0]['id'] + alice=self.accept(self.invite(),'alice') + self.as_user(self.admin) + SpaceGrant.objects.create(user=alice,space_id=new['id'],can_write=False) + self.as_user(alice) + token_url=self.client.post(f'/api/drive/items/{item_id}/link/',{'inline':True},format='json') + self.assertEqual(token_url.status_code,200,token_url.data) + response=self.client.get(token_url.data['url'],HTTP_RANGE='bytes=2-5') + self.assertEqual(response.status_code,206) + self.assertEqual(response['Content-Range'],'bytes 2-5/10') + self.assertEqual(b''.join(response.streaming_content),b'2345') + grant=SpaceGrant.objects.get(user=alice,space_id=new['id']) + grant.delete() + self.assertEqual(self.client.get(token_url.data['url']).status_code,404) + + def test_numbered_directories_and_parent_readonly(self): + token=self.invite(can_view_parent=True,can_write_parent=False) + child=self.accept(token,'alice') + space=DriveSpace.objects.get(user=child) + self.assertRegex(space.directory_name, r'^\d{4,}$') + self.assertEqual(space.relative_path, f'root/{space.directory_name}') + self.assertTrue((Path(self.tmp.name)/space.relative_path).is_dir()) + self.as_user(self.admin) + folder=self.folder(name='Racine').data['id'] + self.as_user(child) + self.assertEqual(self.client.get(f'/api/drive/items/?space={self.root}').status_code,200) + self.assertEqual(self.folder(self.root,name='Interdit').status_code,403) + self.assertEqual(self.client.patch(f'/api/drive/items/{folder}/',{'name':'Fail'},format='json').status_code,403) + self.assertEqual(self.folder(space.pk,name='Partagé').status_code,201) + + def test_private_sibling_cannot_be_read_or_listed(self): + self.as_user(self.admin);a=self.invite() + alice=self.accept(a,'alice') + self.as_user(self.admin);b=self.invite() + bob=self.accept(b,'bob') + self.as_user(alice) + folder=self.folder(name='Secret') + self.assertEqual(folder.status_code,201) + alice_space=DriveSpace.objects.get(user=alice) + self.as_user(bob) + self.assertEqual(self.client.get(f'/api/drive/items/?space={alice_space.pk}').status_code,403) + self.assertEqual(self.client.get(f'/api/drive/items/?parent={folder.data["id"]}').status_code,403) + self.as_user(self.admin) + self.assertEqual(self.client.get(f'/api/drive/items/?space={alice_space.pk}').status_code,200) + + def test_delegate_invitations_and_revocation(self): + self.as_user(self.admin) + first=self.invite(can_view_parent=True,can_invite=True) + alice=self.accept(first,'alice') + self.as_user(alice) + second=self.invite(can_view_parent=True) + bob=self.accept(second,'bob') + bspace=DriveSpace.objects.get(user=bob) + self.assertEqual(bspace.parent.user,alice) + self.assertEqual(bspace.relative_path, f'{DriveSpace.objects.get(user=alice).relative_path}/{bspace.directory_name}') + self.as_user(bob) + self.assertEqual(self.client.post('/api/drive/invitations/',{},format='json').status_code,403) + self.as_user(alice) + pending=self.invite() + self.as_user(self.admin) + space=DriveSpace.objects.get(user=alice) + denied=self.client.patch(f'/api/drive/spaces/members/{space.pk}/',{'can_invite':False},format='json') + self.assertEqual(denied.status_code,200) + self.as_user(None) + self.assertEqual(self.client.get(f'/api/invitations/{pending}/').status_code,404) + + def test_parent_edit_permission_and_no_cross_space_move(self): + self.as_user(self.admin) + token=self.invite(can_view_parent=True,can_write_parent=True) + child=self.accept(token,'alice') + self.as_user(child) + folder=self.folder(self.root,name='Sur racine') + self.assertEqual(folder.status_code,201) + own=self.folder(name='Perso') + self.assertEqual(own.status_code,201) + moved=self.client.patch(f'/api/drive/items/{own.data["id"]}/',{'parent':folder.data['id']},format='json') + self.assertEqual(moved.status_code,403) + + def test_upload_storage_and_signed_access(self): + self.as_user(self.admin) + token=self.invite() + alice=self.accept(token,'alice') + self.as_user(alice) + res=self.client.post('/api/drive/uploads/',{'files':[SimpleUploadedFile('a.txt',b'hello')]},format='multipart') self.assertEqual(res.status_code,201,res.data) - pk=res.data['items'][0]['id'] - link=self.client.post(f'/api/drive/items/{pk}/link/',{},format='json') - self.assertEqual(link.status_code,200) - resp=self.client.get(link.data['url']) - self.assertEqual(b''.join(resp.streaming_content),b'hello world') - self.client.force_authenticate(user=self.b) - self.assertEqual(self.client.post(f'/api/drive/items/{pk}/link/').status_code,403) - self.client.force_authenticate(user=self.a) - self.assertEqual(self.client.post(f'/api/drive/items/{pk}/trash/').status_code,200) - self.assertEqual(self.client.get(link.data['url']).status_code,404) - self.assertEqual(self.client.post(f'/api/drive/items/{pk}/restore/').status_code,200) - self.assertEqual(self.client.get(link.data['url']).status_code,200) - def test_cycle_and_invalid_path(self): - parent=self.folder('Parent').data['id'] - child=self.folder('Child',parent).data['id'] - self.assertEqual(self.client.patch(f'/api/drive/items/{parent}/',{'parent':child},format='json').status_code,400) - self.assertEqual(self.folder('../escape').status_code,400) - def test_shared_parent_descendants(self): - top=self.folder('Shared').data['id'] - inner=self.folder('Nested',top).data['id'] - self.client.patch(f'/api/drive/items/{top}/',{'is_shared':True},format='json') - self.client.force_authenticate(user=self.b) - self.assertEqual(self.client.get(f'/api/drive/items/?parent={inner}').status_code,200) - def test_resume_upload(self): - start=self.client.post('/api/drive/uploads/start/',{'name':'big.bin','size':6},format='json') - self.assertEqual(start.status_code,201,start.data) - pk=start.data['id'] - chunk=self.client.post(f'/api/drive/uploads/{pk}/chunk/',{ - 'offset':'0','part':SimpleUploadedFile('part.bin',b'abc')},format='multipart') + item=DriveItem.objects.get(pk=res.data['items'][0]['id']) + self.assertTrue(item.storage_location.startswith(DriveSpace.objects.get(private_owner=alice).relative_path + '/')) + self.assertTrue((Path(self.tmp.name)/item.storage_location).exists()) + url=self.client.post(f'/api/drive/items/{item.pk}/link/',{},format='json').data['url'] + self.assertEqual(b''.join(self.client.get(url).streaming_content),b'hello') + self.as_user(self.admin) + self.assertEqual(self.client.post(f'/api/drive/items/{item.pk}/link/').status_code,403) + + def test_invitation_one_time_only_and_rename_does_not_move_directory(self): + self.as_user(self.admin);token=self.invite() + alice=self.accept(token,'alice') + self.as_user(None) + self.assertEqual(self.client.get(f'/api/invitations/{token}/').status_code,404) + self.assertEqual(self.client.post(f'/api/invitations/{token}/accept/',{},format='json').status_code,400) + self.as_user(alice) + space=DriveSpace.objects.get(user=alice) + original=space.relative_path + changed=self.client.patch('/api/drive/spaces/me/',{'display_name':'Vacances'},format='json') + self.assertEqual(changed.status_code,200) + space.refresh_from_db() + self.assertEqual(space.display_name,'Vacances') + self.assertEqual(space.relative_path,original) + + def test_cannot_grant_parent_write_without_read(self): + self.as_user(self.admin) + res=self.client.post('/api/drive/invitations/',{'can_view_parent':False,'can_write_parent':True},format='json') + self.assertEqual(res.status_code,400) + + def test_legacy_starred_and_recent_views_not_available(self): + self.as_user(self.admin) + for view in ('starred', 'recent'): + with self.subTest(view=view): + result = self.client.get('/api/drive/items/', {'view': view}) + self.assertEqual(result.status_code, 400) + + def test_personal_files_are_private_even_when_shared_space_is_writable(self): + self.as_user(self.admin) + alice = self.accept(self.invite(can_view_parent=True,can_write_parent=True),'alice') + self.as_user(self.admin) + bob = self.accept(self.invite(can_view_parent=True,can_write_parent=True),'bob') + private_alice=DriveSpace.objects.get(private_owner=alice) + assigned_alice=DriveSpace.objects.get(user=alice) + self.as_user(alice) + secret=self.folder(name='Secret personnel') + self.assertEqual(secret.status_code,201,secret.data) + self.assertEqual(DriveItem.objects.get(pk=secret.data['id']).space,private_alice) + self.assertEqual(self.folder(assigned_alice.pk,name='Dossier partagé').status_code,201) + self.as_user(bob) + self.assertEqual(self.client.get('/api/drive/items/',{'space':private_alice.pk}).status_code,403) + self.assertEqual(self.client.get('/api/drive/items/',{'space':assigned_alice.pk}).status_code,403) + self.assertEqual(self.client.patch(f'/api/drive/items/{secret.data["id"]}/',{'name':'Intrusion'},format='json').status_code,403) + self.as_user(self.admin) + self.assertEqual(self.client.get('/api/drive/items/',{'space':private_alice.pk}).status_code,403) + self.assertEqual(self.client.get('/api/drive/stats/',{'space':private_alice.pk}).status_code,403) + self.assertEqual(self.client.post(f'/api/drive/items/{secret.data["id"]}/link/',{},format='json').status_code,403) + self.assertEqual(self.client.patch(f'/api/drive/items/{secret.data["id"]}/',{'name':'Intrusion admin'},format='json').status_code,403) + self.assertEqual(self.client.post(f'/api/drive/items/{secret.data["id"]}/trash/').status_code,403) + self.assertEqual(self.client.patch(f'/api/drive/spaces/{private_alice.pk}/quota/',{'quota_bytes':100},format='json').status_code,403) + self.assertNotIn(private_alice.pk,[s['id'] for s in self.client.get('/api/drive/spaces/').data['spaces']]) + self.as_user(alice) + self.assertEqual(self.client.get('/api/drive/items/',{'space':private_alice.pk}).status_code,200) + self.assertEqual(self.client.post(f'/api/drive/items/{secret.data["id"]}/trash/').status_code,200) + + def test_direct_user_access_can_be_granted_and_revoked(self): + self.as_user(self.admin) + alice=self.accept(self.invite(can_invite=True),'alice') + self.as_user(self.admin) + bob=self.accept(self.invite(),'bob') + alice_shared=DriveSpace.objects.get(user=alice) + alice_private=DriveSpace.objects.get(private_owner=alice) + self.as_user(alice) + search=self.client.get('/api/drive/spaces/users/search/',{'q':'bo'}) + self.assertEqual(search.status_code,200,search.data) + self.assertEqual(search.data['users'][0]['first_name'],'Bob') + self.assertEqual(search.data['users'][0]['last_name'],'Test') + grant=self.client.post('/api/drive/spaces/users/',{'user_id':bob.pk,'can_write':False},format='json') + self.assertEqual(grant.status_code,201,grant.data) + self.as_user(bob) + self.assertEqual(self.client.get('/api/drive/items/',{'space':alice_shared.pk}).status_code,200) + self.assertEqual(self.folder(alice_shared.pk,name='No write').status_code,403) + self.assertEqual(self.client.get('/api/drive/items/',{'space':alice_private.pk}).status_code,403) + self.as_user(alice) + changed=self.client.patch(f'/api/drive/spaces/users/{grant.data["id"]}/',{'can_write':True},format='json') + self.assertEqual(changed.status_code,200,changed.data) + self.as_user(bob) + self.assertEqual(self.folder(alice_shared.pk,name='Now writable').status_code,201) + self.as_user(alice) + self.assertEqual(self.client.delete(f'/api/drive/spaces/users/{grant.data["id"]}/').status_code,204) + self.as_user(bob) + self.assertEqual(self.client.get('/api/drive/items/',{'space':alice_shared.pk}).status_code,403) + + def test_registration_names_and_virtual_names_only(self): + self.as_user(self.admin) + token=self.invite() + self.as_user(None) + incomplete=self.client.post(f'/api/invitations/{token}/accept/',{ + 'username':'no_name','password':'Safe-password123!', 'display_name':'Mon espace'},format='json') + self.assertEqual(incomplete.status_code,400) + person=self.accept(token,'alice') + self.as_user(person) + me=self.client.get('/api/auth/me/').data + self.assertEqual(me['first_name'],'Alice') + self.assertEqual(me['last_name'],'Test') + all_spaces=self.client.get('/api/drive/spaces/').data + self.assertEqual(all_spaces['mine'],me['private_space_id']) + self.assertEqual(all_spaces['assigned'],me['space_id']) + self.assertNotEqual(all_spaces['mine'],all_spaces['assigned']) + self.assertTrue(all('directory' not in space and 'relative_path' not in space for space in all_spaces['spaces'])) + +class QuotaTests(HierarchicalDriveTests): + """Quotas apply to descendants, trash and reservations, not just visible files.""" + + def upload_small(self, contents, space_id=None): + data={'files':[SimpleUploadedFile('data.bin',contents)]} + if space_id is not None:data['space']=space_id + return self.client.post('/api/drive/uploads/',data,format='multipart') + + def test_invitation_quota_enforced_direct_and_trash(self): + token=self.invite(quota_bytes=5) + person=self.accept(token,'limited') + space=DriveSpace.objects.get(user=person) + self.assertEqual(space.quota_bytes,5) + self.as_user(person) + first=self.upload_small(b'1234') + self.assertEqual(first.status_code,201,first.data) + stat=self.client.get('/api/drive/stats/').data + self.assertEqual(stat['used_bytes'],4) + self.assertEqual(stat['available_bytes'],1) + self.assertEqual(self.upload_small(b'89').status_code,413) + item_id=first.data['items'][0]['id'] + self.assertEqual(self.client.post(f'/api/drive/items/{item_id}/trash/').status_code,200) + # Trashed files are still physically stored and consume quota. + self.assertEqual(self.client.get('/api/drive/stats/').data['used_bytes'],4) + self.assertEqual(self.upload_small(b'89').status_code,413) + self.assertEqual(self.client.delete(f'/api/drive/items/{item_id}/delete/').status_code,204) + self.assertEqual(self.client.get('/api/drive/stats/').data['available_bytes'],5) + self.assertEqual(self.upload_small(b'89').status_code,201) + + def test_resumable_upload_reserves_entire_file(self): + user=self.accept(self.invite(quota_bytes=10),'uploader') + self.as_user(user) + begin=self.client.post('/api/drive/uploads/start/',{'name':'video.bin','size':8},format='json') + self.assertEqual(begin.status_code,201,begin.data) + upload_id=begin.data['id'] + self.assertEqual(self.client.get('/api/drive/stats/').data['reserved_bytes'],8) + self.assertEqual(self.upload_small(b'abc').status_code,413) + self.assertEqual(self.client.post('/api/drive/uploads/start/',{'name':'second','size':3},format='json').status_code,413) + self.assertEqual(self.client.delete(f'/api/drive/uploads/{upload_id}/cancel/').status_code,204) + self.assertEqual(self.client.get('/api/drive/stats/').data['reserved_bytes'],0) + self.assertEqual(self.upload_small(b'abc').status_code,201) + + def test_parent_quota_includes_nested_spaces(self): + alice=self.accept(self.invite(quota_bytes=9,can_invite=True),'alice') + self.as_user(alice) + bob=self.accept(self.invite(quota_bytes=20),'bob') + self.as_user(bob) + self.assertEqual(self.upload_small(b'x'*7).status_code,201) + data=self.client.get('/api/drive/stats/').data + self.assertEqual(data['quota_bytes'],20) + self.assertEqual(data['available_bytes'],2) # parent's 9-byte cap + self.assertEqual(self.upload_small(b'y'*3).status_code,413) + self.as_user(alice) + self.assertEqual(self.client.get('/api/drive/stats/',{'space':DriveSpace.objects.get(user=alice).pk}).data['used_bytes'],7) + self.assertEqual(self.upload_small(b'z'*2).status_code,201) + self.assertEqual(self.upload_small(b'z').status_code,413) + + def test_manager_can_change_child_quota_but_not_below_used(self): + alice=self.accept(self.invite(can_invite=True),'alice') + self.as_user(alice) + bob=self.accept(self.invite(quota_bytes=5),'bob') + space=DriveSpace.objects.get(user=bob) + self.as_user(bob) + self.assertEqual(self.upload_small(b'1234').status_code,201) + self.as_user(alice) + endpoint=f'/api/drive/spaces/members/{space.pk}/' + self.assertEqual(self.client.patch(endpoint,{'quota_bytes':3},format='json').status_code,400) + self.assertEqual(self.client.patch(endpoint,{'quota_bytes':8},format='json').status_code,200) + space.refresh_from_db();self.assertEqual(space.quota_bytes,8) + self.assertEqual(self.client.patch(endpoint,{'quota_bytes':None},format='json').status_code,200) + self.assertEqual(self.client.patch(endpoint,{'quota_bytes':-1},format='json').status_code,400) + self.as_user(bob) + self.assertEqual(self.client.patch(endpoint,{'quota_bytes':0},format='json').status_code,403) + + def test_admin_root_quota_caps_everyone(self): + self.as_user(self.admin) + endpoint=f'/api/drive/spaces/{self.root}/quota/' + self.assertEqual(self.client.patch(endpoint,{'quota_bytes':4},format='json').status_code,200) + person=self.accept(self.invite(quota_bytes=None),'someone') + self.as_user(person) + self.assertEqual(self.upload_small(b'1234').status_code,201) + self.assertEqual(self.upload_small(b'5').status_code,413) + self.as_user(self.admin) + self.assertEqual(self.client.patch(endpoint,{'quota_bytes':2},format='json').status_code,400) + +class TransferAndSharedRefreshTests(HierarchicalDriveTests): + """Uploads can be aborted without leaving a quota reservation or a file.""" + + def test_start_chunk_cancel_frees_quota(self): + self.as_user(self.admin) + started=self.client.post('/api/drive/uploads/start/', + {'name':'gros.zip','size':12,'space':self.root},format='json') + self.assertEqual(started.status_code,201,started.data) + job_id=started.data['id'] + chunk=self.client.post(f'/api/drive/uploads/{job_id}/chunk/', + {'offset':'0','part':SimpleUploadedFile('bloc.bin',b'abc')},format='multipart') self.assertEqual(chunk.status_code,200,chunk.data) - self.assertEqual(self.client.get(f'/api/drive/uploads/{pk}/').data['offset'],3) - repeated=self.client.post(f'/api/drive/uploads/{pk}/chunk/',{ - 'offset':'0','part':SimpleUploadedFile('part.bin',b'abc')},format='multipart') - self.assertEqual(repeated.status_code,409) - self.client.post(f'/api/drive/uploads/{pk}/chunk/',{ - 'offset':'3','part':SimpleUploadedFile('part.bin',b'def')},format='multipart') - done=self.client.post(f'/api/drive/uploads/{pk}/finish/') - self.assertEqual(done.status_code,201,done.data) - self.assertEqual(done.data['size'],6) - def test_resume_forbidden_other_user(self): - pk=self.client.post('/api/drive/uploads/start/',{'name':'private.bin','size':5},format='json').data['id'] - self.client.force_authenticate(user=self.b) - self.assertEqual(self.client.get(f'/api/drive/uploads/{pk}/').status_code,404) - def test_anonymous_rejected(self): - self.client.force_authenticate(user=None) - self.assertIn(self.client.get('/api/drive/items/').status_code,(401,403)) + self.assertEqual(chunk.data['offset'],3) + progress=self.client.get(f'/api/drive/uploads/{job_id}/') + self.assertEqual(progress.data['offset'],3) + with self.captureOnCommitCallbacks(execute=True): + canceled=self.client.delete(f'/api/drive/uploads/{job_id}/cancel/') + self.assertEqual(canceled.status_code,204) + self.assertFalse((Path(self.tmp.name)/'tmp'/f'drive-{job_id}.part').exists()) + self.assertEqual(self.client.get(f'/api/drive/uploads/{job_id}/').status_code,404) + self.assertEqual(self.client.get(f'/api/drive/stats/?space={self.root}').data['reserved_bytes'],0) + self.assertFalse(DriveItem.objects.filter(name='gros.zip').exists()) + + def test_shared_folder_listing_reflects_other_users_trash(self): + self.as_user(self.admin) + token=self.invite(can_view_parent=True,can_write_parent=True) + child=self.accept(token,'member') + self.as_user(self.admin) + folder=self.folder(space_id=self.root,name='Commun') + self.assertEqual(folder.status_code,201) + folder_id=folder.data['id'] + self.as_user(child) + self.assertIn(folder_id, [x['id'] for x in self.client.get(f'/api/drive/items/?space={self.root}').data['items']]) + self.as_user(self.admin) + self.assertEqual(self.client.post(f'/api/drive/items/{folder_id}/trash/').status_code,200) + self.as_user(child) + self.assertNotIn(folder_id, [x['id'] for x in self.client.get(f'/api/drive/items/?space={self.root}').data['items']]) + + +class PrivacyAndStorageTests(HierarchicalDriveTests): + def test_admin_cannot_read_private_upload_content_or_metadata(self): + alice = self.accept(self.invite(), 'alice') + self.as_user(alice) + uploaded = self.client.post('/api/drive/uploads/', { + 'files': [SimpleUploadedFile('private.txt', b'secret-data')] + }, format='multipart') + self.assertEqual(uploaded.status_code, 201, uploaded.data) + pk = uploaded.data['items'][0]['id'] + private_id = DriveSpace.objects.get(private_owner=alice).pk + self.as_user(self.admin) + self.assertEqual(self.client.get('/api/drive/items/', {'space': private_id}).status_code, 403) + self.assertEqual(self.client.post(f'/api/drive/items/{pk}/link/', {}, format='json').status_code, 403) + self.assertEqual(self.client.delete(f'/api/drive/items/{pk}/delete/').status_code, 403) + # Direct Django-admin listing of private DriveItem/UploadSession models is disabled. + from django.contrib import admin + from .models import UploadSession + self.assertFalse(admin.site.is_registered(DriveItem)) + self.assertFalse(admin.site.is_registered(UploadSession)) + + def test_quota_details_distinguish_live_trash_and_pending(self): + alice = self.accept(self.invite(quota_bytes=200), 'alice') + self.as_user(alice) + uploaded = self.client.post('/api/drive/uploads/', { + 'files': [SimpleUploadedFile('file.txt', b'1234567')] + }, format='multipart') + pk = uploaded.data['items'][0]['id'] + stats = lambda: self.client.get('/api/drive/stats/').data + self.assertEqual((stats()['used_bytes'], stats()['active_bytes'], stats()['trashed_bytes']), (7, 7, 0)) + self.assertEqual(self.client.post(f'/api/drive/items/{pk}/trash/').status_code, 200) + self.assertEqual((stats()['used_bytes'], stats()['active_bytes'], stats()['trashed_bytes']), (7, 0, 7)) + self.assertEqual(self.client.delete(f'/api/drive/items/{pk}/delete/').status_code, 204) + self.assertEqual(stats()['used_bytes'], 0) + self.assertEqual(stats()['trashed_bytes'], 0) + started = self.client.post('/api/drive/uploads/start/', {'name':'incomplete.bin','size':70}, format='json') + self.assertEqual(started.status_code, 201) + self.assertEqual(stats()['reserved_bytes'], 70) + self.assertEqual(self.client.delete(f'/api/drive/uploads/{started.data["id"]}/cancel/').status_code, 204) + self.assertEqual(stats()['reserved_bytes'], 0) + + def test_file_inside_trashed_folder_is_counted_as_trash(self): + alice = self.accept(self.invite(), 'alice') + self.as_user(alice) + folder = self.folder(name='À supprimer') + uploaded = self.client.post('/api/drive/uploads/', { + 'parent': folder.data['id'], 'files': [SimpleUploadedFile('nested.txt', b'hello')] + }, format='multipart') + self.assertEqual(uploaded.status_code, 201, uploaded.data) + self.client.post(f'/api/drive/items/{folder.data["id"]}/trash/') + result = self.client.get('/api/drive/stats/').data + self.assertEqual(result['used_bytes'], 5) + self.assertEqual(result['active_bytes'], 0) + self.assertEqual(result['trashed_bytes'], 5) + +class InterruptedUploadTests(HierarchicalDriveTests): + def test_pending_uploads_belong_only_to_owner(self): + alice = self.accept(self.invite(), 'alice') + self.as_user(alice) + started = self.client.post('/api/drive/uploads/start/', { + 'name': 'unfinished.txt', 'size': 71_680 + }, format='json') + self.assertEqual(started.status_code, 201, started.data) + pending = self.client.get('/api/drive/uploads/pending/') + self.assertEqual(pending.status_code, 200) + self.assertEqual(pending.data['sessions'][0]['size'], 71_680) + self.as_user(self.admin) + self.assertEqual(self.client.get('/api/drive/uploads/pending/').data['sessions'], []) + self.as_user(alice) + self.assertEqual(self.client.delete(f'/api/drive/uploads/{started.data["id"]}/cancel/').status_code, 204) + self.assertEqual(self.client.get('/api/drive/uploads/pending/').data['sessions'], []) + + def test_sibling_quotas_cannot_overallocate_parent(self): + self.as_user(self.admin) + self.assertEqual(self.client.patch(f'/api/drive/spaces/{self.root}/quota/', + {'quota_bytes':10_000},format='json').status_code,200) + first=self.client.post('/api/drive/spaces/create/', + {'display_name':'Premier','parent_space':self.root,'quota_bytes':7_000},format='json') + self.assertEqual(first.status_code,201,first.data) + second=self.client.post('/api/drive/spaces/create/', + {'display_name':'Deuxième','parent_space':self.root,'quota_bytes':4_000},format='json') + self.assertEqual(second.status_code,400,second.data) + smaller=self.client.post('/api/drive/spaces/create/', + {'display_name':'Deuxième','parent_space':self.root,'quota_bytes':3_000},format='json') + self.assertEqual(smaller.status_code,201,smaller.data) + rejected=self.client.patch(f'/api/drive/spaces/{first.data["id"]}/', + {'quota_bytes':8_000},format='json') + self.assertEqual(rejected.status_code,400,rejected.data) + + def test_unlimited_child_inherits_parent_remaining(self): + self.as_user(self.admin) + self.client.patch(f'/api/drive/spaces/{self.root}/quota/',{'quota_bytes':10_000},format='json') + child=self.client.post('/api/drive/spaces/create/', + {'display_name':'Sans limite propre','parent_space':self.root,'quota_bytes':None},format='json') + self.assertEqual(child.status_code,201,child.data) + self.assertIsNone(child.data['quota_bytes']) + self.assertEqual(child.data['available_bytes'],10_000) + self.assertEqual(self.client.post('/api/drive/spaces/create/', + {'display_name':'Réservation','parent_space':self.root,'quota_bytes':4_000},format='json').status_code,201) + stats=self.client.get('/api/drive/stats/',{'space':child.data['id']}) + self.assertEqual(stats.status_code,200,stats.data) + self.assertEqual(stats.data['available_bytes'],6_000) + self.assertEqual(self.client.post('/api/drive/uploads/',{'space':child.data['id'], + 'files':[SimpleUploadedFile('too-big.bin',b'x'*6_001)]},format='multipart').status_code,413) + + def test_parent_cannot_be_reduced_below_allocated_children(self): + self.as_user(self.admin) + self.client.post('/api/drive/spaces/create/', + {'display_name':'Réservation','parent_space':self.root,'quota_bytes':1000},format='json') + result=self.client.patch(f'/api/drive/spaces/{self.root}/quota/', + {'quota_bytes':999},format='json') + self.assertEqual(result.status_code,400,result.data) diff --git a/backend/drive/urls.py b/backend/drive/urls.py index f5059af..fd4a93b 100644 --- a/backend/drive/urls.py +++ b/backend/drive/urls.py @@ -1,11 +1,25 @@ from django.urls import path from . import views +from . import views_spaces urlpatterns=[ + path('spaces/',views_spaces.spaces), + path('spaces/create/',views_spaces.create_shared_space), + path('spaces//',views_spaces.edit_shared_space), + path('spaces/me/',views_spaces.rename_my_space), + path('spaces/members/',views_spaces.members), + path('spaces/users/search/',views_spaces.search_users), + path('spaces/users/',views_spaces.add_existing_user), + path('spaces/users//',views_spaces.edit_existing_user), + path('spaces/members//',views_spaces.update_member), + path('spaces//quota/',views_spaces.update_space_quota), + path('invitations/',views_spaces.create_invitation), + path('invitations//revoke/',views_spaces.revoke_invitation), path('items/',views.listing), path('stats/',views.stats), path('folders/',views.create_folder), path('uploads/',views.upload), path('uploads/start/',views.upload_start), + path('uploads/pending/',views.upload_pending), path('uploads//',views.upload_status), path('uploads//chunk/',views.upload_chunk), path('uploads//finish/',views.upload_finish), diff --git a/backend/drive/views.py b/backend/drive/views.py index 12df72d..81d1d2e 100644 --- a/backend/drive/views.py +++ b/backend/drive/views.py @@ -7,5 +7,5 @@ Implementations are grouped by responsibility without changing route names. from .views_items import (me, listing, stats, create_folder, update_item, trash_item, restore_item, delete_item) from .views_transfers import (upload, upload_start, upload_status, upload_chunk, - upload_finish, upload_cancel) + upload_finish, upload_cancel, upload_pending) from .views_content import content_url, content diff --git a/backend/drive/views_content.py b/backend/drive/views_content.py index 1b6a9fe..a66e6fe 100644 --- a/backend/drive/views_content.py +++ b/backend/drive/views_content.py @@ -1,12 +1,30 @@ -"""Expiring download links and secure file streaming endpoints.""" -from pathlib import Path -from django.conf import settings +"""Short-lived, permission-bound preview/download URLs and HTTP byte ranges.""" +import mimetypes +import re +from django.contrib.auth import get_user_model from django.core.signing import TimestampSigner, BadSignature, SignatureExpired -from django.http import FileResponse, Http404 +from django.http import FileResponse, Http404, StreamingHttpResponse, HttpResponse +from django.views.decorators.clickjacking import xframe_options_sameorigin from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response -from .services import error, get_item, readable, chain +from .services import error, get_item, readable +from .spaces import storage_path + +IMAGE_TYPES = {'image/jpeg','image/png','image/gif','image/webp','image/avif','image/bmp'} +VIDEO_TYPES = {'video/mp4','video/webm','video/ogg','video/quicktime','video/x-m4v'} +AUDIO_TYPES = {'audio/mpeg','audio/ogg','audio/wav','audio/mp4','audio/webm','audio/flac','audio/aac','audio/x-wav'} +TEXT_TYPES = {'text/plain','text/csv','text/markdown','text/x-python','text/javascript','text/css', + 'application/json','application/xml','text/xml'} + + +def safe_preview_type(item): + mime = (mimetypes.guess_type(item.name)[0] or item.mime_type or '').lower() + if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}: + return mime + if mime in TEXT_TYPES or mime in {'text/html','image/svg+xml'} or mime.startswith('text/'): + return 'text/plain; charset=utf-8' # Never render user HTML/scripts inline. + return None @api_view(['POST']) @@ -14,37 +32,72 @@ def content_url(request,item_id): item=get_item(item_id) if item.is_folder or not readable(item,request.user):return error('Fichier inaccessible.',403) inline=bool(request.data.get('inline',False)) - safe_types={'image/jpeg','image/png','image/gif','image/webp','application/pdf', - 'audio/mpeg','audio/ogg','audio/wav','audio/mp4'} - if inline and item.mime_type not in safe_types: - return error('Aperçu non disponible pour ce type de fichier.') - token=TimestampSigner(salt='drive-content').sign(f'{item.pk}:{1 if inline else 0}') - return Response({'url':f'/api/drive/items/{item.pk}/content/?token={token}', - 'expires_in':300}) + if inline and not safe_preview_type(item):return error('Aperçu non disponible pour ce type de fichier.',415) + # Encode the authorized identity so access is checked again on every request. + token=TimestampSigner(salt='drive-content').sign(f'{item.pk}:{int(inline)}:{request.user.pk}') + return Response({'url':f'/api/drive/items/{item.pk}/content/?token={token}','expires_in':7200}) -@api_view(['GET']) +def read_bytes(path, start, end): + with path.open('rb') as source: + source.seek(start) + remaining=end-start+1 + while remaining>0: + chunk=source.read(min(1024*1024,remaining)) + if not chunk:break + remaining-=len(chunk) + yield chunk + + +@api_view(['GET','HEAD']) @permission_classes([AllowAny]) +@xframe_options_sameorigin def content(request,item_id): - # A short-lived signed URL permits native streaming/inline display without storing a JWT in the URL. token=request.query_params.get('token','') try: - value=TimestampSigner(salt='drive-content').unsign(token,max_age=300) - expected,inline=value.rsplit(':',1) + value=TimestampSigner(salt='drive-content').unsign(token,max_age=7200) + expected,inline,user_pk=value.rsplit(':',2) if expected!=str(item_id) or inline not in ('0','1'):raise BadSignature() - except (BadSignature,SignatureExpired,ValueError): + user=get_user_model().objects.get(pk=int(user_pk),is_active=True) + except (BadSignature,SignatureExpired,ValueError,TypeError,get_user_model().DoesNotExist): raise Http404 item=get_item(item_id) - if item.is_folder or item.trashed_at:raise Http404 - if any(n.trashed_at for n in chain(item)):raise Http404 - # Authorization performed when URL was minted, 5-minute token is a bearer credential. - key=item.storage_key - if not key or len(key)!=36: - raise Http404 - path=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key + if item.is_folder or not readable(item,user):raise Http404 + media=safe_preview_type(item) + if inline=='1' and not media:raise Http404 + path=storage_path(item) if not path.is_file():raise Http404 - response=FileResponse(open(path,'rb'),as_attachment=inline=='0',filename=item.name, - content_type=item.mime_type if inline=='1' else 'application/octet-stream') + size=path.stat().st_size + content_type=media if inline=='1' else 'application/octet-stream' + name=item.name + byte_range=request.headers.get('Range') + # Native video/audio controls commonly request Range: bytes=0-. + if byte_range: + match=re.fullmatch(r'bytes=(\d*)-(\d*)',byte_range.strip()) + if not match or (not match[1] and not match[2]): + response=HttpResponse(status=416) + response['Content-Range']=f'bytes */{size}' + return response + if match[1]: + start=int(match[1]);end=int(match[2]) if match[2] else size-1 + else: + suffix=int(match[2]);start=max(0,size-suffix);end=size-1 + end=min(end,size-1) + if start>=size or end=500:break trail=[] if parent: for n in reversed(chain(parent)): - if readable(n,user): - trail.append({'id':str(n.id),'name':n.name}) - return Response({'items':items,'breadcrumb':trail,'limited':len(items)>=500}) + trail.append({'id':str(n.id),'name':n.name}) + return Response({'items':items,'breadcrumb':trail,'limited':len(items)>=500, + 'can_write':can_write_space(user,space) if space else False,'space_id':space.pk if space else None}) @api_view(['GET']) def stats(request): - user=request.user - result=DriveItem.objects.filter(owner=user,trashed_at__isnull=True,is_folder=False).aggregate( - count=Count('id'),total=Sum('size')) + try:space=resolve_space(request.user,request.query_params.get('space')) + except PermissionError as ex:return error(str(ex),403) + result=DriveItem.objects.filter(space=space,trashed_at__isnull=True,is_folder=False).aggregate(count=Count('id'),total=Sum('size')) return Response({'files':result['count'] or 0,'bytes':result['total'] or 0, - 'folders':DriveItem.objects.filter(owner=user,is_folder=True,trashed_at__isnull=True).count(), - 'shared':DriveItem.objects.filter(owner=user,parent__isnull=True,is_shared=True,trashed_at__isnull=True).count()}) + **quota_state(space), + 'folders':DriveItem.objects.filter(space=space,is_folder=True,trashed_at__isnull=True).count(), + 'shared':0}) @api_view(['POST']) def create_folder(request): try: + space=resolve_space(request.user,request.data.get('space'),write=True) name=valid_name(request.data.get('name')) - parent=target_parent(request.data.get('parent'),request.user,request.user.pk) - except ValueError as ex: return error(str(ex)) - except PermissionError as ex: return error(str(ex),403) - folder=DriveItem(owner=request.user,parent=parent,name=name,is_folder=True) - folder.full_clean() - folder.save() - return Response(item_info(folder),status=201) - - + parent=target_parent(request.data.get('parent'),request.user,space) + except ValueError as ex:return error(str(ex)) + except PermissionError as ex:return error(str(ex),403) + folder=DriveItem(owner=request.user,space=space,parent=parent,name=name,is_folder=True) + folder.full_clean();folder.save() + return Response(item_info(folder,request.user),status=201) @api_view(['PATCH']) @@ -92,24 +101,18 @@ def update_item(request,item_id): if 'name' in data: try:item.name=valid_name(data['name']) except ValueError as ex:return error(str(ex)) - if 'is_starred' in data:item.is_starred=bool(data['is_starred']) - if 'is_shared' in data: - if item.parent_id:return error('Seuls les éléments à la racine peuvent être partagés.') - item.is_shared=bool(data['is_shared']) + if 'is_shared' in data:return error('Le partage global est désactivé : utilise les espaces et invitations.') if 'parent' in data: - try: - new_parent=target_parent(data['parent'],request.user,item.owner_id) + try:new_parent=target_parent(data['parent'],request.user,item.space) except ValueError as ex:return error(str(ex)) except PermissionError as ex:return error(str(ex),403) - if new_parent: - if new_parent.id==item.id or item.id in [n.id for n in chain(new_parent)]: - return error('Déplacement impossible dans un sous-dossier de cet élément.') + if new_parent and (new_parent.id==item.id or item.id in [n.id for n in chain(new_parent)]): + return error('Déplacement impossible dans un sous-dossier de cet élément.') item.parent=new_parent - if new_parent:item.is_shared=False try:item.full_clean() except ValidationError as ex:return error(str(ex)) item.save() - return Response(item_info(item)) + return Response(item_info(item,request.user)) @api_view(['POST']) @@ -126,11 +129,10 @@ def restore_item(request,item_id): item=get_item(item_id) if not writable(item,request.user):return error('Accès refusé.',403) if not item.trashed_at:return error('Cet élément n’est pas dans la corbeille.') - # An item whose parent was separately trashed is restored to root. if item.parent and any(n.trashed_at for n in chain(item.parent)): item.parent=None item.trashed_at=None;item.save() - return Response(item_info(item)) + return Response(item_info(item,request.user)) @api_view(['DELETE']) @@ -142,8 +144,9 @@ def delete_item(request,item_id): with transaction.atomic(): item.delete() def remove_files(): + root=Path(settings.DRIVE_STORAGE_ROOT).resolve() for key in files: - path=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key - path.unlink(missing_ok=True) + path=(root/key).resolve() + if path.is_relative_to(root):path.unlink(missing_ok=True) transaction.on_commit(remove_files) return Response(status=204) diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py new file mode 100644 index 0000000..e879318 --- /dev/null +++ b/backend/drive/views_spaces.py @@ -0,0 +1,358 @@ +"""Invitation-only registration and hierarchical space management.""" +import secrets +from datetime import timedelta +from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.db import transaction, IntegrityError +from django.utils import timezone +from rest_framework.decorators import api_view, permission_classes, throttle_classes +from rest_framework.permissions import AllowAny +from rest_framework.throttling import AnonRateThrottle +from rest_framework.response import Response +from .models import DriveSpace, SpaceInvitation, SpaceGrant +from .services import error, valid_name +from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_manage_space +from .quotas import parse_quota, locked_quota_tree, assert_new_child_quota, assert_updated_quota + + +def permission_flags(data): + return {k: data.get(k) is True for k in ('can_view_parent','can_write_parent','can_invite')} + + +def manager(user, parent): + # Administrators can manage any descendant; delegates their own direct children only. + return can_manage_space(user, parent) + + +def invitation_info(inv): + return {'id':str(inv.pk), 'created_at':inv.created_at.isoformat(), + 'expires_at':inv.expires_at.isoformat(), 'used':bool(inv.used_at), + 'revoked':bool(inv.revoked_at), + 'can_view_parent':inv.can_view_parent,'can_write_parent':inv.can_write_parent, + 'can_invite':inv.can_invite,'quota_bytes':inv.quota_bytes} + + +@api_view(['GET']) +def spaces(request): + own = ensure_space(request.user) + qs=DriveSpace.objects.select_related('user','private_owner','managed_by','parent').order_by('relative_path') + private=ensure_private_space(request.user) + if is_admin(request.user): + permitted=[private, *qs.filter(private_owner__isnull=True)] + else: + permitted=[private,own] + if own.parent_id and own.can_view_parent and not own.parent.private_owner_id: + permitted.append(own.parent) + permitted.extend(space for space in qs.filter(access_grants__user=request.user) if not space.private_owner_id) + permitted.extend(qs.filter(managed_by=request.user,private_owner__isnull=True)) + permitted=list({space.pk:space for space in permitted}.values()) + return Response({'mine':private.pk, 'assigned':own.pk, 'private':private.pk, + 'spaces':[space_info(space,request.user) for space in permitted]}) + + +@api_view(['PATCH']) +def rename_my_space(request): + own=my_space(request.user) + try: + title=valid_name(request.data.get('display_name')) + except ValueError as ex:return error(str(ex)) + if len(title)>120:return error('Nom limité à 120 caractères.') + own.display_name=title + own.save(update_fields=['display_name']) + return Response(space_info(own,request.user)) + + +@api_view(['GET']) +def members(request): + own=my_space(request.user) + parent=own + if request.query_params.get('parent_space'): + try:parent=DriveSpace.objects.get(pk=int(request.query_params['parent_space'])) + except (ValueError,TypeError,DriveSpace.DoesNotExist):return error('Espace introuvable.',404) + if not manager(request.user,parent):return error('Gestion des invitations non autorisée.',403) + children=DriveSpace.objects.filter(parent=parent,private_owner__isnull=True).select_related('user','parent').order_by('id') + invitations=SpaceInvitation.objects.filter(parent_space=parent).order_by('-created_at')[:100] + return Response({'parent':space_info(parent,request.user), + 'children':[space_info(c,request.user) for c in children if not c.private_owner_id], + 'grants':[grant_info(g) for g in SpaceGrant.objects.filter(space=parent).select_related('user').order_by('user__username')], + 'invitations':[invitation_info(i) for i in invitations]}) + + +def _update_quota(space, requested): + """Must run under transaction.atomic and after locking all ancestors.""" + with locked_quota_tree(space): + quota = parse_quota(requested) + assert_updated_quota(space, quota) + space.quota_bytes = quota + space.save(update_fields=['quota_bytes']) + + +@api_view(['PATCH']) +def update_member(request,space_id): + with transaction.atomic(): + try:child=DriveSpace.objects.select_related('parent','user').get(pk=space_id) + except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) + if child.private_owner_id or not child.parent_id:return error('Impossible de modifier cet espace ici.',403) + if not manager(request.user,child.parent):return error('Modification interdite.',403) + changes=[] + for key in ('can_view_parent','can_write_parent','can_invite'): + if key in request.data: + if not isinstance(request.data[key],bool):return error('Les permissions doivent être des booléens.') + setattr(child,key,request.data[key]);changes.append(key) + if child.can_write_parent and not child.can_view_parent: + return error('L’écriture nécessite la lecture de l’espace parent.') + if 'quota_bytes' in request.data: + try:_update_quota(child, request.data['quota_bytes']) + except ValueError as ex:return error(str(ex)) + if changes:child.save(update_fields=changes) + if 'is_active' in request.data and child.user_id is None: + return error('Ce dossier partagé ne possède pas de compte utilisateur.') + if 'is_active' in request.data: + if not isinstance(request.data['is_active'],bool):return error('Statut invalide.') + child.user.is_active=request.data['is_active'] + child.user.save(update_fields=['is_active']) + return Response(space_info(child,request.user)) + + +@api_view(['PATCH']) +def update_space_quota(request, space_id): + """Administrators may cap any space; delegated inviters manage their direct children.""" + with transaction.atomic(): + try:space=DriveSpace.objects.select_related('parent','user').get(pk=space_id) + except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) + if space.private_owner_id and space.private_owner_id != request.user.pk: + return error('Cet espace personnel est privé.',403) + if not (is_admin(request.user) or (space.parent_id and not space.private_owner_id and manager(request.user,space.parent))): + return error('Modification du quota interdite.',403) + if 'quota_bytes' not in request.data:return error('quota_bytes est obligatoire.') + try:_update_quota(space,request.data['quota_bytes']) + except ValueError as ex:return error(str(ex)) + return Response(space_info(space,request.user)) + + +@api_view(['POST']) +def create_invitation(request): + own=my_space(request.user) + parent=own + if request.data.get('parent_space'): + try:parent=DriveSpace.objects.get(pk=int(request.data['parent_space'])) + except (ValueError,TypeError,DriveSpace.DoesNotExist):return error('Espace introuvable.',404) + if not manager(request.user,parent):return error('Vous ne pouvez pas inviter depuis cet espace.',403) + fields=permission_flags(request.data) + if 'can_view_parent' not in request.data:fields['can_view_parent']=True + if fields['can_write_parent'] and not fields['can_view_parent']: + return error('L’écriture nécessite la lecture de l’espace parent.') + try:days=int(request.data.get('expires_days',7)) + except (ValueError,TypeError):return error('Durée invalide.') + if not 1<=days<=30:return error('La durée doit être comprise entre 1 et 30 jours.') + try:quota=parse_quota(request.data.get('quota_bytes')) + except ValueError as ex:return error(str(ex)) + with transaction.atomic(): + with locked_quota_tree(parent): + try:assert_new_child_quota(parent,quota) + except ValueError as exc:return error(str(exc)) + token=secrets.token_urlsafe(32) + inv=SpaceInvitation.objects.create(parent_space=parent,created_by=request.user, + token_hash=hash_token(token),expires_at=timezone.now()+timedelta(days=days),quota_bytes=quota,**fields) + # Returned exactly once; DB stores only SHA-256. + return Response({**invitation_info(inv),'token':token},status=201) + + +@api_view(['POST']) +def revoke_invitation(request,invitation_id): + try:inv=SpaceInvitation.objects.select_related('parent_space').get(pk=invitation_id) + except SpaceInvitation.DoesNotExist:return error('Invitation introuvable.',404) + if not manager(request.user,inv.parent_space):return error('Accès refusé.',403) + if inv.used_at:return error('Invitation déjà utilisée.') + inv.revoked_at=timezone.now();inv.save(update_fields=['revoked_at']) + return Response({'ok':True}) + + +def get_valid_invitation(token,lock=False): + qs=SpaceInvitation.objects.select_related('parent_space') + if lock:qs=qs.select_for_update() + try:inv=qs.get(token_hash=hash_token(token)) + except SpaceInvitation.DoesNotExist:return None + if inv.used_at or inv.revoked_at or inv.expires_at<=timezone.now():return None + # Revoking the inviter's delegation also invalidates their pending invitations. + if not manager(inv.created_by, inv.parent_space):return None + return inv + + +@api_view(['GET']) +@permission_classes([AllowAny]) +@throttle_classes([AnonRateThrottle]) +def invitation_preview(request,token): + inv=get_valid_invitation(token) + if inv is None:return error('Invitation invalide ou expirée.',404) + return Response({'parent_name':inv.parent_space.display_name, + 'can_view_parent':inv.can_view_parent, + 'can_write_parent':inv.can_write_parent, + 'can_invite':inv.can_invite, + 'quota_bytes':inv.quota_bytes, + 'expires_at':inv.expires_at.isoformat()}) + + +@api_view(['POST']) +@permission_classes([AllowAny]) +@throttle_classes([AnonRateThrottle]) +def accept_invitation(request,token): + if get_valid_invitation(token) is None:return error('Invitation invalide ou expirée.',404) + username=str(request.data.get('username','')).strip() + password=request.data.get('password','') + display_name=str(request.data.get('display_name','')).strip() + email=str(request.data.get('email','')).strip() + first_name=str(request.data.get('first_name','')).strip() + last_name=str(request.data.get('last_name','')).strip() + if not first_name or not last_name or len(first_name)>150 or len(last_name)>150: + return error('Prénom et nom obligatoires (150 caractères maximum).') + if not username or len(username)>150 or not display_name or len(display_name)>120: + return error('Nom d’utilisateur et nom d’espace obligatoires (150/120 caractères maximum).') + User=get_user_model() + try: + User._meta.get_field(User.USERNAME_FIELD).run_validators(username) + validate_password(password) + except ValidationError as ex:return error(' '.join(ex.messages)) + if User.objects.filter(username__iexact=username).exists():return error('Ce nom d’utilisateur est déjà pris.') + if email and len(email)>254:return error('Adresse e-mail trop longue.') + try: + with transaction.atomic(): + inv=get_valid_invitation(token,lock=True) + if inv is None:return error('Invitation invalide ou déjà utilisée.',404) + with locked_quota_tree(inv.parent_space): + try:assert_new_child_quota(inv.parent_space,inv.quota_bytes) + except ValueError as exc:return error(str(exc)) + user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name) + space=create_space(user,inv.parent_space,display_name, + can_view_parent=inv.can_view_parent,can_write_parent=inv.can_write_parent, + can_invite=inv.can_invite,quota_bytes=inv.quota_bytes) + ensure_private_space(user) + inv.used_by=user;inv.used_at=timezone.now() + inv.save(update_fields=['used_by','used_at']) + except IntegrityError:return error('Le nom d’utilisateur est déjà utilisé.') + return Response({'ok':True,'username':user.username,'space_id':space.pk},status=201) + + +# ----------------------------------------------------------------------------- +# Direct access for existing accounts; never relocate their private files. +# ----------------------------------------------------------------------------- +def grant_info(grant): + user = grant.user + return {'id': grant.pk, 'user_id': user.pk, 'username': user.username, + 'first_name': user.first_name, 'last_name': user.last_name, + 'can_write': grant.can_write, 'can_invite': grant.can_invite} + + +def managed_parent(request): + if request.query_params.get('parent_space'): + try: + return DriveSpace.objects.get(pk=int(request.query_params['parent_space'])) + except (ValueError, TypeError, DriveSpace.DoesNotExist): + return None + return ensure_space(request.user) + + +@api_view(['GET']) +def search_users(request): + parent = managed_parent(request) + if parent is None or not manager(request.user, parent): + return error('Recherche non autorisée.', 403) + query = str(request.query_params.get('q', '')).strip() + if len(query) < 2: + return Response({'users': []}) + User = get_user_model() + qs = User.objects.filter(username__icontains=query, is_active=True, is_staff=False, is_superuser=False) + qs = qs.exclude(pk=request.user.pk).order_by('username')[:10] + return Response({'users': [{'id':u.pk, 'username':u.username, + 'first_name':u.first_name,'last_name':u.last_name} + for u in qs]}) + + +@api_view(['POST']) +def add_existing_user(request): + parent = managed_parent(request) + if parent is None or not manager(request.user, parent): + return error('Ajout non autorisé.',403) + try: + user_id = int(request.data.get('user_id')) + except (TypeError, ValueError): + return error('Utilisateur invalide.') + User = get_user_model() + try: + account = User.objects.get(pk=user_id, is_active=True, is_staff=False, is_superuser=False) + except User.DoesNotExist: + return error('Utilisateur introuvable.',404) + if account.pk == parent.user_id or account.pk == request.user.pk: + return error('Impossible de vous ajouter vous-même à cet espace.') + own = ensure_space(account) + if own.parent_id == parent.pk: + return error('Cet utilisateur est déjà un sous-utilisateur direct de cet espace.') + can_write=request.data.get('can_write', False) + can_invite=request.data.get('can_invite', False) + if not isinstance(can_write, bool) or not isinstance(can_invite, bool): + return error('Permissions invalides.') + with transaction.atomic(): + grant,created = SpaceGrant.objects.select_for_update().get_or_create( + user=account, space=parent, defaults={'can_write': can_write,'can_invite':can_invite}) + if not created: + return error('Cet utilisateur a déjà accès à cet espace.') + return Response(grant_info(grant),status=201) + + +@api_view(['PATCH','DELETE']) +def edit_existing_user(request, grant_id): + try: + grant = SpaceGrant.objects.select_related('space','user').get(pk=grant_id) + except SpaceGrant.DoesNotExist: + return error('Accès introuvable.',404) + if not manager(request.user,grant.space): + return error('Gestion non autorisée.',403) + if request.method == 'DELETE': + grant.delete() + return Response(status=204) + for key in ('can_write','can_invite'): + if key in request.data: + if not isinstance(request.data[key],bool): + return error('Permission invalide.') + setattr(grant,key,request.data[key]) + grant.save(update_fields=['can_write','can_invite']) + return Response(grant_info(grant)) + + +@api_view(['POST']) +def create_shared_space(request): + """Create a standalone numbered shared space; no additional user is needed.""" + try: + name=valid_name(request.data.get('display_name')) + if len(name)>120:return error('Nom limité à 120 caractères.') + quota=parse_quota(request.data.get('quota_bytes')) + parent_id=request.data.get('parent_space') + parent=DriveSpace.objects.get(pk=int(parent_id)) if parent_id else ensure_space(request.user) + except (ValueError, TypeError) as exc:return error(str(exc)) + except DriveSpace.DoesNotExist:return error('Espace parent introuvable.',404) + if not manager(request.user,parent):return error('Création interdite dans cet espace.',403) + with transaction.atomic(): + with locked_quota_tree(parent): + try:assert_new_child_quota(parent,quota) + except ValueError as exc:return error(str(exc)) + space=_make_numbered_space(parent,managed_by=request.user,display_name=name,quota_bytes=quota) + return Response(space_info(space,request.user),status=201) + + +@api_view(['PATCH']) +def edit_shared_space(request,space_id): + try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id) + except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) + if space.private_owner_id or not manager(request.user,space):return error('Modification interdite.',403) + try: + with transaction.atomic(): + if 'display_name' in request.data: + title=valid_name(request.data['display_name']) + if len(title)>120:return error('Nom limité à 120 caractères.') + space.display_name=title + space.save(update_fields=['display_name']) + if 'quota_bytes' in request.data: + _update_quota(space,request.data['quota_bytes']) + except ValueError as exc:return error(str(exc)) + return Response(space_info(space,request.user)) diff --git a/backend/drive/views_transfers.py b/backend/drive/views_transfers.py index 4d34c27..5e4f00d 100644 --- a/backend/drive/views_transfers.py +++ b/backend/drive/views_transfers.py @@ -11,13 +11,16 @@ from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.response import Response from .models import DriveItem, UploadSession from .services import error, item_info, target_parent, valid_name, readable, writable +from .spaces import resolve_space, can_write_space, new_storage_location +from .quotas import QuotaExceeded, locked_quota_tree, assert_space_capacity @api_view(['POST']) @parser_classes([MultiPartParser,FormParser]) def upload(request): try: - parent=target_parent(request.data.get('parent'),request.user,request.user.pk) + space=resolve_space(request.user,request.data.get('space'),write=True) + parent=target_parent(request.data.get('parent'),request.user,space) except ValueError as ex: return error(str(ex)) except PermissionError as ex: return error(str(ex),403) incoming=request.FILES.getlist('files') @@ -29,31 +32,35 @@ def upload(request): saved=[] try: with transaction.atomic(): - for f in incoming: - name=valid_name(f.name) - key=str(uuid.uuid4()) - dst=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key - dst.parent.mkdir(parents=True,exist_ok=True) - with open(dst,'xb') as out: - for block in f.chunks(chunk_size=2*1024*1024): - out.write(block) - saved.append(dst) - mime=mimetypes.guess_type(name)[0] or 'application/octet-stream' - node=DriveItem.objects.create(owner=request.user,parent=parent,name=name, - storage_key=key,size=f.size,mime_type=mime) - # Keep a response prepared even if the transaction is rolled back. - saved[-1] = (dst,node) - except (ValueError,OSError) as ex: + with locked_quota_tree(space): + # Include the sum of ALL files in the same request. The lock prevents + # another simultaneous request from consuming these bytes meanwhile. + assert_space_capacity(space, sum(f.size for f in incoming)) + for f in incoming: + name=valid_name(f.name) + key=str(uuid.uuid4()) + location=new_storage_location(space,key) + dst=Path(settings.DRIVE_STORAGE_ROOT)/location + dst.parent.mkdir(parents=True,exist_ok=True) + with open(dst,'xb') as out: + for block in f.chunks(chunk_size=2*1024*1024): + out.write(block) + saved.append(dst) + mime=mimetypes.guess_type(name)[0] or 'application/octet-stream' + node=DriveItem.objects.create(owner=request.user,space=space,parent=parent,name=name, + storage_key=key,storage_location=location,size=f.size,mime_type=mime) + saved[-1] = (dst,node) + except (QuotaExceeded,ValueError,OSError) as ex: for entry in saved: path=entry[0] if isinstance(entry,tuple) else entry path.unlink(missing_ok=True) - return error(f'Import impossible : {ex}') + return error(str(ex),413) if isinstance(ex,QuotaExceeded) else error(f'Import impossible : {ex}') except Exception: for entry in saved: path=entry[0] if isinstance(entry,tuple) else entry path.unlink(missing_ok=True) raise - return Response({'items':[item_info(n) for _,n in saved]},status=201) + return Response({'items':[item_info(n,request.user) for _,n in saved]},status=201) # Large uploads are chunked in 8 MiB pieces; a refresh does not restart the upload. @@ -62,13 +69,18 @@ def upload_start(request): try: name=valid_name(request.data.get('name')) size=int(request.data.get('size',-1)) - parent=target_parent(request.data.get('parent'),request.user,request.user.pk) + space=resolve_space(request.user,request.data.get('space'),write=True) + parent=target_parent(request.data.get('parent'),request.user,space) except (TypeError,ValueError) as ex:return error(str(ex)) except PermissionError as ex:return error(str(ex),403) if size<0 or size>settings.DRIVE_MAX_FILE_SIZE:return error('Taille de fichier non autorisée.',413) - if UploadSession.objects.filter(owner=request.user).count()>=20: - return error('Trop d’imports en cours (20 maximum).',429) - job=UploadSession.objects.create(owner=request.user,parent=parent,name=name,total_size=size) + with transaction.atomic(): + with locked_quota_tree(space): + if UploadSession.objects.filter(owner=request.user).count()>=20: + return error('Trop d’imports en cours (20 maximum).',429) + try:assert_space_capacity(space,size) + except QuotaExceeded as ex:return error(str(ex),413) + job=UploadSession.objects.create(owner=request.user,space=space,parent=parent,name=name,total_size=size) return Response({'id':str(job.pk),'offset':job.received},status=201) @@ -116,28 +128,48 @@ def upload_finish(request,upload_id): src.touch(exist_ok=True) if not src.exists() or src.stat().st_size!=job.total_size: return error('Le fichier temporaire est incomplet.') - if job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user)): + if not can_write_space(request.user,job.space) or (job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user))): return error('Dossier de destination indisponible.',403) key=str(uuid.uuid4()) - dst=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key + location=new_storage_location(job.space,key) + dst=Path(settings.DRIVE_STORAGE_ROOT)/location dst.parent.mkdir(parents=True,exist_ok=True) os.replace(src,dst) mime=mimetypes.guess_type(job.name)[0] or 'application/octet-stream' try: - node=DriveItem.objects.create(owner=request.user,parent=job.parent,name=job.name, - size=job.total_size,mime_type=mime,storage_key=key) + node=DriveItem.objects.create(owner=request.user,space=job.space,parent=job.parent,name=job.name, + size=job.total_size,mime_type=mime,storage_key=key,storage_location=location) job.delete() except Exception: os.replace(dst,src) raise - return Response(item_info(node),status=201) + return Response(item_info(node,request.user),status=201) @api_view(['DELETE']) def upload_cancel(request,upload_id): - try:job=UploadSession.objects.get(pk=upload_id,owner=request.user) - except UploadSession.DoesNotExist:raise Http404 - path=Path(settings.FILE_UPLOAD_TEMP_DIR)/f'drive-{job.pk}.part' - job.delete() - path.unlink(missing_ok=True) + # Serialize cancellation with chunk writes and completion. Without the lock + # a concurrently arriving chunk could recreate the temp file after delete. + with transaction.atomic(): + try: + job=UploadSession.objects.select_for_update().get(pk=upload_id,owner=request.user) + except UploadSession.DoesNotExist: + raise Http404 + path=Path(settings.FILE_UPLOAD_TEMP_DIR)/f'drive-{job.pk}.part' + job.delete() + transaction.on_commit(lambda: path.unlink(missing_ok=True)) return Response(status=204) + + +@api_view(['GET']) +def upload_pending(request): + """Allow a user to find/cancel their own dangling uploads after a reload. + + Session metadata belonging to any other account is never disclosed. + """ + sessions = UploadSession.objects.filter(owner=request.user).order_by('-updated_at')[:20] + return Response({'sessions': [ + {'id': str(job.pk), 'name': job.name, 'size': job.total_size, + 'received': job.received, 'space_id': job.space_id} + for job in sessions + ]}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 85f18ee..57d04ea 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,11 +3,15 @@ import { LoaderCircle } from 'lucide-react' import { api, logoutTokens, savedToken } from '@/api/client' import DrivePage from '@/pages/DrivePage' import LoginPage from '@/pages/LoginPage' +import InvitationPage from '@/pages/InvitationPage' /** Application entry point: only restores the session and selects the screen. */ export default function App() { + const invitationToken = window.location.pathname.match(/^\/invite\/([A-Za-z0-9_-]+)\/?$/)?.[1] const [user,setUser] = useState(null) const [checking,setChecking] = useState(true) + const [connectionError,setConnectionError] = useState(false) + const [attempt,setAttempt] = useState(0) useEffect(() => { let active = true @@ -15,16 +19,25 @@ export default function App() { if(!savedToken()) {setChecking(false); return} try { const data = (await api.get('/auth/me/')).data - if(active) setUser(data) - } catch { logoutTokens() } + if(active) { setUser(data); setConnectionError(false) } + } catch (error) { + // API outages are not proof that the session has expired. + if (!error.response || error.response.status >= 500) { + if(active) setConnectionError(true) + } else if (error.response.status === 401) { + logoutTokens() + } else if(active) setConnectionError(true) + } finally { if(active) setChecking(false) } } probe() const expired = () => {setUser(null);setChecking(false)} window.addEventListener('drive:logout', expired) return () => {active = false;window.removeEventListener('drive:logout', expired)} - }, []) + }, [attempt]) + if(invitationToken) return + if(connectionError) return

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

if(checking) return
return user ? {logoutTokens();setUser(null)}} /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 8783cfe..63e8add 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,38 +1,105 @@ import axios from 'axios' -const ACCESS='drive_access', REFRESH='drive_refresh' -export const api = axios.create({ - baseURL: import.meta.env?.PROD ? '/api' : `http://${window.location.hostname}:1029/api`, - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, -}); -const raw=axios.create({baseURL:'/api'}) -export const savedToken=()=>sessionStorage.getItem(ACCESS) -export const storeTokens=(data)=>{sessionStorage.setItem(ACCESS,data.access);if(data.refresh)sessionStorage.setItem(REFRESH,data.refresh)} -export const logoutTokens=()=>{sessionStorage.removeItem(ACCESS);sessionStorage.removeItem(REFRESH)} -api.interceptors.request.use(config=>{const token=savedToken();if(token)config.headers.Authorization=`Bearer ${token}`;return config}) -let refreshPromise=null -api.interceptors.response.use(r=>r,async error=>{ - const original=error.config - if(error.response?.status!==401 || original?._retry || original?.url?.startsWith('/auth/'))return Promise.reject(error) - const refresh=sessionStorage.getItem(REFRESH) - if(!refresh){logoutTokens();window.dispatchEvent(new Event('drive:logout'));return Promise.reject(error)} - original._retry=true - try{ - if(!refreshPromise)refreshPromise=raw.post('/auth/refresh/',{refresh}).then(r=>r.data.access).finally(()=>{refreshPromise=null}) - const access=await refreshPromise - sessionStorage.setItem(ACCESS,access) - original.headers.Authorization=`Bearer ${access}` - return api(original) - }catch(e){logoutTokens();window.dispatchEvent(new Event('drive:logout'));return Promise.reject(e)} +const ACCESS = 'drive_access', REFRESH = 'drive_refresh' +// Single origin for JSON, previews, videos, and token renewal. +export const api = axios.create({baseURL:'/api', headers:{Accept:'application/json'}}) +const raw = axios.create({baseURL:'/api', headers:{Accept:'application/json'}}) +export const savedToken = () => sessionStorage.getItem(ACCESS) +export const storeTokens = data => { + if (data.access) sessionStorage.setItem(ACCESS,data.access) + if (data.refresh) sessionStorage.setItem(REFRESH,data.refresh) +} +export const logoutTokens = () => { + sessionStorage.removeItem(ACCESS) + sessionStorage.removeItem(REFRESH) +} +api.interceptors.request.use(config => { + const token = savedToken() + if (token && !config.url?.startsWith('/invitations/')) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) +let refreshPromise = null +export async function refreshAccess() { + const token = sessionStorage.getItem(REFRESH) + if (!token) throw new Error('Aucun jeton de renouvellement') + if (!refreshPromise) { + refreshPromise = raw.post('/auth/refresh/',{refresh:token}).then(response => { + storeTokens(response.data) + return response.data.access + }).finally(() => { refreshPromise = null }) + } + return refreshPromise +} +const authError = error => error?.response?.status === 401 +api.interceptors.response.use(response => response, async error => { + const original = error.config + if (error.response?.status !== 401 || !original || original._retry || + original.url?.startsWith('/auth/') || original.url?.startsWith('/invitations/')) { + return Promise.reject(error) + } + original._retry = true + try { + const access = await refreshAccess() + original.headers.Authorization = `Bearer ${access}` + return await api(original) + } catch (refreshError) { + // Temporary network outages and server 502/503 must NEVER log out a user. + // Only a definitively rejected/expired refresh token revokes the session. + if (authError(refreshError)) { + logoutTokens() + window.dispatchEvent(new Event('drive:logout')) + } + return Promise.reject(refreshError) + } }) export const drive={ list:params=>api.get('/drive/items/',{params}).then(r=>r.data), - stats:()=>api.get('/drive/stats/').then(r=>r.data), - folder:(name,parent)=>api.post('/drive/folders/',{name,parent}).then(r=>r.data), + 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), trash:id=>api.post(`/drive/items/${id}/trash/`), restore:id=>api.post(`/drive/items/${id}/restore/`), remove:id=>api.delete(`/drive/items/${id}/delete/`), link:(id,inline=false)=>api.post(`/drive/items/${id}/link/`,{inline}).then(r=>r.data.url), - upload:(files,parent,progress)=>{const data=new FormData();if(parent)data.append('parent',parent);for(const file of files)data.append('files',file);return api.post('/drive/uploads/',data,{timeout:0,onUploadProgress:e=>progress?.(e.total?Math.round((e.loaded/e.total)*100):null)}).then(r=>r.data)}, + upload:(files,parent,space,progress)=>{const data=new FormData();if(parent)data.append('parent',parent);data.append('space',space);for(const file of files)data.append('files',file);return api.post('/drive/uploads/',data,{timeout:0,onUploadProgress:e=>progress?.(e.total?Math.round((e.loaded/e.total)*100):null)}).then(r=>r.data)}, + uploadResumable:async(file,parent,space,progress)=>{ + const {data:started}=await api.post('/drive/uploads/start/',{name:file.name,size:file.size,parent,space}) + const chunkSize=8*1024*1024;let offset=started.offset + try{ + while(offsetapi.post('/drive/uploads/start/',{name:file.name,size:file.size,parent,space}).then(r=>r.data), + uploadStatus:id=>api.get(`/drive/uploads/${id}/`).then(r=>r.data), + uploadChunk:(id,offset,part,options={})=>{ + const form=new FormData();form.append('offset',offset);form.append('part',part,'chunk.bin') + return api.post(`/drive/uploads/${id}/chunk/`,form,{timeout:0,...options}).then(r=>r.data) + }, + finishUpload:id=>api.post(`/drive/uploads/${id}/finish/`,{},{timeout:0}).then(r=>r.data), + cancelUpload:id=>api.delete(`/drive/uploads/${id}/cancel/`).then(r=>r.data), + pendingUploads:()=>api.get('/drive/uploads/pending/').then(r=>r.data.sessions), + spaces:()=>api.get('/drive/spaces/').then(r=>r.data), + createSpace:data=>api.post('/drive/spaces/create/',data).then(r=>r.data), + editSpace:(id,data)=>api.patch(`/drive/spaces/${id}/`,data).then(r=>r.data), + renameSpace:display_name=>api.patch('/drive/spaces/me/',{display_name}).then(r=>r.data), + searchUsers:(q,parent_space)=>api.get('/drive/spaces/users/search/',{params:{q,parent_space}}).then(r=>r.data.users), + addUser:(user_id,parent_space,can_write=false,can_invite=false)=>api.post('/drive/spaces/users/',{user_id,can_write,can_invite},{params:{parent_space}}).then(r=>r.data), + grantPatch:(id,data)=>api.patch(`/drive/spaces/users/${id}/`,data).then(r=>r.data), + grantRemove:id=>api.delete(`/drive/spaces/users/${id}/`), + members:parent_space=>api.get('/drive/spaces/members/',{params:parent_space?{parent_space}:{}}).then(r=>r.data), + memberPatch:(id,data)=>api.patch(`/drive/spaces/members/${id}/`,data).then(r=>r.data), + spaceQuota:(id,quota_bytes)=>api.patch(`/drive/spaces/${id}/quota/`,{quota_bytes}).then(r=>r.data), + invite:data=>api.post('/drive/invitations/',data).then(r=>r.data), + revokeInvite:id=>api.post(`/drive/invitations/${id}/revoke/`).then(r=>r.data), + invitePreview:token=>api.get(`/invitations/${encodeURIComponent(token)}/`).then(r=>r.data), + inviteAccept:(token,data)=>api.post(`/invitations/${encodeURIComponent(token)}/accept/`,data).then(r=>r.data), } 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.'} diff --git a/frontend/src/components/drive/ContextActions.jsx b/frontend/src/components/drive/ContextActions.jsx new file mode 100644 index 0000000..df6d125 --- /dev/null +++ b/frontend/src/components/drive/ContextActions.jsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users } from 'lucide-react' + +export const fileOptions = (item, trash) => { + if (!item) return [] + if (trash) return item.can_write ? [ + { id: 'restore', title: 'Restaurer', icon: RotateCcw }, + { id: 'confirmdelete', title: 'Supprimer définitivement', icon: Trash2, danger: true }, + ] : [] + return [ + { id: 'open', title: 'Ouvrir', icon: Eye }, + ...(!item.is_folder ? [{ id: 'download', title: 'Télécharger', icon: Download }] : []), + ...(item.can_write ? [ + { id: 'rename', title: 'Renommer', icon: Pencil }, + { id: 'move', title: 'Déplacer', icon: FolderInput }, + { id: 'trash', title: 'Mettre à la corbeille', icon: Trash2, danger: true }, + ] : []), + ] +} + +export const spaceOptions = (space) => space?.can_invite ? [ + {id:'manage',title:'Paramètres de l’espace',icon:Settings2}, + {id:'members',title:'Accès et invitations',icon:Users}, +] : [] + +/** Keyboard-accessible viewport-clamped menu used on desktop right-click and mobile ···. */ +export default function ContextActions({ menu, onClose, onChoose }) { + const node = useRef(null) + useEffect(() => { + if (!menu) return + const onDown = event => { if (!node.current?.contains(event.target)) onClose() } + const onKey = event => { if (event.key === 'Escape') onClose() } + const onScroll = event => { if (!node.current?.contains(event.target)) onClose() } + document.addEventListener('pointerdown',onDown) + document.addEventListener('keydown',onKey) + window.addEventListener('scroll',onScroll,true) + node.current?.querySelector('button')?.focus() + return () => { + document.removeEventListener('pointerdown',onDown) + document.removeEventListener('keydown',onKey) + window.removeEventListener('scroll',onScroll,true) + } + }, [menu, onClose]) + if (!menu) return null + const width=236 + const height=menu.options.length*39+16 + const x=Math.max(8,Math.min(menu.x,window.innerWidth-width-8)) + const y=Math.max(8,Math.min(menu.y,window.innerHeight-height-8)) + return createPortal(
+ {menu.options.map(option => )} +
,document.body) +} + +export function mobileDots(event, show, options) { + event.stopPropagation() + const rect=event.currentTarget.getBoundingClientRect() + show({ x:rect.right, y:rect.bottom+4, options }) +} diff --git a/frontend/src/components/drive/DriveDialogs.jsx b/frontend/src/components/drive/DriveDialogs.jsx index 1335b43..e120947 100644 --- a/frontend/src/components/drive/DriveDialogs.jsx +++ b/frontend/src/components/drive/DriveDialogs.jsx @@ -2,6 +2,8 @@ import ItemFormDialog from './dialogs/ItemFormDialog' import ItemActionsDialog from './dialogs/ItemActionsDialog' import DeleteDialog from './dialogs/DeleteDialog' import PreviewDialog from './dialogs/PreviewDialog' +import SpaceSettingsDialog from './dialogs/SpaceSettingsDialog' +import MembersDialog from './dialogs/MembersDialog' /** Groups the Drive's existing dialogs without coupling them to the page markup. */ export default function DriveDialogs({ controller, user }) { @@ -10,5 +12,7 @@ export default function DriveDialogs({ controller, user }) { + + } diff --git a/frontend/src/components/drive/DriveExplorer.jsx b/frontend/src/components/drive/DriveExplorer.jsx index 8d09401..4889e23 100644 --- a/frontend/src/components/drive/DriveExplorer.jsx +++ b/frontend/src/components/drive/DriveExplorer.jsx @@ -1,26 +1,37 @@ -import React from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Progress } from '@/components/ui/progress' import { - ChevronRight, CloudUpload, FolderOpen, Grid2X2, List as ListIcon, + ChevronRight, FolderOpen, Grid2X2, List as ListIcon, LoaderCircle, Search, Upload, } from 'lucide-react' import FileList from './FileList' import FileGrid from './FileGrid' import Banner from './StatusBanner' import { headerNames } from '@/config/navigation' +import ContextActions, {fileOptions} from './ContextActions' export default function DriveExplorer({ controller, user }) { const { view, setParent, trail, items, stats, query, setQuery, search, loading, error, setError, note, setNote, layout, setLayout, sort, setSort, - uploadState, display, goRoot, openEntry, startAction, filesInput, isTrash, + display, goRoot, openEntry, startAction, performItemAction, filesInput, isTrash, canWrite, activeSpace, } = controller + const [context, setContext] = useState(null) + const closeContext = useCallback(() => setContext(null), []) + const openContext = (item,x,y) => { + const options=fileOptions(item,isTrash) + if (options.length) setContext({item,x,y,options}) + } + useEffect(() => { + if (context && !display.some(item => item.id === context.item.id && item.space_id === context.item.space_id)) closeContext() + }, [display, context, closeContext]) + useEffect(() => { closeContext() }, [view, activeSpace?.id, closeContext]) + const title = search ? `« ${search} »` - : trail.length && !search ? trail.at(-1)?.name : headerNames[view] + : trail.length && !search ? trail.at(-1)?.name : view === 'files' ? (activeSpace?.display_name || headerNames[view]) : headerNames[view] return (
@@ -32,7 +43,9 @@ export default function DriveExplorer({ controller, user }) {
- + {isTrash + ? Tous les espaces accessibles + : } {view === 'files' && !search && trail.map(folder => ( @@ -47,7 +60,7 @@ export default function DriveExplorer({ controller, user }) {

{title}

{loading ? 'Chargement…' : `${items.length} élément${items.length > 1 ? 's' : ''}`} - {view === 'shared' ? ' · accessible aux membres connectés' : ''} + {!canWrite ? ' · lecture seule' : ''}

@@ -72,20 +85,6 @@ export default function DriveExplorer({ controller, user }) { {error &&
setError('')} />
} {note &&
setNote('')} />
} - {uploadState && ( -
-
- - Import de {uploadState.names} - - - {uploadState.percent === null ? 'En cours…' : `${uploadState.percent}%`} - -
- -
- )} -
{loading ? (
@@ -105,22 +104,24 @@ export default function DriveExplorer({ controller, user }) { : search ? 'Essaie un autre nom de fichier.' : 'Importe tes premiers fichiers ou crée un dossier pour commencer à organiser ton espace.'}

- {!isTrash && !search && ( + {!isTrash && !search && canWrite && ( )}
) : layout === 'list' ? ( - + {} : openEntry} onActions={startAction} onContext={openContext} /> ) : ( - + {} : openEntry} onActions={startAction} onContext={openContext} /> )}
+ performItemAction(context.item,type)} />
- Les fichiers restent sur ton propre serveur. - {stats ? `${stats.folders} dossiers · ${stats.files} fichiers` : ''} + Clic droit sur un élément pour les actions.Tes fichiers restent sur ton serveur. + {!isTrash && stats ? `${stats.folders} dossiers · ${stats.files} fichiers` : ''}
) diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index c8434d2..88fb3ba 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -1,10 +1,51 @@ +import { useCallback, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' -import { Database, FolderPlus, HardDrive, LogOut } from 'lucide-react' +import ContextActions, {spaceOptions, mobileDots} from './ContextActions' +import { MoreHorizontal, Plus } from 'lucide-react' +import { Database, FolderPlus, HardDrive, LogOut, FolderLock, FolderOpen, X } from 'lucide-react' import { sections } from '@/config/navigation' import { fmtSize } from '@/lib/drive-format' export default function DriveSidebar({ controller, user, onLogout }) { - const { sidebar, view, search, setValue, setModal, setSidebar, section, stats } = controller + const { sidebar, view, search, setValue, setModal, setSidebar, section, stats, spaces, activeSpaceId, activeSpace, switchSpace, canWrite, + pendingUploads, cancelPendingUpload, uploads, setSpaceDialog, setManagedSpaceId } = controller + + const [spaceMenu,setSpaceMenu] = useState(null) + const closeSpaceMenu=useCallback(()=>setSpaceMenu(null),[]) + useEffect(()=>{ + if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && space.can_invite)) closeSpaceMenu() + },[spaces,spaceMenu,closeSpaceMenu]) + const showSpaceMenu=(space,x,y)=>{ + const options=spaceOptions(space) + if(options.length)setSpaceMenu({space,x,y,options}) + } + const spaceCommand=command=>{ + const space=spaceMenu?.space + if(!space)return + if(command==='manage')setSpaceDialog({mode:'manage',spaceId:space.id}) + if(command==='members'){ + setManagedSpaceId(space.id) + + } + setSidebar(false) + } + const usedBytes = stats?.used_bytes ?? stats?.bytes ?? 0 + const reservedBytes = stats?.reserved_bytes || 0 + const trashBytes = stats?.trashed_bytes || 0 + const activeBytes = stats?.active_bytes ?? Math.max(0, usedBytes - trashBytes) + const freeBytes = stats?.available_bytes + const occupied = usedBytes + reservedBytes + const allocated = stats?.quota_bytes ?? (freeBytes == null ? null : occupied + freeBytes) + const percent = allocated == null ? null : allocated === 0 ? (occupied ? 100 : 0) : Math.min(100, 100 * occupied / allocated) + + // Exclude sessions currently handled by this tab. An interrupted import can + // be explicitly canceled even after the user reloads the page. + const currentSessions = new Set(uploads.jobs.map(job => job.sessionId).filter(Boolean)) + const orphaned = pendingUploads.filter(job => !currentSessions.has(job.id)) + const personal = spaces.find(space => space.is_private && space.user_id === user.id) + const shared = spaces.filter(space => !space.is_private) + const manageable=shared.filter(space=>space.can_invite) + const createSpace=()=>{setSpaceDialog({mode:'create',parentId:activeSpace?.can_invite&&!activeSpace.is_private ? activeSpaceId : manageable[0]?.id});setSidebar(false)} const newFolder = () => { setValue('') @@ -19,18 +60,39 @@ export default function DriveSidebar({ controller, user, onLogout }) {
-
Bocage Drive
-
Mon espace de stockage
+
GoodDrive
+
Un Drive qui se partage
-
-
+
+ {personal && } +
+

Espaces partagés

+ {manageable.length>0 && } +
+
+ {shared.map(space =>
{if(space.can_invite){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}> + + {space.can_invite && } +
)} +

Explorer

@@ -57,21 +119,43 @@ export default function DriveSidebar({ controller, user, onLogout }) {
- Mon stockage + Stockage de l’espace {stats?.files ?? '—'} fichiers
-
{fmtSize(stats?.bytes || 0)}
-

Espace utilisé par tes documents actifs

+
{fmtSize(usedBytes)} {stats?.quota_bytes == null ? (freeBytes == null ? ' · sans limite' : ` / ${fmtSize(freeBytes)} restants du parent`) : ` / ${fmtSize(allocated)}`}
+ {percent != null &&
+
= 95 ? 'bg-destructive' : 'bg-primary'}`} style={{width:`${percent}%`}} /> +
} +

+ {freeBytes == null ? 'Aucune limite définie (ni dans les parents)' : stats?.quota_bytes == null ? `${fmtSize(freeBytes)} restants dans le parent` : `${fmtSize(freeBytes)} encore disponibles (parents inclus)`} +

+
+

Fichiers actifs : {fmtSize(activeBytes)}

+ {trashBytes > 0 &&

Corbeille : {fmtSize(trashBytes)} (vider pour libérer)

} + {reservedBytes > 0 &&

Transferts réservés : {fmtSize(reservedBytes)} (annuler pour libérer)

} +

Sous-espaces inclus. Le compteur correspond à l’espace réellement occupé.

+
+ {orphaned.length > 0 &&
+

Imports non terminés ({orphaned.length})

+

Ils réservent encore du stockage. Si tu as actualisé la page, annule ceux que tu ne souhaites plus reprendre.

+
+ {orphaned.map(job =>
+ {job.name} · {fmtSize(job.size)} + +
)} +
+
} +
{user.username.slice(0, 1).toUpperCase()} - {user.first_name || user.username} + {[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} {user.is_admin ? 'Administrateur' : 'Utilisateur'} @@ -82,6 +166,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
+ ) } diff --git a/frontend/src/components/drive/DriveToolbar.jsx b/frontend/src/components/drive/DriveToolbar.jsx index 3c18c82..6164fec 100644 --- a/frontend/src/components/drive/DriveToolbar.jsx +++ b/frontend/src/components/drive/DriveToolbar.jsx @@ -4,7 +4,7 @@ import { Menu, Moon, RefreshCcw, Search, Sun, Upload } from 'lucide-react' import { headerNames } from '@/config/navigation' export default function DriveToolbar({ controller }) { - const { search, view, query, setQuery, setSidebar, dark, setDark, refresh, filesInput, isTrash } = controller + const { search, view, query, setQuery, setSidebar, dark, setDark, refresh, filesInput, isTrash, canWrite, activeSpace } = controller const openPicker = () => filesInput.current?.click() return ( @@ -15,7 +15,7 @@ export default function DriveToolbar({ controller }) {
- {search ? 'Résultats de recherche' : headerNames[view]} + {search ? 'Résultats de recherche' : view==='files' ? (activeSpace?.is_private ? 'Mes fichiers' : activeSpace?.display_name || 'Fichiers') : headerNames[view]}
Tous vos fichiers, au même endroit.
@@ -29,13 +29,13 @@ export default function DriveToolbar({ controller }) { - - -
diff --git a/frontend/src/components/drive/FileGrid.jsx b/frontend/src/components/drive/FileGrid.jsx index 63a8d5a..d04357c 100644 --- a/frontend/src/components/drive/FileGrid.jsx +++ b/frontend/src/components/drive/FileGrid.jsx @@ -3,19 +3,21 @@ import { MoreHorizontal } from 'lucide-react' import FileGlyph from './FileGlyph' import { fmtSize } from '@/lib/drive-format' -export default function FileGrid({ items, onOpen, onActions }) { +export default function FileGrid({ items, onOpen, onActions, onContext }) { return (
{items.map(item => ( - - +
))}
) diff --git a/frontend/src/components/drive/FileList.jsx b/frontend/src/components/drive/FileList.jsx index 227a531..b7f0faa 100644 --- a/frontend/src/components/drive/FileList.jsx +++ b/frontend/src/components/drive/FileList.jsx @@ -1,9 +1,9 @@ import { Button } from '@/components/ui/button' -import { MoreHorizontal, Star, Users } from 'lucide-react' +import { MoreHorizontal, Users } from 'lucide-react' import FileGlyph from './FileGlyph' import { fmtDate, fmtSize } from '@/lib/drive-format' -export default function FileList({ items, user, onOpen, onActions }) { +export default function FileList({ items, user, onOpen, onActions, onContext }) { return ( @@ -11,7 +11,7 @@ export default function FileList({ items, user, onOpen, onActions }) { - @@ -20,6 +20,7 @@ export default function FileList({ items, user, onOpen, onActions }) { key={item.id} className="drive-file group cursor-pointer border-b border-border/70 transition-colors last:border-b-0 hover:bg-muted/55" onClick={() => onOpen(item)} + onContextMenu={event => {event.preventDefault();onContext(item,event.clientX,event.clientY)}} > -
Nom Modifié le Taille +
@@ -28,8 +29,8 @@ export default function FileList({ items, user, onOpen, onActions }) {
{item.name}
{item.owner_id !== user.id && {item.owner}} + {item.space_name && · {item.space_name}} {item.is_shared && Partagé} - {item.is_starred && }
@@ -38,7 +39,7 @@ export default function FileList({ items, user, onOpen, onActions }) {
{item.is_folder ? '—' : fmtSize(item.size)} + + {pausable && (paused ? + : + + )} + + + {expanded && <> +
+
+ {jobs.map(job => { + const current = job.status === 'done' ? job.size : Math.min(job.sent, job.size) + const pct = job.size ? 100 * current / job.size : job.status === 'done' ? 100 : 0 + const rate = currentTransferSpeed(job, now) + const eta = rate > 0 ? Math.ceil(Math.max(0, job.size - current) / rate) : null + return
+
+ {job.status === 'done' ? : } +
+

{job.name}

+

{labels[job.status]} · {current.toLocaleString('fr-FR')} / {job.size.toLocaleString('fr-FR')} octets

+ {job.status === 'uploading' &&

{rate > 0 ? `${fmtSize(rate)}/s · ETA ${formatDuration(eta)}` : 'Débit et ETA en calcul…'}

} +
+ {job.status === 'error' && } + {!['done', 'canceled', 'finishing'].includes(job.status) && } +
+ + {job.error &&

{job.error}

} +
+ })} +
+
+ {unfinished.length ? 'Ne ferme pas l’onglet pendant les transferts.' : 'Tous les transferts sont traités.'} + {jobs.some(job => ['done', 'canceled'].includes(job.status)) && } +
+ } + + ) +} diff --git a/frontend/src/components/drive/dialogs/ItemActionsDialog.jsx b/frontend/src/components/drive/dialogs/ItemActionsDialog.jsx index 3f769dd..3469bda 100644 --- a/frontend/src/components/drive/dialogs/ItemActionsDialog.jsx +++ b/frontend/src/components/drive/dialogs/ItemActionsDialog.jsx @@ -1,17 +1,39 @@ import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Eye, Download, Pencil, FolderInput, Star, Share2, Trash2, RotateCcw } from 'lucide-react' +import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw } from 'lucide-react' -export default function ItemActionsDialog({controller: c, user}) { - const {modal, setModal, action, handleAction, isTrash} = c +const ActionButton = ({ icon: Icon, children, destructive = false, ...props }) => ( + +) + +export default function ItemActionsDialog({ controller: c }) { + const { modal, setModal, action, handleAction, isTrash } = c return ( - {if(!v)setModal(null)}}>{action?.name||'Actions'}Choisir une action sur cet élément.
{action&&<> - {!isTrash&&} - {!action.is_folder&&} - {action.owner_id===user.id||user.is_admin?<> - {!isTrash&&<>{!action.parent&&}} - {isTrash&&<>} - :null} - }

Le partage rend cet élément et ses sous-dossiers accessibles en lecture à tous les utilisateurs connectés.

+ { if (!open) setModal(null) }}> + + + {action?.name || 'Actions'} + Choisir une action sur cet élément. + +
+ {action && <> + {!isTrash && handleAction('open')}>Ouvrir} + {!action.is_folder && handleAction('download')}>Télécharger} + {action.can_write && !isTrash && <> + handleAction('rename')}>Renommer + handleAction('move')}>Déplacer + handleAction('trash')}>Corbeille + } + {action.can_write && isTrash && <> + handleAction('restore')}>Restaurer + handleAction('confirmdelete')}>Supprimer + } + } +
+

Les fichiers restent privés. Seuls les membres autorisés de cet espace peuvent y accéder.

+
+
) } diff --git a/frontend/src/components/drive/dialogs/ItemFormDialog.jsx b/frontend/src/components/drive/dialogs/ItemFormDialog.jsx index d15413b..40c9ff3 100644 --- a/frontend/src/components/drive/dialogs/ItemFormDialog.jsx +++ b/frontend/src/components/drive/dialogs/ItemFormDialog.jsx @@ -6,6 +6,6 @@ import { HardDrive, ChevronRight, Folder } from 'lucide-react' export default function ItemFormDialog({controller: c}) { const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c return ( - {if(!v)setModal(null)}}>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}{modal==='move'?'Choisis le dossier de destination.':'Les modifications seront sauvegardées immédiatement.'}
{modal==='move'?
{moveTrail.map((x,i)=>
)}

Destination : {moveTrail.at(-1)?.name||'Racine'}

{moveItems.filter(x=>x.id!==action?.id && x.owner_id===action?.owner_id).map(x=>)}
:setValue(e.target.value)} placeholder="Nom"/>}
+ {if(!v)setModal(null)}}>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}{modal==='move'?'Choisis le dossier de destination.':'Les modifications seront sauvegardées immédiatement.'}
{modal==='move'?
{moveTrail.map((x,i)=>
)}

Destination : {moveTrail.at(-1)?.name||'Racine'}

{moveItems.filter(x=>x.id!==action?.id && x.can_write).map(x=>)}
:setValue(e.target.value)} placeholder="Nom"/>}
) } diff --git a/frontend/src/components/drive/dialogs/MembersDialog.jsx b/frontend/src/components/drive/dialogs/MembersDialog.jsx new file mode 100644 index 0000000..b905aeb --- /dev/null +++ b/frontend/src/components/drive/dialogs/MembersDialog.jsx @@ -0,0 +1,16 @@ +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import MembersPage from '@/pages/MembersPage' + +export default function MembersDialog({controller, user}) { + const {managedSpaceId, setManagedSpaceId, spaces} = controller + const selected = spaces.find(space => space.id === managedSpaceId) + return {if (!open) setManagedSpaceId(null)}}> + + + Accès et invitations · {selected?.display_name || 'Espace'} + Gérer les utilisateurs et leurs permissions dans cet espace. + + + + +} diff --git a/frontend/src/components/drive/dialogs/PreviewDialog.jsx b/frontend/src/components/drive/dialogs/PreviewDialog.jsx index e7f71c0..adfd5f2 100644 --- a/frontend/src/components/drive/dialogs/PreviewDialog.jsx +++ b/frontend/src/components/drive/dialogs/PreviewDialog.jsx @@ -1,11 +1,31 @@ import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { LoaderCircle, FileText, Download } from 'lucide-react' +import { LoaderCircle, FileText, Download, RefreshCcw } from 'lucide-react' import { fmtSize } from '@/lib/drive-format' -export default function PreviewDialog({controller: c}) { - const {preview, setPreview, previewUrl, setPreviewUrl, download} = c - return ( - {if(!v){setPreview(null);setPreviewUrl('')}}}>{preview?.name}{preview&&!preview.is_folder?fmtSize(preview.size):''}
{!previewUrl?:previewUrl==='unsupported'?

Aperçu non disponible pour ce fichier.

:preview?.mime_type.startsWith('image/')?{preview.name}/:preview?.mime_type==='application/pdf'?