nice
This commit is contained in:
parent
97a25e4df8
commit
ac44c6ed8b
@ -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.
|
|
||||||
42
CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md
Normal file
42
CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md
Normal file
@ -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.
|
||||||
@ -2,7 +2,10 @@ from django.contrib import admin
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
from drive.views import me
|
from drive.views import me
|
||||||
|
from drive import views_spaces
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('api/invitations/<str:token>/',views_spaces.invitation_preview),
|
||||||
|
path('api/invitations/<str:token>/accept/',views_spaces.accept_invitation),
|
||||||
path('django-admin/', admin.site.urls),
|
path('django-admin/', admin.site.urls),
|
||||||
path('api/auth/login/', TokenObtainPairView.as_view()),
|
path('api/auth/login/', TokenObtainPairView.as_view()),
|
||||||
path('api/auth/refresh/', TokenRefreshView.as_view()),
|
path('api/auth/refresh/', TokenRefreshView.as_view()),
|
||||||
|
|||||||
@ -1,8 +1,43 @@
|
|||||||
|
"""Administration of users and shared spaces, without access to personal files."""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import DriveItem
|
from django.db.models import Q
|
||||||
@admin.register(DriveItem)
|
from .models import DriveSpace, SpaceInvitation, SpaceGrant
|
||||||
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')
|
@admin.register(DriveSpace)
|
||||||
search_fields=('name','owner__username')
|
class SpaceAdmin(admin.ModelAdmin):
|
||||||
readonly_fields=('id','storage_key','size','created_at','updated_at')
|
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.
|
||||||
|
|||||||
69
backend/drive/migrations/0004_spaces.py
Normal file
69
backend/drive/migrations/0004_spaces.py
Normal file
@ -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')),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
12
backend/drive/migrations/0005_space_quotas.py
Normal file
12
backend/drive/migrations/0005_space_quotas.py
Normal file
@ -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)),
|
||||||
|
]
|
||||||
14
backend/drive/migrations/0006_merge_20261013_2159.py
Normal file
14
backend/drive/migrations/0006_merge_20261013_2159.py
Normal file
@ -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 = [
|
||||||
|
]
|
||||||
41
backend/drive/migrations/0007_private_and_direct_access.py
Normal file
41
backend/drive/migrations/0007_private_and_direct_access.py
Normal file
@ -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),
|
||||||
|
]
|
||||||
18
backend/drive/migrations/0008_alter_spacegrant_id.py
Normal file
18
backend/drive/migrations/0008_alter_spacegrant_id.py
Normal file
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
7
backend/drive/migrations/0009_managed_spaces.py
Normal file
7
backend/drive/migrations/0009_managed_spaces.py
Normal file
@ -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))]
|
||||||
@ -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 = [
|
||||||
|
]
|
||||||
@ -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
|
import uuid
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
class DriveItem(models.Model):
|
|
||||||
id=models.UUIDField(primary_key=True,default=uuid.uuid4,editable=False)
|
class DriveSpace(models.Model):
|
||||||
owner=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,related_name='drive_items')
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_space', null=True, blank=True)
|
||||||
parent=models.ForeignKey('self',null=True,blank=True,on_delete=models.CASCADE,related_name='children')
|
managed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name='managed_drive_spaces')
|
||||||
name=models.CharField(max_length=255)
|
private_owner = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='private_drive_space', null=True, blank=True)
|
||||||
is_folder=models.BooleanField(default=False)
|
parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.PROTECT, related_name='children')
|
||||||
storage_key=models.CharField(max_length=120,blank=True)
|
directory_name = models.CharField(max_length=24, unique=True, null=True, blank=True)
|
||||||
size=models.BigIntegerField(default=0)
|
relative_path = models.CharField(max_length=700, unique=True)
|
||||||
mime_type=models.CharField(max_length=180,default='application/octet-stream')
|
display_name = models.CharField(max_length=120)
|
||||||
is_shared=models.BooleanField(default=False) # share a ROOT item and its descendants with signed-in users
|
can_view_parent = models.BooleanField(default=True)
|
||||||
is_starred=models.BooleanField(default=False)
|
can_write_parent = models.BooleanField(default=False)
|
||||||
trashed_at=models.DateTimeField(null=True,blank=True)
|
can_invite = models.BooleanField(default=False)
|
||||||
created_at=models.DateTimeField(auto_now_add=True)
|
quota_bytes = models.BigIntegerField(null=True, blank=True, default=None)
|
||||||
updated_at=models.DateTimeField(auto_now=True)
|
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:
|
class Meta:
|
||||||
ordering=['-is_folder','name','id']
|
constraints = [models.UniqueConstraint(fields=['user', 'space'], name='drive_unique_space_grant')]
|
||||||
indexes=[models.Index(fields=['owner','parent']),models.Index(fields=['owner','trashed_at'])]
|
|
||||||
|
|
||||||
|
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/<uuid>).
|
||||||
|
# New files go under /files/root/0001/.../ab/<uuid>.
|
||||||
|
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):
|
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.')
|
raise ValidationError('Nom de fichier ou dossier invalide.')
|
||||||
if len(self.name)>255: raise ValidationError('Nom trop long.')
|
if len(self.name) > 255:
|
||||||
if self.parent_id and not self.parent.is_folder: raise ValidationError('Le parent doit être un dossier.')
|
raise ValidationError('Nom trop long.')
|
||||||
if self.parent_id and self.parent.owner_id != self.owner_id: raise ValidationError('Le propriétaire du dossier doit être identique.')
|
if self.parent_id and not self.parent.is_folder:
|
||||||
if self.is_shared and self.parent_id: raise ValidationError('Seuls les éléments racine sont partageables.')
|
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):
|
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)
|
||||||
id=models.UUIDField(primary_key=True,default=uuid.uuid4,editable=False)
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
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)
|
parent = models.ForeignKey(DriveItem, null=True, blank=True, on_delete=models.SET_NULL)
|
||||||
name=models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
total_size=models.BigIntegerField()
|
total_size = models.BigIntegerField()
|
||||||
received=models.BigIntegerField(default=0)
|
received = models.BigIntegerField(default=0)
|
||||||
created_at=models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at=models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|||||||
206
backend/drive/quotas.py
Normal file
206
backend/drive/quotas.py
Normal file
@ -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 quota<committed:
|
||||||
|
raise ValueError('Le quota est inférieur aux fichiers et sous-espaces déjà attribués.')
|
||||||
|
current=space.quota_bytes
|
||||||
|
if current==quota:return
|
||||||
|
# Compute the changed reservation at every ancestor: each parent's
|
||||||
|
# committed usage has this subtree's reserved/actual allocation substituted.
|
||||||
|
old_child=max(current or 0,committed)
|
||||||
|
new_child=max(quota or 0,committed)
|
||||||
|
for ancestor in chain[1:]:
|
||||||
|
amount=committed_tree[ancestor.pk]-old_child+new_child
|
||||||
|
if ancestor.quota_bytes is not None and amount>ancestor.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.'
|
||||||
|
)
|
||||||
@ -1,33 +1,33 @@
|
|||||||
"""Reusable file-tree permission, validation and serialization helpers."""
|
"""Shared file-tree access and serialization helpers."""
|
||||||
import uuid
|
import uuid
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from .models import DriveItem
|
|
||||||
|
|
||||||
|
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import DriveItem
|
from .models import DriveItem
|
||||||
|
from .spaces import can_read_space, can_write_space
|
||||||
|
|
||||||
|
|
||||||
def error(message, code=400):
|
def error(message, code=400):
|
||||||
return Response({'detail':message}, status=code)
|
return Response({'detail':message}, status=code)
|
||||||
|
|
||||||
|
|
||||||
def item_info(item):
|
def item_info(item, user=None):
|
||||||
return {
|
data = {
|
||||||
'id':str(item.id), 'name':item.name, 'is_folder':item.is_folder,
|
'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,
|
'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,
|
'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,
|
'trashed_at':item.trashed_at.isoformat() if item.trashed_at else None,
|
||||||
'created_at':item.created_at.isoformat(),'updated_at':item.updated_at.isoformat(),
|
'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):
|
def get_item(pk):
|
||||||
try:
|
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):
|
except (DriveItem.DoesNotExist,ValueError,ValidationError):
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
@ -45,16 +45,15 @@ def chain(item):
|
|||||||
|
|
||||||
|
|
||||||
def readable(item, user, allow_trashed=False):
|
def readable(item, user, allow_trashed=False):
|
||||||
ancestors=chain(item)
|
if not can_read_space(user,item.space):
|
||||||
if not allow_trashed and any(n.trashed_at is not None for n in ancestors):
|
|
||||||
return False
|
return False
|
||||||
if user.is_staff or user.is_superuser or item.owner_id == user.pk:
|
if not allow_trashed and any(n.trashed_at is not None for n in chain(item)):
|
||||||
return True
|
return False
|
||||||
return bool(ancestors[-1].is_shared and ancestors[-1].owner_id == item.owner_id)
|
return True
|
||||||
|
|
||||||
|
|
||||||
def writable(item,user):
|
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):
|
def valid_name(name):
|
||||||
@ -63,32 +62,33 @@ def valid_name(name):
|
|||||||
name=name.strip()
|
name=name.strip()
|
||||||
if name in ('.','..') or any(c in name for c in ('/','\\','\x00','\r','\n')):
|
if name in ('.','..') or any(c in name for c in ('/','\\','\x00','\r','\n')):
|
||||||
raise ValueError('Ce nom contient des caractères interdits.')
|
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:
|
if not parent_id:
|
||||||
return None
|
return None
|
||||||
try:
|
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):
|
except (ValueError,DriveItem.DoesNotExist,AttributeError):
|
||||||
raise ValueError('Dossier de destination introuvable.')
|
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):
|
if not parent.is_folder or not readable(parent,user) or not writable(parent,user):
|
||||||
raise PermissionError('Dossier de destination inaccessible en écriture.')
|
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
|
return parent
|
||||||
|
|
||||||
|
|
||||||
def descendant_files(node):
|
def descendant_files(node):
|
||||||
# Collect all physical paths before cascade delete.
|
|
||||||
files=[]
|
files=[]
|
||||||
pending=[node.pk]
|
pending=[node.pk]
|
||||||
while pending:
|
while pending:
|
||||||
batch=pending[:500];pending=pending[500:]
|
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)
|
pending.extend(row[0] for row in children)
|
||||||
for _,is_folder,key in children:
|
for _,is_folder,key,location in children:
|
||||||
if not is_folder and key: files.append(key)
|
if not is_folder and key: files.append(location or f'{key[:2]}/{key}')
|
||||||
if not node.is_folder and node.storage_key:files.append(node.storage_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
|
return files
|
||||||
|
|||||||
169
backend/drive/spaces.py
Normal file
169
backend/drive/spaces.py
Normal file
@ -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()
|
||||||
@ -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
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
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 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):
|
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.tmp=tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(self.tmp.cleanup)
|
self.addCleanup(self.tmp.cleanup)
|
||||||
self.override=override_settings(DRIVE_STORAGE_ROOT=Path(self.tmp.name))
|
self.overrides=override_settings(DRIVE_STORAGE_ROOT=Path(self.tmp.name),
|
||||||
self.override.enable();self.addCleanup(self.override.disable)
|
FILE_UPLOAD_TEMP_DIR=str(Path(self.tmp.name)/'tmp'))
|
||||||
self.client=APIClient();self.client.force_authenticate(user=self.a)
|
self.overrides.enable();self.addCleanup(self.overrides.disable)
|
||||||
def folder(self,name,parent=None):
|
User=get_user_model()
|
||||||
return self.client.post('/api/drive/folders/',{'name':name,'parent':parent},format='json')
|
self.admin=User.objects.create_superuser('admin', 'admin@example.test','Safe-password123!')
|
||||||
def test_owner_and_shared_access(self):
|
self.client=APIClient()
|
||||||
r=self.folder('Photos');self.assertEqual(r.status_code,201)
|
self.as_user(self.admin)
|
||||||
folder=r.data['id']
|
self.root=self.client.get('/api/auth/me/').data['space_id']
|
||||||
self.client.force_authenticate(user=self.b)
|
|
||||||
self.assertEqual(self.client.get(f'/api/drive/items/?parent={folder}').status_code,403)
|
def as_user(self,user):
|
||||||
self.client.force_authenticate(user=self.a)
|
self.client.force_authenticate(user=user)
|
||||||
self.client.patch(f'/api/drive/items/{folder}/',{'is_shared':True},format='json')
|
|
||||||
self.client.force_authenticate(user=self.b)
|
def invite(self,**rights):
|
||||||
self.assertEqual(self.client.get(f'/api/drive/items/?parent={folder}').status_code,200)
|
result=self.client.post('/api/drive/invitations/',rights,format='json')
|
||||||
self.assertEqual(self.folder('Nope',folder).status_code,403)
|
self.assertEqual(result.status_code,201,result.data)
|
||||||
def test_upload_download_and_trash(self):
|
return result.data['token']
|
||||||
res=self.client.post('/api/drive/uploads/',{'files':[SimpleUploadedFile('hello.txt',b'hello world')]},format='multipart')
|
|
||||||
|
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)
|
self.assertEqual(res.status_code,201,res.data)
|
||||||
pk=res.data['items'][0]['id']
|
item=DriveItem.objects.get(pk=res.data['items'][0]['id'])
|
||||||
link=self.client.post(f'/api/drive/items/{pk}/link/',{},format='json')
|
self.assertTrue(item.storage_location.startswith(DriveSpace.objects.get(private_owner=alice).relative_path + '/'))
|
||||||
self.assertEqual(link.status_code,200)
|
self.assertTrue((Path(self.tmp.name)/item.storage_location).exists())
|
||||||
resp=self.client.get(link.data['url'])
|
url=self.client.post(f'/api/drive/items/{item.pk}/link/',{},format='json').data['url']
|
||||||
self.assertEqual(b''.join(resp.streaming_content),b'hello world')
|
self.assertEqual(b''.join(self.client.get(url).streaming_content),b'hello')
|
||||||
self.client.force_authenticate(user=self.b)
|
self.as_user(self.admin)
|
||||||
self.assertEqual(self.client.post(f'/api/drive/items/{pk}/link/').status_code,403)
|
self.assertEqual(self.client.post(f'/api/drive/items/{item.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)
|
def test_invitation_one_time_only_and_rename_does_not_move_directory(self):
|
||||||
self.assertEqual(self.client.get(link.data['url']).status_code,404)
|
self.as_user(self.admin);token=self.invite()
|
||||||
self.assertEqual(self.client.post(f'/api/drive/items/{pk}/restore/').status_code,200)
|
alice=self.accept(token,'alice')
|
||||||
self.assertEqual(self.client.get(link.data['url']).status_code,200)
|
self.as_user(None)
|
||||||
def test_cycle_and_invalid_path(self):
|
self.assertEqual(self.client.get(f'/api/invitations/{token}/').status_code,404)
|
||||||
parent=self.folder('Parent').data['id']
|
self.assertEqual(self.client.post(f'/api/invitations/{token}/accept/',{},format='json').status_code,400)
|
||||||
child=self.folder('Child',parent).data['id']
|
self.as_user(alice)
|
||||||
self.assertEqual(self.client.patch(f'/api/drive/items/{parent}/',{'parent':child},format='json').status_code,400)
|
space=DriveSpace.objects.get(user=alice)
|
||||||
self.assertEqual(self.folder('../escape').status_code,400)
|
original=space.relative_path
|
||||||
def test_shared_parent_descendants(self):
|
changed=self.client.patch('/api/drive/spaces/me/',{'display_name':'Vacances'},format='json')
|
||||||
top=self.folder('Shared').data['id']
|
self.assertEqual(changed.status_code,200)
|
||||||
inner=self.folder('Nested',top).data['id']
|
space.refresh_from_db()
|
||||||
self.client.patch(f'/api/drive/items/{top}/',{'is_shared':True},format='json')
|
self.assertEqual(space.display_name,'Vacances')
|
||||||
self.client.force_authenticate(user=self.b)
|
self.assertEqual(space.relative_path,original)
|
||||||
self.assertEqual(self.client.get(f'/api/drive/items/?parent={inner}').status_code,200)
|
|
||||||
def test_resume_upload(self):
|
def test_cannot_grant_parent_write_without_read(self):
|
||||||
start=self.client.post('/api/drive/uploads/start/',{'name':'big.bin','size':6},format='json')
|
self.as_user(self.admin)
|
||||||
self.assertEqual(start.status_code,201,start.data)
|
res=self.client.post('/api/drive/invitations/',{'can_view_parent':False,'can_write_parent':True},format='json')
|
||||||
pk=start.data['id']
|
self.assertEqual(res.status_code,400)
|
||||||
chunk=self.client.post(f'/api/drive/uploads/{pk}/chunk/',{
|
|
||||||
'offset':'0','part':SimpleUploadedFile('part.bin',b'abc')},format='multipart')
|
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(chunk.status_code,200,chunk.data)
|
||||||
self.assertEqual(self.client.get(f'/api/drive/uploads/{pk}/').data['offset'],3)
|
self.assertEqual(chunk.data['offset'],3)
|
||||||
repeated=self.client.post(f'/api/drive/uploads/{pk}/chunk/',{
|
progress=self.client.get(f'/api/drive/uploads/{job_id}/')
|
||||||
'offset':'0','part':SimpleUploadedFile('part.bin',b'abc')},format='multipart')
|
self.assertEqual(progress.data['offset'],3)
|
||||||
self.assertEqual(repeated.status_code,409)
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
self.client.post(f'/api/drive/uploads/{pk}/chunk/',{
|
canceled=self.client.delete(f'/api/drive/uploads/{job_id}/cancel/')
|
||||||
'offset':'3','part':SimpleUploadedFile('part.bin',b'def')},format='multipart')
|
self.assertEqual(canceled.status_code,204)
|
||||||
done=self.client.post(f'/api/drive/uploads/{pk}/finish/')
|
self.assertFalse((Path(self.tmp.name)/'tmp'/f'drive-{job_id}.part').exists())
|
||||||
self.assertEqual(done.status_code,201,done.data)
|
self.assertEqual(self.client.get(f'/api/drive/uploads/{job_id}/').status_code,404)
|
||||||
self.assertEqual(done.data['size'],6)
|
self.assertEqual(self.client.get(f'/api/drive/stats/?space={self.root}').data['reserved_bytes'],0)
|
||||||
def test_resume_forbidden_other_user(self):
|
self.assertFalse(DriveItem.objects.filter(name='gros.zip').exists())
|
||||||
pk=self.client.post('/api/drive/uploads/start/',{'name':'private.bin','size':5},format='json').data['id']
|
|
||||||
self.client.force_authenticate(user=self.b)
|
def test_shared_folder_listing_reflects_other_users_trash(self):
|
||||||
self.assertEqual(self.client.get(f'/api/drive/uploads/{pk}/').status_code,404)
|
self.as_user(self.admin)
|
||||||
def test_anonymous_rejected(self):
|
token=self.invite(can_view_parent=True,can_write_parent=True)
|
||||||
self.client.force_authenticate(user=None)
|
child=self.accept(token,'member')
|
||||||
self.assertIn(self.client.get('/api/drive/items/').status_code,(401,403))
|
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)
|
||||||
|
|||||||
@ -1,11 +1,25 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import views
|
||||||
|
from . import views_spaces
|
||||||
urlpatterns=[
|
urlpatterns=[
|
||||||
|
path('spaces/',views_spaces.spaces),
|
||||||
|
path('spaces/create/',views_spaces.create_shared_space),
|
||||||
|
path('spaces/<int:space_id>/',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/<int:grant_id>/',views_spaces.edit_existing_user),
|
||||||
|
path('spaces/members/<int:space_id>/',views_spaces.update_member),
|
||||||
|
path('spaces/<int:space_id>/quota/',views_spaces.update_space_quota),
|
||||||
|
path('invitations/',views_spaces.create_invitation),
|
||||||
|
path('invitations/<uuid:invitation_id>/revoke/',views_spaces.revoke_invitation),
|
||||||
path('items/',views.listing),
|
path('items/',views.listing),
|
||||||
path('stats/',views.stats),
|
path('stats/',views.stats),
|
||||||
path('folders/',views.create_folder),
|
path('folders/',views.create_folder),
|
||||||
path('uploads/',views.upload),
|
path('uploads/',views.upload),
|
||||||
path('uploads/start/',views.upload_start),
|
path('uploads/start/',views.upload_start),
|
||||||
|
path('uploads/pending/',views.upload_pending),
|
||||||
path('uploads/<uuid:upload_id>/',views.upload_status),
|
path('uploads/<uuid:upload_id>/',views.upload_status),
|
||||||
path('uploads/<uuid:upload_id>/chunk/',views.upload_chunk),
|
path('uploads/<uuid:upload_id>/chunk/',views.upload_chunk),
|
||||||
path('uploads/<uuid:upload_id>/finish/',views.upload_finish),
|
path('uploads/<uuid:upload_id>/finish/',views.upload_finish),
|
||||||
|
|||||||
@ -7,5 +7,5 @@ Implementations are grouped by responsibility without changing route names.
|
|||||||
from .views_items import (me, listing, stats, create_folder, update_item,
|
from .views_items import (me, listing, stats, create_folder, update_item,
|
||||||
trash_item, restore_item, delete_item)
|
trash_item, restore_item, delete_item)
|
||||||
from .views_transfers import (upload, upload_start, upload_status, upload_chunk,
|
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
|
from .views_content import content_url, content
|
||||||
|
|||||||
@ -1,12 +1,30 @@
|
|||||||
"""Expiring download links and secure file streaming endpoints."""
|
"""Short-lived, permission-bound preview/download URLs and HTTP byte ranges."""
|
||||||
from pathlib import Path
|
import mimetypes
|
||||||
from django.conf import settings
|
import re
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
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.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
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'])
|
@api_view(['POST'])
|
||||||
@ -14,37 +32,72 @@ def content_url(request,item_id):
|
|||||||
item=get_item(item_id)
|
item=get_item(item_id)
|
||||||
if item.is_folder or not readable(item,request.user):return error('Fichier inaccessible.',403)
|
if item.is_folder or not readable(item,request.user):return error('Fichier inaccessible.',403)
|
||||||
inline=bool(request.data.get('inline',False))
|
inline=bool(request.data.get('inline',False))
|
||||||
safe_types={'image/jpeg','image/png','image/gif','image/webp','application/pdf',
|
if inline and not safe_preview_type(item):return error('Aperçu non disponible pour ce type de fichier.',415)
|
||||||
'audio/mpeg','audio/ogg','audio/wav','audio/mp4'}
|
# Encode the authorized identity so access is checked again on every request.
|
||||||
if inline and item.mime_type not in safe_types:
|
token=TimestampSigner(salt='drive-content').sign(f'{item.pk}:{int(inline)}:{request.user.pk}')
|
||||||
return error('Aperçu non disponible pour ce type de fichier.')
|
return Response({'url':f'/api/drive/items/{item.pk}/content/?token={token}','expires_in':7200})
|
||||||
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})
|
|
||||||
|
|
||||||
|
|
||||||
@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])
|
@permission_classes([AllowAny])
|
||||||
|
@xframe_options_sameorigin
|
||||||
def content(request,item_id):
|
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','')
|
token=request.query_params.get('token','')
|
||||||
try:
|
try:
|
||||||
value=TimestampSigner(salt='drive-content').unsign(token,max_age=300)
|
value=TimestampSigner(salt='drive-content').unsign(token,max_age=7200)
|
||||||
expected,inline=value.rsplit(':',1)
|
expected,inline,user_pk=value.rsplit(':',2)
|
||||||
if expected!=str(item_id) or inline not in ('0','1'):raise BadSignature()
|
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
|
raise Http404
|
||||||
item=get_item(item_id)
|
item=get_item(item_id)
|
||||||
if item.is_folder or item.trashed_at:raise Http404
|
if item.is_folder or not readable(item,user):raise Http404
|
||||||
if any(n.trashed_at for n in chain(item)):raise Http404
|
media=safe_preview_type(item)
|
||||||
# Authorization performed when URL was minted, 5-minute token is a bearer credential.
|
if inline=='1' and not media:raise Http404
|
||||||
key=item.storage_key
|
path=storage_path(item)
|
||||||
if not key or len(key)!=36:
|
|
||||||
raise Http404
|
|
||||||
path=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key
|
|
||||||
if not path.is_file():raise Http404
|
if not path.is_file():raise Http404
|
||||||
response=FileResponse(open(path,'rb'),as_attachment=inline=='0',filename=item.name,
|
size=path.stat().st_size
|
||||||
content_type=item.mime_type if inline=='1' else 'application/octet-stream')
|
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<start:
|
||||||
|
response=HttpResponse(status=416)
|
||||||
|
response['Content-Range']=f'bytes */{size}'
|
||||||
|
return response
|
||||||
|
response=StreamingHttpResponse(read_bytes(path,start,end) if request.method=='GET' else [],status=206,content_type=content_type)
|
||||||
|
response['Content-Range']=f'bytes {start}-{end}/{size}'
|
||||||
|
response['Content-Length']=end-start+1
|
||||||
|
elif request.method=='HEAD':
|
||||||
|
response=HttpResponse(content_type=content_type)
|
||||||
|
response['Content-Length']=size
|
||||||
|
else:
|
||||||
|
response=FileResponse(path.open('rb'),as_attachment=inline=='0',filename=name,content_type=content_type)
|
||||||
|
response['Accept-Ranges']='bytes'
|
||||||
response['Cache-Control']='private, no-store'
|
response['Cache-Control']='private, no-store'
|
||||||
response['X-Content-Type-Options']='nosniff'
|
response['X-Content-Type-Options']='nosniff'
|
||||||
|
if content_type.startswith('text/plain'):
|
||||||
|
response['Content-Security-Policy']="sandbox"
|
||||||
return response
|
return response
|
||||||
|
|||||||
@ -1,86 +1,95 @@
|
|||||||
"""Drive navigation, folder management, metadata and trash endpoints."""
|
"""Space-bound navigation, folder management and trash endpoints."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q, Sum, Count
|
from django.db.models import Sum, Count
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.decorators import api_view
|
from rest_framework.decorators import api_view
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import DriveItem
|
from .models import DriveItem, DriveSpace
|
||||||
|
from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space
|
||||||
|
from .quotas import quota_state
|
||||||
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, descendant_files
|
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, descendant_files
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def me(request):
|
def me(request):
|
||||||
|
space=ensure_space(request.user)
|
||||||
|
private=ensure_private_space(request.user)
|
||||||
return Response({'id':request.user.pk,'username':request.user.username,
|
return Response({'id':request.user.pk,'username':request.user.username,
|
||||||
'is_admin':request.user.is_staff or request.user.is_superuser,
|
'is_admin':is_admin(request.user),'first_name':request.user.first_name,
|
||||||
'first_name':request.user.first_name})
|
'last_name':request.user.last_name,'private_space_id':private.pk,
|
||||||
|
'space_id':space.pk,'can_invite':space.can_invite or is_admin(request.user)})
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
def listing(request):
|
def listing(request):
|
||||||
user=request.user
|
user=request.user
|
||||||
view=request.query_params.get('view','files')
|
view=request.query_params.get('view','files')
|
||||||
|
if view not in ('files','trash'):
|
||||||
|
return error('Vue inconnue.',400)
|
||||||
q=request.query_params.get('q','').strip()
|
q=request.query_params.get('q','').strip()
|
||||||
parent_id=request.query_params.get('parent')
|
parent_id=request.query_params.get('parent')
|
||||||
parent=None
|
parent=None
|
||||||
if parent_id:
|
# Trash is global across *readable* spaces, regardless of the selected space.
|
||||||
parent=get_item(parent_id)
|
# In particular another person's private space is never included, even for admin.
|
||||||
if not parent.is_folder or not readable(parent,user):
|
|
||||||
return error('Dossier inaccessible.',403)
|
|
||||||
if view=='trash':
|
if view=='trash':
|
||||||
nodes=DriveItem.objects.filter(owner=user,trashed_at__isnull=False).select_related('owner')
|
accessible=[space.pk for space in DriveSpace.objects.all() if can_read_space(user,space)]
|
||||||
elif view=='shared':
|
nodes=DriveItem.objects.filter(space_id__in=accessible,trashed_at__isnull=False).select_related('owner','parent','space')
|
||||||
nodes=DriveItem.objects.filter(parent__isnull=True,is_shared=True,trashed_at__isnull=True).select_related('owner')
|
if q:nodes=nodes.filter(name__icontains=q)
|
||||||
if not user.is_staff:
|
space=None
|
||||||
nodes=nodes.exclude(owner=user)
|
|
||||||
elif view=='starred':
|
|
||||||
nodes=DriveItem.objects.filter(owner=user,is_starred=True,trashed_at__isnull=True).select_related('owner','parent')
|
|
||||||
elif view=='recent':
|
|
||||||
nodes=DriveItem.objects.filter(owner=user,is_folder=False,trashed_at__isnull=True).select_related('owner','parent').order_by('-updated_at')
|
|
||||||
elif q:
|
|
||||||
nodes=DriveItem.objects.filter(owner=user,trashed_at__isnull=True,name__icontains=q).select_related('owner','parent')
|
|
||||||
elif parent:
|
|
||||||
nodes=DriveItem.objects.filter(parent=parent,trashed_at__isnull=True).select_related('owner','parent')
|
|
||||||
else:
|
else:
|
||||||
nodes=DriveItem.objects.filter(parent__isnull=True,trashed_at__isnull=True).filter(
|
try:space=resolve_space(user,request.query_params.get('space'))
|
||||||
Q(owner=user)|Q(is_shared=True)).select_related('owner','parent')
|
except PermissionError as ex:return error(str(ex),403)
|
||||||
|
if parent_id:
|
||||||
|
parent=get_item(parent_id)
|
||||||
|
if not parent.is_folder or parent.space_id!=space.pk or not readable(parent,user):
|
||||||
|
return error('Dossier inaccessible.',403)
|
||||||
|
nodes=DriveItem.objects.filter(space=space,trashed_at__isnull=True).select_related('owner','parent','space')
|
||||||
|
if q:
|
||||||
|
nodes=nodes.filter(name__icontains=q)
|
||||||
|
elif parent:
|
||||||
|
nodes=nodes.filter(parent=parent)
|
||||||
|
else:
|
||||||
|
nodes=nodes.filter(parent__isnull=True)
|
||||||
items=[]
|
items=[]
|
||||||
for n in nodes[:500]:
|
for n in nodes[:501]:
|
||||||
if view=='trash' or readable(n,user):
|
if readable(n,user,allow_trashed=view=='trash'):
|
||||||
items.append(item_info(n))
|
info=item_info(n,user)
|
||||||
|
if view=='trash':info['space_name']=n.space.display_name if not n.space.private_owner_id else 'Mes fichiers'
|
||||||
|
items.append(info)
|
||||||
|
if len(items)>=500:break
|
||||||
trail=[]
|
trail=[]
|
||||||
if parent:
|
if parent:
|
||||||
for n in reversed(chain(parent)):
|
for n in reversed(chain(parent)):
|
||||||
if readable(n,user):
|
trail.append({'id':str(n.id),'name':n.name})
|
||||||
trail.append({'id':str(n.id),'name':n.name})
|
return Response({'items':items,'breadcrumb':trail,'limited':len(items)>=500,
|
||||||
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'])
|
@api_view(['GET'])
|
||||||
def stats(request):
|
def stats(request):
|
||||||
user=request.user
|
try:space=resolve_space(request.user,request.query_params.get('space'))
|
||||||
result=DriveItem.objects.filter(owner=user,trashed_at__isnull=True,is_folder=False).aggregate(
|
except PermissionError as ex:return error(str(ex),403)
|
||||||
count=Count('id'),total=Sum('size'))
|
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,
|
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(),
|
**quota_state(space),
|
||||||
'shared':DriveItem.objects.filter(owner=user,parent__isnull=True,is_shared=True,trashed_at__isnull=True).count()})
|
'folders':DriveItem.objects.filter(space=space,is_folder=True,trashed_at__isnull=True).count(),
|
||||||
|
'shared':0})
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def create_folder(request):
|
def create_folder(request):
|
||||||
try:
|
try:
|
||||||
|
space=resolve_space(request.user,request.data.get('space'),write=True)
|
||||||
name=valid_name(request.data.get('name'))
|
name=valid_name(request.data.get('name'))
|
||||||
parent=target_parent(request.data.get('parent'),request.user,request.user.pk)
|
parent=target_parent(request.data.get('parent'),request.user,space)
|
||||||
except ValueError as ex: return error(str(ex))
|
except ValueError as ex:return error(str(ex))
|
||||||
except PermissionError as ex: return error(str(ex),403)
|
except PermissionError as ex:return error(str(ex),403)
|
||||||
folder=DriveItem(owner=request.user,parent=parent,name=name,is_folder=True)
|
folder=DriveItem(owner=request.user,space=space,parent=parent,name=name,is_folder=True)
|
||||||
folder.full_clean()
|
folder.full_clean();folder.save()
|
||||||
folder.save()
|
return Response(item_info(folder,request.user),status=201)
|
||||||
return Response(item_info(folder),status=201)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(['PATCH'])
|
@api_view(['PATCH'])
|
||||||
@ -92,24 +101,18 @@ def update_item(request,item_id):
|
|||||||
if 'name' in data:
|
if 'name' in data:
|
||||||
try:item.name=valid_name(data['name'])
|
try:item.name=valid_name(data['name'])
|
||||||
except ValueError as ex:return error(str(ex))
|
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:return error('Le partage global est désactivé : utilise les espaces et invitations.')
|
||||||
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 'parent' in data:
|
if 'parent' in data:
|
||||||
try:
|
try:new_parent=target_parent(data['parent'],request.user,item.space)
|
||||||
new_parent=target_parent(data['parent'],request.user,item.owner_id)
|
|
||||||
except ValueError as ex:return error(str(ex))
|
except ValueError as ex:return error(str(ex))
|
||||||
except PermissionError as ex:return error(str(ex),403)
|
except PermissionError as ex:return error(str(ex),403)
|
||||||
if new_parent:
|
if new_parent and (new_parent.id==item.id or item.id in [n.id for n in chain(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.')
|
||||||
return error('Déplacement impossible dans un sous-dossier de cet élément.')
|
|
||||||
item.parent=new_parent
|
item.parent=new_parent
|
||||||
if new_parent:item.is_shared=False
|
|
||||||
try:item.full_clean()
|
try:item.full_clean()
|
||||||
except ValidationError as ex:return error(str(ex))
|
except ValidationError as ex:return error(str(ex))
|
||||||
item.save()
|
item.save()
|
||||||
return Response(item_info(item))
|
return Response(item_info(item,request.user))
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
@ -126,11 +129,10 @@ def restore_item(request,item_id):
|
|||||||
item=get_item(item_id)
|
item=get_item(item_id)
|
||||||
if not writable(item,request.user):return error('Accès refusé.',403)
|
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.')
|
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)):
|
if item.parent and any(n.trashed_at for n in chain(item.parent)):
|
||||||
item.parent=None
|
item.parent=None
|
||||||
item.trashed_at=None;item.save()
|
item.trashed_at=None;item.save()
|
||||||
return Response(item_info(item))
|
return Response(item_info(item,request.user))
|
||||||
|
|
||||||
|
|
||||||
@api_view(['DELETE'])
|
@api_view(['DELETE'])
|
||||||
@ -142,8 +144,9 @@ def delete_item(request,item_id):
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
item.delete()
|
item.delete()
|
||||||
def remove_files():
|
def remove_files():
|
||||||
|
root=Path(settings.DRIVE_STORAGE_ROOT).resolve()
|
||||||
for key in files:
|
for key in files:
|
||||||
path=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key
|
path=(root/key).resolve()
|
||||||
path.unlink(missing_ok=True)
|
if path.is_relative_to(root):path.unlink(missing_ok=True)
|
||||||
transaction.on_commit(remove_files)
|
transaction.on_commit(remove_files)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|||||||
358
backend/drive/views_spaces.py
Normal file
358
backend/drive/views_spaces.py
Normal file
@ -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))
|
||||||
@ -11,13 +11,16 @@ from rest_framework.parsers import MultiPartParser, FormParser
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import DriveItem, UploadSession
|
from .models import DriveItem, UploadSession
|
||||||
from .services import error, item_info, target_parent, valid_name, readable, writable
|
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'])
|
@api_view(['POST'])
|
||||||
@parser_classes([MultiPartParser,FormParser])
|
@parser_classes([MultiPartParser,FormParser])
|
||||||
def upload(request):
|
def upload(request):
|
||||||
try:
|
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 ValueError as ex: return error(str(ex))
|
||||||
except PermissionError as ex: return error(str(ex),403)
|
except PermissionError as ex: return error(str(ex),403)
|
||||||
incoming=request.FILES.getlist('files')
|
incoming=request.FILES.getlist('files')
|
||||||
@ -29,31 +32,35 @@ def upload(request):
|
|||||||
saved=[]
|
saved=[]
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
for f in incoming:
|
with locked_quota_tree(space):
|
||||||
name=valid_name(f.name)
|
# Include the sum of ALL files in the same request. The lock prevents
|
||||||
key=str(uuid.uuid4())
|
# another simultaneous request from consuming these bytes meanwhile.
|
||||||
dst=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key
|
assert_space_capacity(space, sum(f.size for f in incoming))
|
||||||
dst.parent.mkdir(parents=True,exist_ok=True)
|
for f in incoming:
|
||||||
with open(dst,'xb') as out:
|
name=valid_name(f.name)
|
||||||
for block in f.chunks(chunk_size=2*1024*1024):
|
key=str(uuid.uuid4())
|
||||||
out.write(block)
|
location=new_storage_location(space,key)
|
||||||
saved.append(dst)
|
dst=Path(settings.DRIVE_STORAGE_ROOT)/location
|
||||||
mime=mimetypes.guess_type(name)[0] or 'application/octet-stream'
|
dst.parent.mkdir(parents=True,exist_ok=True)
|
||||||
node=DriveItem.objects.create(owner=request.user,parent=parent,name=name,
|
with open(dst,'xb') as out:
|
||||||
storage_key=key,size=f.size,mime_type=mime)
|
for block in f.chunks(chunk_size=2*1024*1024):
|
||||||
# Keep a response prepared even if the transaction is rolled back.
|
out.write(block)
|
||||||
saved[-1] = (dst,node)
|
saved.append(dst)
|
||||||
except (ValueError,OSError) as ex:
|
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:
|
for entry in saved:
|
||||||
path=entry[0] if isinstance(entry,tuple) else entry
|
path=entry[0] if isinstance(entry,tuple) else entry
|
||||||
path.unlink(missing_ok=True)
|
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:
|
except Exception:
|
||||||
for entry in saved:
|
for entry in saved:
|
||||||
path=entry[0] if isinstance(entry,tuple) else entry
|
path=entry[0] if isinstance(entry,tuple) else entry
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
raise
|
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.
|
# Large uploads are chunked in 8 MiB pieces; a refresh does not restart the upload.
|
||||||
@ -62,13 +69,18 @@ def upload_start(request):
|
|||||||
try:
|
try:
|
||||||
name=valid_name(request.data.get('name'))
|
name=valid_name(request.data.get('name'))
|
||||||
size=int(request.data.get('size',-1))
|
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 (TypeError,ValueError) as ex:return error(str(ex))
|
||||||
except PermissionError as ex:return error(str(ex),403)
|
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 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:
|
with transaction.atomic():
|
||||||
return error('Trop d’imports en cours (20 maximum).',429)
|
with locked_quota_tree(space):
|
||||||
job=UploadSession.objects.create(owner=request.user,parent=parent,name=name,total_size=size)
|
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)
|
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)
|
src.touch(exist_ok=True)
|
||||||
if not src.exists() or src.stat().st_size!=job.total_size:
|
if not src.exists() or src.stat().st_size!=job.total_size:
|
||||||
return error('Le fichier temporaire est incomplet.')
|
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)
|
return error('Dossier de destination indisponible.',403)
|
||||||
key=str(uuid.uuid4())
|
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)
|
dst.parent.mkdir(parents=True,exist_ok=True)
|
||||||
os.replace(src,dst)
|
os.replace(src,dst)
|
||||||
mime=mimetypes.guess_type(job.name)[0] or 'application/octet-stream'
|
mime=mimetypes.guess_type(job.name)[0] or 'application/octet-stream'
|
||||||
try:
|
try:
|
||||||
node=DriveItem.objects.create(owner=request.user,parent=job.parent,name=job.name,
|
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)
|
size=job.total_size,mime_type=mime,storage_key=key,storage_location=location)
|
||||||
job.delete()
|
job.delete()
|
||||||
except Exception:
|
except Exception:
|
||||||
os.replace(dst,src)
|
os.replace(dst,src)
|
||||||
raise
|
raise
|
||||||
return Response(item_info(node),status=201)
|
return Response(item_info(node,request.user),status=201)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['DELETE'])
|
@api_view(['DELETE'])
|
||||||
def upload_cancel(request,upload_id):
|
def upload_cancel(request,upload_id):
|
||||||
try:job=UploadSession.objects.get(pk=upload_id,owner=request.user)
|
# Serialize cancellation with chunk writes and completion. Without the lock
|
||||||
except UploadSession.DoesNotExist:raise Http404
|
# a concurrently arriving chunk could recreate the temp file after delete.
|
||||||
path=Path(settings.FILE_UPLOAD_TEMP_DIR)/f'drive-{job.pk}.part'
|
with transaction.atomic():
|
||||||
job.delete()
|
try:
|
||||||
path.unlink(missing_ok=True)
|
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)
|
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
|
||||||
|
]})
|
||||||
|
|||||||
@ -3,11 +3,15 @@ import { LoaderCircle } from 'lucide-react'
|
|||||||
import { api, logoutTokens, savedToken } from '@/api/client'
|
import { api, logoutTokens, savedToken } from '@/api/client'
|
||||||
import DrivePage from '@/pages/DrivePage'
|
import DrivePage from '@/pages/DrivePage'
|
||||||
import LoginPage from '@/pages/LoginPage'
|
import LoginPage from '@/pages/LoginPage'
|
||||||
|
import InvitationPage from '@/pages/InvitationPage'
|
||||||
|
|
||||||
/** Application entry point: only restores the session and selects the screen. */
|
/** Application entry point: only restores the session and selects the screen. */
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const invitationToken = window.location.pathname.match(/^\/invite\/([A-Za-z0-9_-]+)\/?$/)?.[1]
|
||||||
const [user,setUser] = useState(null)
|
const [user,setUser] = useState(null)
|
||||||
const [checking,setChecking] = useState(true)
|
const [checking,setChecking] = useState(true)
|
||||||
|
const [connectionError,setConnectionError] = useState(false)
|
||||||
|
const [attempt,setAttempt] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
@ -15,16 +19,25 @@ export default function App() {
|
|||||||
if(!savedToken()) {setChecking(false); return}
|
if(!savedToken()) {setChecking(false); return}
|
||||||
try {
|
try {
|
||||||
const data = (await api.get('/auth/me/')).data
|
const data = (await api.get('/auth/me/')).data
|
||||||
if(active) setUser(data)
|
if(active) { setUser(data); setConnectionError(false) }
|
||||||
} catch { logoutTokens() }
|
} 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) }
|
finally { if(active) setChecking(false) }
|
||||||
}
|
}
|
||||||
probe()
|
probe()
|
||||||
const expired = () => {setUser(null);setChecking(false)}
|
const expired = () => {setUser(null);setChecking(false)}
|
||||||
window.addEventListener('drive:logout', expired)
|
window.addEventListener('drive:logout', expired)
|
||||||
return () => {active = false;window.removeEventListener('drive:logout', expired)}
|
return () => {active = false;window.removeEventListener('drive:logout', expired)}
|
||||||
}, [])
|
}, [attempt])
|
||||||
|
|
||||||
|
if(invitationToken) return <InvitationPage token={invitationToken} />
|
||||||
|
if(connectionError) return <div className="grid min-h-dvh place-items-center p-6 text-center"><div className="space-y-4"><p>GoodDrive est momentanément inaccessible. Ta session est conservée.</p><button type="button" className="rounded-lg bg-primary px-4 py-2 text-primary-foreground" onClick={() => {setConnectionError(false);setChecking(true);setAttempt(n=>n+1)}}>Réessayer</button></div></div>
|
||||||
if(checking) return <div className="grid h-dvh place-items-center"><LoaderCircle className="size-7 animate-spin text-primary" /></div>
|
if(checking) return <div className="grid h-dvh place-items-center"><LoaderCircle className="size-7 animate-spin text-primary" /></div>
|
||||||
return user
|
return user
|
||||||
? <DrivePage user={user} onLogout={() => {logoutTokens();setUser(null)}} />
|
? <DrivePage user={user} onLogout={() => {logoutTokens();setUser(null)}} />
|
||||||
|
|||||||
@ -1,38 +1,105 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
const ACCESS='drive_access', REFRESH='drive_refresh'
|
const ACCESS = 'drive_access', REFRESH = 'drive_refresh'
|
||||||
export const api = axios.create({
|
// Single origin for JSON, previews, videos, and token renewal.
|
||||||
baseURL: import.meta.env?.PROD ? '/api' : `http://${window.location.hostname}:1029/api`,
|
export const api = axios.create({baseURL:'/api', headers:{Accept:'application/json'}})
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
const raw = axios.create({baseURL:'/api', headers:{Accept:'application/json'}})
|
||||||
});
|
export const savedToken = () => sessionStorage.getItem(ACCESS)
|
||||||
const raw=axios.create({baseURL:'/api'})
|
export const storeTokens = data => {
|
||||||
export const savedToken=()=>sessionStorage.getItem(ACCESS)
|
if (data.access) sessionStorage.setItem(ACCESS,data.access)
|
||||||
export const storeTokens=(data)=>{sessionStorage.setItem(ACCESS,data.access);if(data.refresh)sessionStorage.setItem(REFRESH,data.refresh)}
|
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})
|
export const logoutTokens = () => {
|
||||||
let refreshPromise=null
|
sessionStorage.removeItem(ACCESS)
|
||||||
api.interceptors.response.use(r=>r,async error=>{
|
sessionStorage.removeItem(REFRESH)
|
||||||
const original=error.config
|
}
|
||||||
if(error.response?.status!==401 || original?._retry || original?.url?.startsWith('/auth/'))return Promise.reject(error)
|
api.interceptors.request.use(config => {
|
||||||
const refresh=sessionStorage.getItem(REFRESH)
|
const token = savedToken()
|
||||||
if(!refresh){logoutTokens();window.dispatchEvent(new Event('drive:logout'));return Promise.reject(error)}
|
if (token && !config.url?.startsWith('/invitations/')) {
|
||||||
original._retry=true
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
try{
|
}
|
||||||
if(!refreshPromise)refreshPromise=raw.post('/auth/refresh/',{refresh}).then(r=>r.data.access).finally(()=>{refreshPromise=null})
|
return config
|
||||||
const access=await refreshPromise
|
})
|
||||||
sessionStorage.setItem(ACCESS,access)
|
let refreshPromise = null
|
||||||
original.headers.Authorization=`Bearer ${access}`
|
export async function refreshAccess() {
|
||||||
return api(original)
|
const token = sessionStorage.getItem(REFRESH)
|
||||||
}catch(e){logoutTokens();window.dispatchEvent(new Event('drive:logout'));return Promise.reject(e)}
|
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={
|
export const drive={
|
||||||
list:params=>api.get('/drive/items/',{params}).then(r=>r.data),
|
list:params=>api.get('/drive/items/',{params}).then(r=>r.data),
|
||||||
stats:()=>api.get('/drive/stats/').then(r=>r.data),
|
stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data),
|
||||||
folder:(name,parent)=>api.post('/drive/folders/',{name,parent}).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),
|
patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data),
|
||||||
trash:id=>api.post(`/drive/items/${id}/trash/`),
|
trash:id=>api.post(`/drive/items/${id}/trash/`),
|
||||||
restore:id=>api.post(`/drive/items/${id}/restore/`),
|
restore:id=>api.post(`/drive/items/${id}/restore/`),
|
||||||
remove:id=>api.delete(`/drive/items/${id}/delete/`),
|
remove:id=>api.delete(`/drive/items/${id}/delete/`),
|
||||||
link:(id,inline=false)=>api.post(`/drive/items/${id}/link/`,{inline}).then(r=>r.data.url),
|
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(offset<file.size){
|
||||||
|
const part=file.slice(offset,Math.min(offset+chunkSize,file.size))
|
||||||
|
const data=new FormData();data.append('offset',offset);data.append('part',part,file.name)
|
||||||
|
const result=await api.post(`/drive/uploads/${started.id}/chunk/`,data,{timeout:0})
|
||||||
|
offset=result.data.offset;progress?.(Math.round(100*offset/file.size))
|
||||||
|
}
|
||||||
|
return (await api.post(`/drive/uploads/${started.id}/finish/`,{}, {timeout:0})).data
|
||||||
|
} catch(error){throw error}
|
||||||
|
},
|
||||||
|
// Transfer-control API. Every file, including small files, can pause/resume/cancel.
|
||||||
|
startUpload:(file,parent,space)=>api.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.'}
|
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.'}
|
||||||
|
|||||||
65
frontend/src/components/drive/ContextActions.jsx
Normal file
65
frontend/src/components/drive/ContextActions.jsx
Normal file
@ -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(<div ref={node} role="menu" aria-label="Actions"
|
||||||
|
className="fixed z-[100] w-[236px] rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-xl"
|
||||||
|
style={{left:x,top:y}}>
|
||||||
|
{menu.options.map(option => <button key={option.id} type="button" role="menuitem"
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-none hover:bg-accent focus:bg-accent ${option.danger ? 'text-destructive' : ''}`}
|
||||||
|
onClick={() => { onClose(); onChoose(option.id) }}>
|
||||||
|
<option.icon className="size-4 shrink-0" />{option.title}
|
||||||
|
</button>)}
|
||||||
|
</div>,document.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mobileDots(event, show, options) {
|
||||||
|
event.stopPropagation()
|
||||||
|
const rect=event.currentTarget.getBoundingClientRect()
|
||||||
|
show({ x:rect.right, y:rect.bottom+4, options })
|
||||||
|
}
|
||||||
@ -2,6 +2,8 @@ import ItemFormDialog from './dialogs/ItemFormDialog'
|
|||||||
import ItemActionsDialog from './dialogs/ItemActionsDialog'
|
import ItemActionsDialog from './dialogs/ItemActionsDialog'
|
||||||
import DeleteDialog from './dialogs/DeleteDialog'
|
import DeleteDialog from './dialogs/DeleteDialog'
|
||||||
import PreviewDialog from './dialogs/PreviewDialog'
|
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. */
|
/** Groups the Drive's existing dialogs without coupling them to the page markup. */
|
||||||
export default function DriveDialogs({ controller, user }) {
|
export default function DriveDialogs({ controller, user }) {
|
||||||
@ -10,5 +12,7 @@ export default function DriveDialogs({ controller, user }) {
|
|||||||
<ItemActionsDialog controller={controller} user={user} />
|
<ItemActionsDialog controller={controller} user={user} />
|
||||||
<DeleteDialog controller={controller} />
|
<DeleteDialog controller={controller} />
|
||||||
<PreviewDialog controller={controller} />
|
<PreviewDialog controller={controller} />
|
||||||
|
<SpaceSettingsDialog controller={controller} />
|
||||||
|
<MembersDialog controller={controller} user={user} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,37 @@
|
|||||||
import React from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import {
|
import {
|
||||||
ChevronRight, CloudUpload, FolderOpen, Grid2X2, List as ListIcon,
|
ChevronRight, FolderOpen, Grid2X2, List as ListIcon,
|
||||||
LoaderCircle, Search, Upload,
|
LoaderCircle, Search, Upload,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import FileList from './FileList'
|
import FileList from './FileList'
|
||||||
import FileGrid from './FileGrid'
|
import FileGrid from './FileGrid'
|
||||||
import Banner from './StatusBanner'
|
import Banner from './StatusBanner'
|
||||||
import { headerNames } from '@/config/navigation'
|
import { headerNames } from '@/config/navigation'
|
||||||
|
import ContextActions, {fileOptions} from './ContextActions'
|
||||||
|
|
||||||
export default function DriveExplorer({ controller, user }) {
|
export default function DriveExplorer({ controller, user }) {
|
||||||
const {
|
const {
|
||||||
view, setParent, trail, items, stats, query, setQuery, search, loading,
|
view, setParent, trail, items, stats, query, setQuery, search, loading,
|
||||||
error, setError, note, setNote, layout, setLayout, sort, setSort,
|
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
|
} = 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
|
const title = search
|
||||||
? `« ${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 (
|
return (
|
||||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4 pt-6 sm:px-7 sm:pb-7">
|
<section className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4 pt-6 sm:px-7 sm:pb-7">
|
||||||
@ -32,7 +43,9 @@ export default function DriveExplorer({ controller, user }) {
|
|||||||
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<button onClick={goRoot} className="rounded px-1 py-1 hover:bg-muted">Mes fichiers</button>
|
{isTrash
|
||||||
|
? <span className="px-1">Tous les espaces accessibles</span>
|
||||||
|
: <button onClick={goRoot} className="rounded px-1 py-1 hover:bg-muted">{activeSpace?.is_private ? 'Mes fichiers' : activeSpace?.display_name || 'Mes fichiers'}</button>}
|
||||||
{view === 'files' && !search && trail.map(folder => (
|
{view === 'files' && !search && trail.map(folder => (
|
||||||
<React.Fragment key={folder.id}>
|
<React.Fragment key={folder.id}>
|
||||||
<ChevronRight className="size-3.5" />
|
<ChevronRight className="size-3.5" />
|
||||||
@ -47,7 +60,7 @@ export default function DriveExplorer({ controller, user }) {
|
|||||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{loading ? 'Chargement…' : `${items.length} élément${items.length > 1 ? 's' : ''}`}
|
{loading ? 'Chargement…' : `${items.length} élément${items.length > 1 ? 's' : ''}`}
|
||||||
{view === 'shared' ? ' · accessible aux membres connectés' : ''}
|
{!canWrite ? ' · lecture seule' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -72,20 +85,6 @@ export default function DriveExplorer({ controller, user }) {
|
|||||||
{error && <div className="mb-3"><Banner text={error} close={() => setError('')} /></div>}
|
{error && <div className="mb-3"><Banner text={error} close={() => setError('')} /></div>}
|
||||||
{note && <div className="mb-3"><Banner text={note} close={() => setNote('')} /></div>}
|
{note && <div className="mb-3"><Banner text={note} close={() => setNote('')} /></div>}
|
||||||
|
|
||||||
{uploadState && (
|
|
||||||
<div className="mb-4 rounded-xl border bg-card p-3">
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-2 text-sm">
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
<CloudUpload className="mr-2 inline size-4 text-primary" /> Import de {uploadState.names}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{uploadState.percent === null ? 'En cours…' : `${uploadState.percent}%`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={uploadState.percent ?? 0} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card shadow-sm">
|
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card shadow-sm">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full min-h-72 items-center justify-center gap-3 text-sm text-muted-foreground">
|
<div className="flex h-full min-h-72 items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||||
@ -105,22 +104,24 @@ export default function DriveExplorer({ controller, user }) {
|
|||||||
: search ? 'Essaie un autre nom de fichier.'
|
: search ? 'Essaie un autre nom de fichier.'
|
||||||
: 'Importe tes premiers fichiers ou crée un dossier pour commencer à organiser ton espace.'}
|
: 'Importe tes premiers fichiers ou crée un dossier pour commencer à organiser ton espace.'}
|
||||||
</p>
|
</p>
|
||||||
{!isTrash && !search && (
|
{!isTrash && !search && canWrite && (
|
||||||
<Button className="mt-5" onClick={() => filesInput.current?.click()}>
|
<Button className="mt-5" onClick={() => filesInput.current?.click()}>
|
||||||
<Upload /> Importer des fichiers
|
<Upload /> Importer des fichiers
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : layout === 'list' ? (
|
) : layout === 'list' ? (
|
||||||
<FileList items={display} user={user} onOpen={openEntry} onActions={startAction} />
|
<FileList items={display} user={user} onOpen={isTrash ? () => {} : openEntry} onActions={startAction} onContext={openContext} />
|
||||||
) : (
|
) : (
|
||||||
<FileGrid items={display} onOpen={openEntry} onActions={startAction} />
|
<FileGrid items={display} onOpen={isTrash ? () => {} : openEntry} onActions={startAction} onContext={openContext} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContextActions menu={context && {...context, options:fileOptions(context.item,isTrash)}} onClose={closeContext}
|
||||||
|
onChoose={type => performItemAction(context.item,type)} />
|
||||||
<footer className="flex justify-between gap-2 px-1 pt-3 text-[11px] text-muted-foreground">
|
<footer className="flex justify-between gap-2 px-1 pt-3 text-[11px] text-muted-foreground">
|
||||||
<span>Les fichiers restent sur ton propre serveur.</span>
|
<span className="hidden md:inline">Clic droit sur un élément pour les actions.</span><span className="md:hidden">Tes fichiers restent sur ton serveur.</span>
|
||||||
<span>{stats ? `${stats.folders} dossiers · ${stats.files} fichiers` : ''}</span>
|
<span>{!isTrash && stats ? `${stats.folders} dossiers · ${stats.files} fichiers` : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,10 +1,51 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { sections } from '@/config/navigation'
|
||||||
import { fmtSize } from '@/lib/drive-format'
|
import { fmtSize } from '@/lib/drive-format'
|
||||||
|
|
||||||
export default function DriveSidebar({ controller, user, onLogout }) {
|
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 = () => {
|
const newFolder = () => {
|
||||||
setValue('')
|
setValue('')
|
||||||
@ -19,18 +60,39 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
|||||||
<HardDrive className="size-6" />
|
<HardDrive className="size-6" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-bold tracking-tight">Bocage Drive</div>
|
<div className="text-lg font-bold tracking-tight">GoodDrive</div>
|
||||||
<div className="text-xs text-muted-foreground">Mon espace de stockage</div>
|
<div className="text-xs text-muted-foreground">Un Drive qui se partage</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 pb-6 pt-3">
|
<div className="px-4 pb-6 pt-3">
|
||||||
<Button onClick={newFolder} className="h-11 w-full justify-start gap-2 rounded-xl shadow-sm">
|
<Button onClick={newFolder} disabled={!canWrite || view==='trash'} className="h-11 w-full justify-start gap-2 rounded-xl shadow-sm">
|
||||||
<FolderPlus className="size-5" /> Nouveau dossier
|
<FolderPlus className="size-5" /> Nouveau dossier
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3">
|
<div className="min-h-0 overflow-y-auto px-3 pb-4">
|
||||||
|
{personal && <button type="button" onClick={()=>switchSpace(personal.id)}
|
||||||
|
className={`mb-4 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold ${activeSpaceId===personal.id ? 'bg-accent text-primary' : 'text-muted-foreground hover:bg-muted'}`}>
|
||||||
|
<FolderLock className="size-[19px] shrink-0"/><span className="truncate">Mes fichiers</span>
|
||||||
|
</button>}
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2 px-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">Espaces partagés</p>
|
||||||
|
{manageable.length>0 && <Button type="button" size="icon-sm" variant="ghost" title="Créer un espace partagé" aria-label="Créer un espace partagé" onClick={createSpace}><Plus className="size-4" /></Button>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 pb-4">
|
||||||
|
{shared.map(space => <div key={space.id} className={`group flex min-h-10 items-center rounded-xl ${activeSpaceId===space.id ? 'bg-accent text-primary font-semibold':'text-muted-foreground hover:bg-muted'}`}
|
||||||
|
onContextMenu={event=>{if(space.can_invite){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}>
|
||||||
|
<button type="button" onClick={()=>switchSpace(space.id)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm">
|
||||||
|
{space.can_write ? <FolderOpen className="size-4 shrink-0"/>:<FolderLock className="size-4 shrink-0"/>}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{space.display_name}</span>
|
||||||
|
</button>
|
||||||
|
{space.can_invite && <Button type="button" variant="ghost" size="icon-sm" title="Gérer l’espace"
|
||||||
|
aria-label={`Gérer ${space.display_name}`} className="mr-1 shrink-0 md:hidden"
|
||||||
|
onClick={event=>mobileDots(event, data=>setSpaceMenu({space,...data}),spaceOptions(space))}><MoreHorizontal className="size-4" /></Button>}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
<p className="mb-3 px-3 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
<p className="mb-3 px-3 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
Explorer
|
Explorer
|
||||||
</p>
|
</p>
|
||||||
@ -57,21 +119,43 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
|||||||
<div className="rounded-2xl border bg-muted/45 p-4">
|
<div className="rounded-2xl border bg-muted/45 p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<span className="flex items-center gap-2 text-xs font-semibold">
|
<span className="flex items-center gap-2 text-xs font-semibold">
|
||||||
<Database className="size-4 text-primary" /> Mon stockage
|
<Database className="size-4 text-primary" /> Stockage de l’espace
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted-foreground">{stats?.files ?? '—'} fichiers</span>
|
<span className="text-[11px] text-muted-foreground">{stats?.files ?? '—'} fichiers</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-bold">{fmtSize(stats?.bytes || 0)}</div>
|
<div className="text-xl font-bold">{fmtSize(usedBytes)} <span className="text-xs font-normal text-muted-foreground">{stats?.quota_bytes == null ? (freeBytes == null ? ' · sans limite' : ` / ${fmtSize(freeBytes)} restants du parent`) : ` / ${fmtSize(allocated)}`}</span></div>
|
||||||
<p className="mt-1 text-[11px] text-muted-foreground">Espace utilisé par tes documents actifs</p>
|
{percent != null && <div className="mt-3 h-2 overflow-hidden rounded-full bg-border" role="progressbar" aria-label="Stockage utilisé" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(percent)}>
|
||||||
|
<div className={`h-full rounded-full transition-all ${percent >= 95 ? 'bg-destructive' : 'bg-primary'}`} style={{width:`${percent}%`}} />
|
||||||
|
</div>}
|
||||||
|
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||||
|
{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)`}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-y-0.5 text-[11px] text-muted-foreground">
|
||||||
|
<p>Fichiers actifs : {fmtSize(activeBytes)}</p>
|
||||||
|
{trashBytes > 0 && <p>Corbeille : {fmtSize(trashBytes)} (vider pour libérer)</p>}
|
||||||
|
{reservedBytes > 0 && <p>Transferts réservés : {fmtSize(reservedBytes)} (annuler pour libérer)</p>}
|
||||||
|
<p className="text-[10px]">Sous-espaces inclus. Le compteur correspond à l’espace réellement occupé.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{orphaned.length > 0 && <div className="rounded-xl border p-3 text-xs">
|
||||||
|
<p className="mb-2 font-semibold">Imports non terminés ({orphaned.length})</p>
|
||||||
|
<p className="mb-2 text-muted-foreground">Ils réservent encore du stockage. Si tu as actualisé la page, annule ceux que tu ne souhaites plus reprendre.</p>
|
||||||
|
<div className="max-h-28 space-y-1 overflow-y-auto">
|
||||||
|
{orphaned.map(job => <div key={job.id} className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={job.name}>{job.name} · {fmtSize(job.size)}</span>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Annuler l'import ${job.name}`} title="Annuler et libérer la place" onClick={() => cancelPendingUpload(job.id)}><X className="size-3.5" /></Button>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 rounded-xl px-2 py-1">
|
<div className="flex items-center justify-between gap-2 rounded-xl px-2 py-1">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-secondary font-semibold text-primary">
|
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-secondary font-semibold text-primary">
|
||||||
{user.username.slice(0, 1).toUpperCase()}
|
{user.username.slice(0, 1).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-sm font-semibold">{user.first_name || user.username}</span>
|
<span className="block truncate text-sm font-semibold">{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username}</span>
|
||||||
<span className="block text-[11px] text-muted-foreground">
|
<span className="block text-[11px] text-muted-foreground">
|
||||||
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
|
{user.is_admin ? 'Administrateur' : 'Utilisateur'}
|
||||||
</span>
|
</span>
|
||||||
@ -82,6 +166,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ContextActions menu={spaceMenu} onClose={closeSpaceMenu} onChoose={spaceCommand} />
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Menu, Moon, RefreshCcw, Search, Sun, Upload } from 'lucide-react'
|
|||||||
import { headerNames } from '@/config/navigation'
|
import { headerNames } from '@/config/navigation'
|
||||||
|
|
||||||
export default function DriveToolbar({ controller }) {
|
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()
|
const openPicker = () => filesInput.current?.click()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -15,7 +15,7 @@ export default function DriveToolbar({ controller }) {
|
|||||||
</Button>
|
</Button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-base font-semibold sm:text-lg">
|
<div className="text-base font-semibold sm:text-lg">
|
||||||
{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]}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden text-xs text-muted-foreground sm:block">Tous vos fichiers, au même endroit.</div>
|
<div className="hidden text-xs text-muted-foreground sm:block">Tous vos fichiers, au même endroit.</div>
|
||||||
</div>
|
</div>
|
||||||
@ -29,13 +29,13 @@ export default function DriveToolbar({ controller }) {
|
|||||||
<Button size="icon" variant="ghost" onClick={() => setDark(value => !value)} title="Changer le thème">
|
<Button size="icon" variant="ghost" onClick={() => setDark(value => !value)} title="Changer le thème">
|
||||||
{dark ? <Sun /> : <Moon />}
|
{dark ? <Sun /> : <Moon />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="icon" variant="ghost" onClick={refresh} title="Actualiser">
|
<Button size="icon" variant="ghost" onClick={() => refresh()} title="Actualiser">
|
||||||
<RefreshCcw />
|
<RefreshCcw />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={openPicker} className="hidden sm:inline-flex" disabled={isTrash}>
|
<Button onClick={openPicker} className="hidden sm:inline-flex" disabled={isTrash||!canWrite}>
|
||||||
<Upload /> Importer
|
<Upload /> Importer
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={openPicker} size="icon" className="sm:hidden" disabled={isTrash} aria-label="Importer">
|
<Button onClick={openPicker} size="icon" className="sm:hidden" disabled={isTrash||!canWrite} aria-label="Importer">
|
||||||
<Upload />
|
<Upload />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,19 +3,21 @@ import { MoreHorizontal } from 'lucide-react'
|
|||||||
import FileGlyph from './FileGlyph'
|
import FileGlyph from './FileGlyph'
|
||||||
import { fmtSize } from '@/lib/drive-format'
|
import { fmtSize } from '@/lib/drive-format'
|
||||||
|
|
||||||
export default function FileGrid({ items, onOpen, onActions }) {
|
export default function FileGrid({ items, onOpen, onActions, onContext }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 p-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
<div className="grid grid-cols-2 gap-3 p-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<button
|
<div
|
||||||
key={item.id}
|
key={item.id} role="button" tabIndex={0}
|
||||||
className="group relative flex min-w-0 flex-col items-start gap-3 rounded-2xl border p-3 text-left transition hover:border-primary/40 hover:bg-muted/50"
|
className="group relative flex min-w-0 flex-col items-start gap-3 rounded-2xl border p-3 text-left transition hover:border-primary/40 hover:bg-muted/50"
|
||||||
onClick={() => onOpen(item)}
|
onClick={() => onOpen(item)}
|
||||||
|
onKeyDown={event => {if(event.target===event.currentTarget && (event.key==='Enter'||event.key===' ')){event.preventDefault();onOpen(item)}}}
|
||||||
|
onContextMenu={event => {event.preventDefault();onContext(item,event.clientX,event.clientY)}}
|
||||||
>
|
>
|
||||||
<FileGlyph item={item} size="lg" />
|
<FileGlyph item={item} size="lg" />
|
||||||
<span className="block w-full truncate text-sm font-medium">{item.name}</span>
|
<span className="block w-full truncate text-sm font-medium">{item.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">{item.is_folder ? 'Dossier' : fmtSize(item.size)}</span>
|
<span className="text-xs text-muted-foreground">{item.is_folder ? 'Dossier' : fmtSize(item.size)}{item.space_name ? ` · ${item.space_name}` : ''}</span>
|
||||||
<span className="absolute right-2 top-2">
|
<span className="absolute right-2 top-2 md:hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="icon-sm" title="Actions" aria-label={`Actions pour ${item.name}`}
|
variant="ghost" size="icon-sm" title="Actions" aria-label={`Actions pour ${item.name}`}
|
||||||
onClick={event => { event.stopPropagation(); onActions(item, 'actions') }}
|
onClick={event => { event.stopPropagation(); onActions(item, 'actions') }}
|
||||||
@ -23,7 +25,7 @@ export default function FileGrid({ items, onOpen, onActions }) {
|
|||||||
<MoreHorizontal />
|
<MoreHorizontal />
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { MoreHorizontal, Star, Users } from 'lucide-react'
|
import { MoreHorizontal, Users } from 'lucide-react'
|
||||||
import FileGlyph from './FileGlyph'
|
import FileGlyph from './FileGlyph'
|
||||||
import { fmtDate, fmtSize } from '@/lib/drive-format'
|
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 (
|
return (
|
||||||
<table className="w-full table-fixed text-sm">
|
<table className="w-full table-fixed text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@ -11,7 +11,7 @@ export default function FileList({ items, user, onOpen, onActions }) {
|
|||||||
<th className="w-[54%] px-4 py-3 font-medium sm:px-6">Nom</th>
|
<th className="w-[54%] px-4 py-3 font-medium sm:px-6">Nom</th>
|
||||||
<th className="hidden w-[20%] px-4 py-3 font-medium md:table-cell">Modifié le</th>
|
<th className="hidden w-[20%] px-4 py-3 font-medium md:table-cell">Modifié le</th>
|
||||||
<th className="hidden w-[17%] px-4 py-3 font-medium sm:table-cell">Taille</th>
|
<th className="hidden w-[17%] px-4 py-3 font-medium sm:table-cell">Taille</th>
|
||||||
<th className="w-[58px] px-2 py-3" />
|
<th className="w-[58px] px-2 py-3 md:hidden" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -20,6 +20,7 @@ export default function FileList({ items, user, onOpen, onActions }) {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="drive-file group cursor-pointer border-b border-border/70 transition-colors last:border-b-0 hover:bg-muted/55"
|
className="drive-file group cursor-pointer border-b border-border/70 transition-colors last:border-b-0 hover:bg-muted/55"
|
||||||
onClick={() => onOpen(item)}
|
onClick={() => onOpen(item)}
|
||||||
|
onContextMenu={event => {event.preventDefault();onContext(item,event.clientX,event.clientY)}}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 sm:px-6">
|
<td className="px-4 py-3 sm:px-6">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
@ -28,8 +29,8 @@ export default function FileList({ items, user, onOpen, onActions }) {
|
|||||||
<div className="truncate font-medium">{item.name}</div>
|
<div className="truncate font-medium">{item.name}</div>
|
||||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
{item.owner_id !== user.id && <span>{item.owner}</span>}
|
{item.owner_id !== user.id && <span>{item.owner}</span>}
|
||||||
|
{item.space_name && <span>· {item.space_name}</span>}
|
||||||
{item.is_shared && <span className="flex items-center gap-1"><Users className="size-3" /> Partagé</span>}
|
{item.is_shared && <span className="flex items-center gap-1"><Users className="size-3" /> Partagé</span>}
|
||||||
{item.is_starred && <Star className="size-3 fill-amber-400 text-amber-400" />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -38,7 +39,7 @@ export default function FileList({ items, user, onOpen, onActions }) {
|
|||||||
<td className="hidden px-4 py-3 text-muted-foreground sm:table-cell">
|
<td className="hidden px-4 py-3 text-muted-foreground sm:table-cell">
|
||||||
{item.is_folder ? '—' : fmtSize(item.size)}
|
{item.is_folder ? '—' : fmtSize(item.size)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-3 text-right">
|
<td className="px-2 py-3 text-right md:hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="icon-sm" title="Actions" aria-label={`Actions pour ${item.name}`}
|
variant="ghost" size="icon-sm" title="Actions" aria-label={`Actions pour ${item.name}`}
|
||||||
onClick={event => { event.stopPropagation(); onActions(item, 'actions') }}
|
onClick={event => { event.stopPropagation(); onActions(item, 'actions') }}
|
||||||
|
|||||||
18
frontend/src/components/drive/QuotaField.jsx
Normal file
18
frontend/src/components/drive/QuotaField.jsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
export default function QuotaField({ value, onChange, disabled = false, id = 'quota' }) {
|
||||||
|
const unlimited = value === ''
|
||||||
|
return <div className="space-y-2">
|
||||||
|
<label htmlFor={id} className="text-sm font-medium">Taille maximale de l’espace (Go)</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Input id={id} type="number" min="0" step="0.001" inputMode="decimal" placeholder="Ex. 10"
|
||||||
|
className="max-w-44" disabled={disabled || unlimited} value={value}
|
||||||
|
onChange={event => onChange(event.target.value)}/>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Checkbox checked={unlimited} disabled={disabled} onCheckedChange={checked => onChange(checked ? '' : '10')}/>
|
||||||
|
Sans limite dédiée
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">1 Go = 1 073 741 824 octets. Les limites des espaces parents restent applicables.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
76
frontend/src/components/drive/UploadTray.jsx
Normal file
76
frontend/src/components/drive/UploadTray.jsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { CheckCircle2, ChevronDown, ChevronUp, CloudUpload, Pause, Play, RotateCcw, Trash2, X } from 'lucide-react'
|
||||||
|
import { fmtSize } from '@/lib/drive-format'
|
||||||
|
import { currentTransferSpeed, formatDuration } from '@/lib/transfer-metrics'
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
queued: 'En attente', uploading: 'Transfert en cours', paused: 'En pause',
|
||||||
|
finishing: 'Enregistrement…', done: 'Terminé', canceled: 'Annulé', error: 'Échec',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadTray({ uploads }) {
|
||||||
|
const { jobs, paused, expanded, setExpanded, pause, resume, retry, cancel, clearFinished } = uploads
|
||||||
|
if (!jobs.length) return null
|
||||||
|
const visible = jobs.filter(job => job.status !== 'canceled')
|
||||||
|
const total = visible.reduce((sum, job) => sum + job.size, 0)
|
||||||
|
const transferred = visible.reduce((sum, job) => sum + Math.min(job.size, job.sent), 0)
|
||||||
|
const completed = jobs.filter(job => job.status === 'done').length
|
||||||
|
const unfinished = jobs.filter(job => !['done', 'canceled'].includes(job.status))
|
||||||
|
const pausable = unfinished.some(job => ['queued', 'uploading', 'paused', 'finishing'].includes(job.status))
|
||||||
|
const percent = total ? 100 * transferred / total : completed === visible.length ? 100 : 0
|
||||||
|
const now = Date.now()
|
||||||
|
const active = jobs.find(job => job.status === 'uploading')
|
||||||
|
const speed = active ? currentTransferSpeed(active, now) : 0
|
||||||
|
const remaining = visible.filter(job => ['queued', 'uploading', 'finishing'].includes(job.status)).reduce((sum, job) => sum + Math.max(0, job.size - job.sent), 0)
|
||||||
|
const overallEta = speed > 0 ? Math.ceil(remaining / speed) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside aria-label="Gestionnaire de transferts" className="fixed bottom-4 right-4 z-40 w-[min(440px,calc(100vw-2rem))] overflow-hidden rounded-2xl border bg-card shadow-2xl">
|
||||||
|
<div className="flex items-center gap-2 border-b p-3">
|
||||||
|
<CloudUpload className="size-5 shrink-0 text-primary" />
|
||||||
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setExpanded(v => !v)} aria-expanded={expanded}>
|
||||||
|
<span className="block font-semibold">Transferts · {completed}/{visible.length} terminés</span>
|
||||||
|
<span className="block text-xs text-muted-foreground" aria-live="polite">{fmtSize(transferred)} / {fmtSize(total)} · {Math.round(percent)} %</span>
|
||||||
|
{unfinished.length > 0 && <span className="block text-xs text-muted-foreground">{speed > 0 ? `${fmtSize(speed)}/s · ${formatDuration(overallEta)} restantes` : paused ? 'En pause' : jobs.some(job => job.status === 'finishing') ? 'Finalisation…' : 'Débit et durée en calcul…'}</span>}
|
||||||
|
</button>
|
||||||
|
{pausable && (paused ?
|
||||||
|
<Button size="sm" variant="outline" onClick={resume}><Play className="size-3.5" /> Reprendre</Button> :
|
||||||
|
<Button size="sm" variant="outline" onClick={pause}><Pause className="size-3.5" /> Pause</Button>
|
||||||
|
)}
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={() => setExpanded(v => !v)} aria-label={expanded ? 'Réduire' : 'Afficher les transferts'}>
|
||||||
|
{expanded ? <ChevronDown /> : <ChevronUp />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{expanded && <>
|
||||||
|
<div className="px-3 pt-3"><Progress value={percent} /></div>
|
||||||
|
<div className="max-h-[min(340px,50dvh)] overflow-y-auto px-3 pb-2 pt-3">
|
||||||
|
{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 <div className="mb-2 rounded-lg border bg-muted/30 p-3" key={job.id}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{job.status === 'done' ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-primary" /> : <CloudUpload className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium" title={job.name}>{job.name}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground" aria-live="polite">{labels[job.status]} · {current.toLocaleString('fr-FR')} / {job.size.toLocaleString('fr-FR')} octets</p>
|
||||||
|
{job.status === 'uploading' && <p className="mt-1 text-xs text-muted-foreground" aria-live="polite">{rate > 0 ? `${fmtSize(rate)}/s · ETA ${formatDuration(eta)}` : 'Débit et ETA en calcul…'}</p>}
|
||||||
|
</div>
|
||||||
|
{job.status === 'error' && <Button size="icon-sm" variant="outline" title="Réessayer" aria-label={`Réessayer ${job.name}`} onClick={() => retry(job.id)}><RotateCcw /></Button>}
|
||||||
|
{!['done', 'canceled', 'finishing'].includes(job.status) && <Button size="icon-sm" variant="ghost" title="Annuler le transfert" aria-label={`Annuler ${job.name}`} onClick={() => cancel(job.id)}><X /></Button>}
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className="mt-2" />
|
||||||
|
{job.error && <p className="mt-2 break-words text-xs text-destructive" role="alert">{job.error}</p>}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t px-3 py-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{unfinished.length ? 'Ne ferme pas l’onglet pendant les transferts.' : 'Tous les transferts sont traités.'}</span>
|
||||||
|
{jobs.some(job => ['done', 'canceled'].includes(job.status)) && <Button size="sm" variant="ghost" onClick={clearFinished}><Trash2 className="size-3.5" /> Nettoyer</Button>}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,17 +1,39 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
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 ActionButton = ({ icon: Icon, children, destructive = false, ...props }) => (
|
||||||
const {modal, setModal, action, handleAction, isTrash} = c
|
<Button variant="outline" className={`w-full min-w-0 justify-start gap-2 px-3 ${destructive ? 'text-destructive' : ''}`} {...props}>
|
||||||
|
<Icon className="size-4 shrink-0" /><span className="truncate">{children}</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function ItemActionsDialog({ controller: c }) {
|
||||||
|
const { modal, setModal, action, handleAction, isTrash } = c
|
||||||
return (
|
return (
|
||||||
<Dialog open={modal==='actions'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-sm"><DialogHeader><DialogTitle className="truncate">{action?.name||'Actions'}</DialogTitle><DialogDescription>Choisir une action sur cet élément.</DialogDescription></DialogHeader><div className="grid grid-cols-2 gap-2">{action&&<>
|
<Dialog open={modal === 'actions'} onOpenChange={open => { if (!open) setModal(null) }}>
|
||||||
{!isTrash&&<Button variant="outline" onClick={()=>handleAction('open')}><Eye/> Ouvrir</Button>}
|
<DialogContent className="w-[calc(100vw-2rem)] max-h-[85dvh] min-w-0 overflow-y-auto sm:max-w-md">
|
||||||
{!action.is_folder&&<Button variant="outline" onClick={()=>handleAction('download')}><Download/> Télécharger</Button>}
|
<DialogHeader className="min-w-0 pr-9">
|
||||||
{action.owner_id===user.id||user.is_admin?<>
|
<DialogTitle className="min-w-0 break-all text-base leading-snug">{action?.name || 'Actions'}</DialogTitle>
|
||||||
{!isTrash&&<><Button variant="outline" onClick={()=>handleAction('rename')}><Pencil/> Renommer</Button><Button variant="outline" onClick={()=>handleAction('move')}><FolderInput/> Déplacer</Button><Button variant="outline" onClick={()=>handleAction('star')}><Star className={action.is_starred?'fill-current':''}/> {action.is_starred?'Retirer favori':'Favori'}</Button>{!action.parent&&<Button variant="outline" onClick={()=>handleAction('share')}><Share2/> {action.is_shared?'Ne plus partager':'Partager'}</Button>}<Button variant="outline" className="text-destructive" onClick={()=>handleAction('trash')}><Trash2/> Corbeille</Button></>}
|
<DialogDescription>Choisir une action sur cet élément.</DialogDescription>
|
||||||
{isTrash&&<><Button variant="outline" onClick={()=>handleAction('restore')}><RotateCcw/> Restaurer</Button><Button variant="destructive" onClick={()=>handleAction('confirmdelete')}><Trash2/> Supprimer</Button></>}
|
</DialogHeader>
|
||||||
</>:null}
|
<div className="grid min-w-0 grid-cols-1 gap-2 min-[400px]:grid-cols-2">
|
||||||
</>}</div><p className="text-xs text-muted-foreground">Le partage rend cet élément et ses sous-dossiers accessibles en lecture à tous les utilisateurs connectés.</p></DialogContent></Dialog>
|
{action && <>
|
||||||
|
{!isTrash && <ActionButton icon={Eye} onClick={() => handleAction('open')}>Ouvrir</ActionButton>}
|
||||||
|
{!action.is_folder && <ActionButton icon={Download} onClick={() => handleAction('download')}>Télécharger</ActionButton>}
|
||||||
|
{action.can_write && !isTrash && <>
|
||||||
|
<ActionButton icon={Pencil} onClick={() => handleAction('rename')}>Renommer</ActionButton>
|
||||||
|
<ActionButton icon={FolderInput} onClick={() => handleAction('move')}>Déplacer</ActionButton>
|
||||||
|
<ActionButton icon={Trash2} destructive onClick={() => handleAction('trash')}>Corbeille</ActionButton>
|
||||||
|
</>}
|
||||||
|
{action.can_write && isTrash && <>
|
||||||
|
<ActionButton icon={RotateCcw} onClick={() => handleAction('restore')}>Restaurer</ActionButton>
|
||||||
|
<ActionButton icon={Trash2} destructive onClick={() => handleAction('confirmdelete')}>Supprimer</ActionButton>
|
||||||
|
</>}
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">Les fichiers restent privés. Seuls les membres autorisés de cet espace peuvent y accéder.</p>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,6 @@ import { HardDrive, ChevronRight, Folder } from 'lucide-react'
|
|||||||
export default function ItemFormDialog({controller: c}) {
|
export default function ItemFormDialog({controller: c}) {
|
||||||
const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c
|
const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c
|
||||||
return (
|
return (
|
||||||
<Dialog open={modal==='folder'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription>{modal==='move'?'Choisis le dossier de destination.':'Les modifications seront sauvegardées immédiatement.'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.owner_id===action?.owner_id).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
|
<Dialog open={modal==='folder'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription>{modal==='move'?'Choisis le dossier de destination.':'Les modifications seront sauvegardées immédiatement.'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.can_write).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
16
frontend/src/components/drive/dialogs/MembersDialog.jsx
Normal file
16
frontend/src/components/drive/dialogs/MembersDialog.jsx
Normal file
@ -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 <Dialog open={managedSpaceId != null} onOpenChange={open => {if (!open) setManagedSpaceId(null)}}>
|
||||||
|
<DialogContent className="flex h-[min(92dvh,900px)] w-[calc(100vw-1rem)] max-w-5xl flex-col overflow-hidden p-0">
|
||||||
|
<DialogHeader className="shrink-0 border-b px-5 pb-3 pt-5">
|
||||||
|
<DialogTitle>Accès et invitations · {selected?.display_name || 'Espace'}</DialogTitle>
|
||||||
|
<DialogDescription>Gérer les utilisateurs et leurs permissions dans cet espace.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<MembersPage controller={controller} user={user} embedded />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
@ -1,11 +1,31 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
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'
|
import { fmtSize } from '@/lib/drive-format'
|
||||||
|
|
||||||
export default function PreviewDialog({controller: c}) {
|
function Preview({item,url,onError}) {
|
||||||
const {preview, setPreview, previewUrl, setPreviewUrl, download} = c
|
if (!url) return <LoaderCircle className="size-6 animate-spin" />
|
||||||
return (
|
if (url === 'unsupported') return <div className="p-8 text-center"><FileText className="mx-auto mb-2 size-12 text-muted-foreground"/><p className="text-sm">Aperçu indisponible pour ce format. Tu peux le télécharger.</p></div>
|
||||||
<Dialog open={!!preview} onOpenChange={v=>{if(!v){setPreview(null);setPreviewUrl('')}}}><DialogContent className="sm:max-w-[min(95vw,950px)]"><DialogHeader><DialogTitle className="max-w-[85%] truncate">{preview?.name}</DialogTitle><DialogDescription>{preview&&!preview.is_folder?fmtSize(preview.size):''}</DialogDescription></DialogHeader><div className="flex max-h-[70vh] min-h-40 items-center justify-center overflow-hidden rounded-xl bg-muted/70">{!previewUrl?<LoaderCircle className="size-6 animate-spin"/>:previewUrl==='unsupported'?<div className="p-8 text-center"><FileText className="mx-auto mb-2 size-12 text-muted-foreground"/><p className="text-sm">Aperçu non disponible pour ce fichier.</p></div>:preview?.mime_type.startsWith('image/')?<img className="max-h-[67vh] max-w-full object-contain" src={previewUrl} alt={preview.name}/>:preview?.mime_type==='application/pdf'?<iframe title={preview.name} className="h-[65vh] w-full" src={previewUrl}/>:preview?.mime_type.startsWith('audio/')?<audio controls src={previewUrl}/>:<p className="text-sm text-muted-foreground">Aperçu indisponible.</p>}</div><DialogFooter><Button variant="outline" onClick={()=>{setPreview(null);setPreviewUrl('')}}>Fermer</Button>{preview&&<Button onClick={()=>download(preview)}><Download/> Télécharger</Button>}</DialogFooter></DialogContent></Dialog>
|
const name=item?.name?.toLowerCase() || ''
|
||||||
)
|
const mime=(item?.mime_type || '').toLowerCase()
|
||||||
|
if ((mime.startsWith('image/') && mime !== 'image/svg+xml') || /\.(jpe?g|png|gif|webp|avif|bmp)$/.test(name)) return <img src={url} onError={onError} alt={item.name} className="max-h-[70dvh] max-w-full object-contain" />
|
||||||
|
if (mime === 'application/pdf' || name.endsWith('.pdf')) return <iframe title={item.name} src={url} className="h-[70dvh] w-full rounded-md bg-white" />
|
||||||
|
if (mime.startsWith('video/') || /\.(mp4|webm|ogv|mov|m4v)$/.test(name)) return <video key={url} onError={onError} controls playsInline preload="metadata" className="max-h-[70dvh] w-full" src={url}>Vidéo non supportée par le navigateur.</video>
|
||||||
|
if (mime.startsWith('audio/') || /\.(mp3|wav|ogg|flac|m4a|aac)$/.test(name)) return <audio key={url} onError={onError} controls preload="metadata" className="mx-5 w-full" src={url}>Audio non supporté.</audio>
|
||||||
|
if (mime.startsWith('text/') || mime==='application/json' || mime==='application/xml' || /\.(txt|md|csv|json|xml|log|py|js|jsx|ts|tsx|css|html|svg)$/.test(name)) return <iframe title={item.name} src={url} className="h-[65dvh] w-full rounded-md bg-white" sandbox="" />
|
||||||
|
return <p className="p-8 text-sm text-muted-foreground">Aperçu non disponible pour ce type de fichier.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreviewDialog({controller: c}) {
|
||||||
|
const {preview, setPreview, previewUrl, setPreviewUrl, download, openEntry} = c
|
||||||
|
const close=()=>{setPreview(null);setPreviewUrl('')}
|
||||||
|
return <Dialog open={!!preview} onOpenChange={value=>{if(!value)close()}}>
|
||||||
|
<DialogContent className="w-[calc(100vw-1.5rem)] max-w-[min(96vw,1100px)]">
|
||||||
|
<DialogHeader><DialogTitle className="max-w-[90%] break-all text-base">{preview?.name}</DialogTitle><DialogDescription>{preview&&!preview.is_folder ? fmtSize(preview.size) : ''}</DialogDescription></DialogHeader>
|
||||||
|
<div className="flex min-h-40 max-h-[72dvh] items-center justify-center overflow-auto rounded-xl bg-muted/60">
|
||||||
|
{preview && <Preview item={preview} url={previewUrl} onError={()=>setPreviewUrl('unsupported')}/>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>{preview && <Button variant="outline" onClick={()=>openEntry(preview)}><RefreshCcw/> Réessayer</Button>}<Button variant="outline" onClick={close}>Fermer</Button>{preview && <Button onClick={()=>download(preview)}><Download/> Télécharger</Button>}</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { drive, message } from '@/api/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import QuotaField from '@/components/drive/QuotaField'
|
||||||
|
import {fromQuota,toQuota} from '@/lib/quota-utils'
|
||||||
|
import {fmtSize} from '@/lib/drive-format'
|
||||||
|
|
||||||
|
export default function SpaceSettingsDialog({controller: c}) {
|
||||||
|
const {spaceDialog, setSpaceDialog, spaces, activeSpaceId, loadSpaces, switchSpace, setError, setNote, setManagedSpaceId} = c
|
||||||
|
const manage = spaceDialog?.mode === 'manage'
|
||||||
|
const current = spaces.find(space => space.id === spaceDialog?.spaceId)
|
||||||
|
const managed = spaces.filter(space => !space.is_private && space.can_invite)
|
||||||
|
const [name,setName] = useState('')
|
||||||
|
const [quota,setQuota] = useState('')
|
||||||
|
const [parent,setParent] = useState('')
|
||||||
|
const [busy,setBusy] = useState(false)
|
||||||
|
const [localError,setLocalError] = useState('')
|
||||||
|
// En mode gestion, prendre le parent de l'espace édité ; en création,
|
||||||
|
// prendre le parent sélectionné. Le state doit être déclaré avant usage.
|
||||||
|
const parentId = manage ? current?.parent_id : parent
|
||||||
|
const parentSpace = spaces.find(space => space.id === Number(parentId))
|
||||||
|
useEffect(() => {
|
||||||
|
setName(manage ? current?.display_name || '' : '')
|
||||||
|
setQuota(manage ? fromQuota(current?.quota_bytes) : '')
|
||||||
|
setParent(String(manage ? current?.parent_id || '' : spaceDialog?.parentId || managed[0]?.id || ''))
|
||||||
|
setLocalError('')
|
||||||
|
}, [spaceDialog?.mode,spaceDialog?.spaceId,spaceDialog?.parentId,current?.display_name,current?.quota_bytes,current?.parent_id,managed[0]?.id])
|
||||||
|
|
||||||
|
async function save(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
setBusy(true);setLocalError('')
|
||||||
|
try {
|
||||||
|
const data={display_name:name.trim(),quota_bytes:toQuota(quota)}
|
||||||
|
const result=manage ? await drive.editSpace(current.id,data) : await drive.createSpace({...data,parent_space:Number(parent)})
|
||||||
|
await loadSpaces()
|
||||||
|
if (!manage) switchSpace(result.id)
|
||||||
|
setNote(manage ? 'Espace modifié.' : 'Espace créé.')
|
||||||
|
setSpaceDialog(null)
|
||||||
|
} catch(exception) {setLocalError(exception?.response ? message(exception) : exception.message || message(exception))}
|
||||||
|
finally {setBusy(false)}
|
||||||
|
}
|
||||||
|
function members() {
|
||||||
|
if (!current) return
|
||||||
|
setManagedSpaceId(current.id)
|
||||||
|
setSpaceDialog(null)
|
||||||
|
|
||||||
|
}
|
||||||
|
return <Dialog open={!!spaceDialog} onOpenChange={open=>{if(!open&&!busy)setSpaceDialog(null)}}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{manage ? 'Gérer l’espace' : 'Créer un espace partagé'}</DialogTitle>
|
||||||
|
<DialogDescription>Le nom affiché est indépendant du dossier numéroté sur le serveur.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={save} className="space-y-4">
|
||||||
|
{!manage && <div className="space-y-2"><Label htmlFor="space-parent">Espace parent</Label>
|
||||||
|
<select id="space-parent" value={parent} onChange={e=>setParent(e.target.value)} required
|
||||||
|
className="h-10 w-full rounded-md border bg-background px-3 text-sm">
|
||||||
|
{managed.map(space=><option key={space.id} value={space.id}>{space.display_name}</option>)}
|
||||||
|
</select></div>}
|
||||||
|
<div className="space-y-2"><Label htmlFor="space-name">Nom affiché</Label>
|
||||||
|
<Input id="space-name" value={name} onChange={e=>setName(e.target.value)} maxLength={120} required placeholder="Ex. Équipe projet" /></div>
|
||||||
|
<QuotaField id="space-quota" value={quota} onChange={setQuota} disabled={busy} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{manage
|
||||||
|
? <>Capacité supplémentaire du parent : {parentSpace?.available_bytes == null ? 'illimitée' : fmtSize(parentSpace.available_bytes)}. Une modification conserve la réservation déjà attribuée à cet espace.</>
|
||||||
|
: <>Disponible dans l’espace parent : {parentSpace?.available_bytes == null ? 'illimité' : fmtSize(parentSpace.available_bytes)}. Les quotas des sous-espaces sont réservés et inclus dans le calcul.</>}
|
||||||
|
</p>
|
||||||
|
{localError && <p role="alert" className="text-sm text-destructive">{localError}</p>}
|
||||||
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
{manage && current?.can_invite && <Button type="button" variant="outline" onClick={members}>Gérer les accès</Button>}
|
||||||
|
<Button disabled={busy||!name.trim()||(!manage&&!parent)} type="submit">{busy ? 'Enregistrement…' : manage ? 'Enregistrer' : 'Créer l’espace'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
@ -1,17 +1,3 @@
|
|||||||
import { HardDrive, Clock3, Star, Users, Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
|
export const sections = [{ id:'trash', name:'Corbeille', icon:Trash2 }]
|
||||||
export const sections = [
|
export const headerNames = { files:'Mes fichiers', trash:'Corbeille' }
|
||||||
{id: 'files', name: 'Mes fichiers', icon: HardDrive},
|
|
||||||
{id: 'recent', name: 'Récents', icon: Clock3},
|
|
||||||
{id: 'starred', name: 'Favoris', icon: Star},
|
|
||||||
{id: 'shared', name: 'Partagés avec moi', icon: Users},
|
|
||||||
{id: 'trash', name: 'Corbeille', icon: Trash2},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const headerNames = {
|
|
||||||
files: 'Mes fichiers',
|
|
||||||
recent: 'Récents',
|
|
||||||
starred: 'Favoris',
|
|
||||||
shared: 'Partagés avec moi',
|
|
||||||
trash: 'Corbeille',
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { drive, message } from '@/api/client'
|
import { drive, message } from '@/api/client'
|
||||||
|
import { useUploadQueue } from '@/hooks/useUploadQueue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared Drive page state: navigation, search, uploads and item actions.
|
* Shared Drive page state: navigation, search, uploads and item actions.
|
||||||
@ -7,10 +8,13 @@ import { drive, message } from '@/api/client'
|
|||||||
*/
|
*/
|
||||||
export function useDriveController(user) {
|
export function useDriveController(user) {
|
||||||
const [view, setView] = useState('files')
|
const [view, setView] = useState('files')
|
||||||
|
const [spaces, setSpaces] = useState([])
|
||||||
|
const [activeSpaceId, setActiveSpaceId] = useState(user.private_space_id || user.space_id)
|
||||||
const [parent, setParent] = useState(null)
|
const [parent, setParent] = useState(null)
|
||||||
const [trail, setTrail] = useState([])
|
const [trail, setTrail] = useState([])
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
const [stats, setStats] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
|
const [pendingUploads, setPendingUploads] = useState([])
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -26,12 +30,16 @@ export function useDriveController(user) {
|
|||||||
const [moveFolder, setMoveFolder] = useState(null)
|
const [moveFolder, setMoveFolder] = useState(null)
|
||||||
const [moveTrail, setMoveTrail] = useState([])
|
const [moveTrail, setMoveTrail] = useState([])
|
||||||
const [moveItems, setMoveItems] = useState([])
|
const [moveItems, setMoveItems] = useState([])
|
||||||
|
const [managedSpaceId, setManagedSpaceId] = useState(null)
|
||||||
|
const [spaceDialog, setSpaceDialog] = useState(null)
|
||||||
const [preview, setPreview] = useState(null)
|
const [preview, setPreview] = useState(null)
|
||||||
const [previewUrl, setPreviewUrl] = useState('')
|
const [previewUrl, setPreviewUrl] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [uploadState, setUploadState] = useState(null)
|
|
||||||
const [drag, setDrag] = useState(false)
|
const [drag, setDrag] = useState(false)
|
||||||
const filesInput = useRef(null)
|
const filesInput = useRef(null)
|
||||||
|
const previewSequence = useRef(0)
|
||||||
|
const requestSequence = useRef(0)
|
||||||
|
const statsSequence = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.toggle('dark', dark)
|
document.documentElement.classList.toggle('dark', dark)
|
||||||
@ -44,32 +52,84 @@ export function useDriveController(user) {
|
|||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const activeSpace = spaces.find(s => s.id === activeSpaceId)
|
||||||
setLoading(true)
|
const canWrite = activeSpace?.can_write ?? false
|
||||||
setError('')
|
const loadSpaces = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const params = { view }
|
const data = await drive.spaces()
|
||||||
|
setSpaces(data.spaces)
|
||||||
|
setActiveSpaceId(current => current && data.spaces.some(s => s.id === current) ? current : data.mine)
|
||||||
|
} catch (exception) { setError(message(exception)) }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { loadSpaces() }, [loadSpaces])
|
||||||
|
|
||||||
|
const switchSpace = id => {
|
||||||
|
++statsSequence.current
|
||||||
|
setStats(null)
|
||||||
|
setActiveSpaceId(id)
|
||||||
|
setView('files')
|
||||||
|
setParent(null)
|
||||||
|
setQuery('')
|
||||||
|
setSidebar(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silent background synchronization: other users can rename, move or delete
|
||||||
|
// shared items. Ignore responses from an outdated folder/space request.
|
||||||
|
const refresh = useCallback(async (silent = false) => {
|
||||||
|
const sequence = ++requestSequence.current
|
||||||
|
if (!silent) setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = { view, space: activeSpaceId }
|
||||||
if (view === 'files' && parent) params.parent = parent
|
if (view === 'files' && parent) params.parent = parent
|
||||||
if (search) {
|
if (search) {
|
||||||
params.q = search
|
params.q = search
|
||||||
params.view = 'files'
|
if (view === 'files') delete params.parent
|
||||||
delete params.parent
|
|
||||||
}
|
}
|
||||||
const data = await drive.list(params)
|
const data = await drive.list(params)
|
||||||
|
if (sequence !== requestSequence.current) return
|
||||||
setItems(data.items)
|
setItems(data.items)
|
||||||
setTrail(data.breadcrumb || [])
|
setTrail(data.breadcrumb || [])
|
||||||
if (data.limited) setNote('La liste affiche les 500 premiers résultats. Utilise la recherche pour affiner.')
|
if (data.limited && !silent) setNote('La liste affiche les 500 premiers résultats. Utilise la recherche pour affiner.')
|
||||||
|
if (!silent) setError('')
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
setError(message(exception))
|
if (sequence !== requestSequence.current) return
|
||||||
|
if (silent && parent && [403, 404].includes(exception.response?.status)) {
|
||||||
|
setParent(null)
|
||||||
|
setNote('Ce dossier a été déplacé, supprimé ou n’est plus accessible.')
|
||||||
|
} else if (!silent) setError(message(exception))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (sequence === requestSequence.current) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [view, parent, search])
|
}, [view, parent, search, activeSpaceId])
|
||||||
|
|
||||||
|
// The list is synchronized in the background; never leave an action panel
|
||||||
|
// pointing at an item which no longer exists in the visible folder.
|
||||||
|
useEffect(() => {
|
||||||
|
if (action && ['actions','rename','move','confirmdelete'].includes(modal)
|
||||||
|
&& !items.some(item => item.id === action.id)) {
|
||||||
|
setModal(null)
|
||||||
|
setAction(null)
|
||||||
|
}
|
||||||
|
if (preview && !items.some(item => item.id === preview.id)) {
|
||||||
|
++previewSequence.current
|
||||||
|
setPreview(null)
|
||||||
|
setPreviewUrl('')
|
||||||
|
}
|
||||||
|
}, [items, action, modal, preview])
|
||||||
useEffect(() => { refresh() }, [refresh])
|
useEffect(() => { refresh() }, [refresh])
|
||||||
|
|
||||||
const reloadStats = useCallback(() => drive.stats().then(setStats).catch(() => {}), [])
|
const reloadStats = useCallback(async () => {
|
||||||
|
const sequence = ++statsSequence.current
|
||||||
|
try {
|
||||||
|
const response = await drive.stats({ space: activeSpaceId })
|
||||||
|
if (sequence === statsSequence.current) setStats(response)
|
||||||
|
} catch { /* The next refresh will retry without showing stale figures. */ }
|
||||||
|
}, [activeSpaceId])
|
||||||
useEffect(() => { reloadStats() }, [reloadStats])
|
useEffect(() => { reloadStats() }, [reloadStats])
|
||||||
|
const refreshPending = useCallback(() => {
|
||||||
|
drive.pendingUploads().then(setPendingUploads).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { refreshPending() }, [refreshPending])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!note) return
|
if (!note) return
|
||||||
@ -81,7 +141,7 @@ export function useDriveController(user) {
|
|||||||
setView(id)
|
setView(id)
|
||||||
setParent(null)
|
setParent(null)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
setSort(id === 'recent' ? 'date' : 'name')
|
setSort('name')
|
||||||
setSidebar(false)
|
setSidebar(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,13 +154,18 @@ export function useDriveController(user) {
|
|||||||
|
|
||||||
const openEntry = async item => {
|
const openEntry = async item => {
|
||||||
if (item.is_folder) return openFolder(item)
|
if (item.is_folder) return openFolder(item)
|
||||||
|
const sequence = ++previewSequence.current
|
||||||
setPreview(item)
|
setPreview(item)
|
||||||
setPreviewUrl('')
|
setPreviewUrl('')
|
||||||
try {
|
try {
|
||||||
const url = await drive.link(item.id, true)
|
const url = await drive.link(item.id, true)
|
||||||
setPreviewUrl(url)
|
// A broken proxy or a vanished permission must show an error, not a blank
|
||||||
|
// image / PDF iframe or an endlessly spinning video.
|
||||||
|
const probe = await fetch(url, {method:'HEAD', cache:'no-store'})
|
||||||
|
if (!probe.ok) throw new Error(`Aperçu inaccessible (HTTP ${probe.status})`)
|
||||||
|
if (sequence === previewSequence.current) setPreviewUrl(url)
|
||||||
} catch {
|
} catch {
|
||||||
setPreviewUrl('unsupported')
|
if (sequence === previewSequence.current) setPreviewUrl('unsupported')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,35 +183,43 @@ export function useDriveController(user) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadFiles = async incoming => {
|
const uploads = useUploadQueue({
|
||||||
const files = Array.from(incoming || [])
|
onComplete: () => { refresh(true); reloadStats(); refreshPending() },
|
||||||
if (!files.length) return
|
onSettled: () => { reloadStats(); refreshPending() },
|
||||||
setUploadState({ names: files.length === 1 ? files[0].name : `${files.length} fichiers`, percent: 0 })
|
})
|
||||||
setError('')
|
|
||||||
try {
|
const uploadFiles = incoming => {
|
||||||
const destination = view === 'files' ? parent : null
|
if (!canWrite || view === 'trash') return
|
||||||
for (let i = 0; i < files.length; i++) {
|
uploads.addFiles(incoming, view === 'files' ? parent : null, activeSpaceId)
|
||||||
const file = files[i]
|
if (filesInput.current) filesInput.current.value = ''
|
||||||
setUploadState({
|
}
|
||||||
names: files.length === 1 ? file.name : `${file.name} (${i + 1}/${files.length})`,
|
|
||||||
percent: 0,
|
// Refresh in the background while the user is looking at the Drive. No
|
||||||
})
|
// loading overlay/flicker, and no need to reload the page manually.
|
||||||
const progress = percent => setUploadState(current => current ? { ...current, percent } : null)
|
useEffect(() => {
|
||||||
if (file.size >= 8 * 1024 * 1024) {
|
const poll = () => {
|
||||||
await drive.uploadResumable(file, destination, user.id, progress)
|
if (document.visibilityState !== 'visible') return
|
||||||
} else {
|
refresh(true)
|
||||||
await drive.upload([file], destination, progress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setNote(`${files.length} fichier(s) importé(s).`)
|
|
||||||
refresh()
|
|
||||||
reloadStats()
|
reloadStats()
|
||||||
} catch (exception) {
|
refreshPending()
|
||||||
setError(message(exception))
|
|
||||||
} finally {
|
|
||||||
setUploadState(null)
|
|
||||||
if (filesInput.current) filesInput.current.value = ''
|
|
||||||
}
|
}
|
||||||
|
const interval = window.setInterval(poll, 4000)
|
||||||
|
window.addEventListener('focus', poll)
|
||||||
|
const onVisibility = () => { if (document.visibilityState === 'visible') poll() }
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval)
|
||||||
|
window.removeEventListener('focus', poll)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
}
|
||||||
|
}, [refresh, reloadStats, refreshPending])
|
||||||
|
|
||||||
|
const cancelPendingUpload = async id => {
|
||||||
|
try {
|
||||||
|
await drive.cancelUpload(id)
|
||||||
|
setNote('Import interrompu annulé, espace réservé libéré.')
|
||||||
|
await Promise.all([refreshPending(), reloadStats()])
|
||||||
|
} catch (exception) { setError(message(exception)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = async operation => {
|
const run = async operation => {
|
||||||
@ -187,13 +260,13 @@ export function useDriveController(user) {
|
|||||||
const moveBrowse = useCallback(async destination => {
|
const moveBrowse = useCallback(async destination => {
|
||||||
setMoveFolder(destination)
|
setMoveFolder(destination)
|
||||||
try {
|
try {
|
||||||
const data = await drive.list({ view: 'files', ...(destination ? { parent: destination } : {}) })
|
const data = await drive.list({ view: 'files', space: activeSpaceId, ...(destination ? { parent: destination } : {}) })
|
||||||
setMoveTrail(data.breadcrumb)
|
setMoveTrail(data.breadcrumb)
|
||||||
setMoveItems(data.items.filter(item => item.is_folder))
|
setMoveItems(data.items.filter(item => item.is_folder))
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
setError(message(exception))
|
setError(message(exception))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [activeSpaceId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modal === 'move') moveBrowse(null)
|
if (modal === 'move') moveBrowse(null)
|
||||||
@ -201,17 +274,23 @@ export function useDriveController(user) {
|
|||||||
|
|
||||||
const actionSubmit = event => {
|
const actionSubmit = event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (modal === 'folder') return run(() => drive.folder(value, parent))
|
if (modal === 'folder') return run(() => drive.folder(value, parent, activeSpaceId))
|
||||||
if (modal === 'rename') return run(() => drive.patch(action.id, { name: value }))
|
if (modal === 'rename') return run(() => drive.patch(action.id, { name: value }))
|
||||||
if (modal === 'move') return run(() => drive.patch(action.id, { parent: moveFolder }))
|
if (modal === 'move') return run(() => drive.patch(action.id, { parent: moveFolder }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const performItemAction = (item, type) => {
|
||||||
|
if (type === 'download') return download(item)
|
||||||
|
if (type === 'open') return openEntry(item)
|
||||||
|
if (type === 'trash') return run(() => drive.trash(item.id))
|
||||||
|
if (type === 'restore') return run(() => drive.restore(item.id))
|
||||||
|
startAction(item, type)
|
||||||
|
}
|
||||||
|
|
||||||
const handleAction = type => {
|
const handleAction = type => {
|
||||||
if (!action) return
|
if (!action) return
|
||||||
if (type === 'download') { setModal(null); return download(action) }
|
if (type === 'download') { setModal(null); return download(action) }
|
||||||
if (type === 'open') { setModal(null); return openEntry(action) }
|
if (type === 'open') { setModal(null); return openEntry(action) }
|
||||||
if (type === 'star') return run(() => drive.patch(action.id, { is_starred: !action.is_starred }))
|
|
||||||
if (type === 'share') return run(() => drive.patch(action.id, { is_shared: !action.is_shared }))
|
|
||||||
if (type === 'trash') return run(() => drive.trash(action.id))
|
if (type === 'trash') return run(() => drive.trash(action.id))
|
||||||
if (type === 'restore') return run(() => drive.restore(action.id))
|
if (type === 'restore') return run(() => drive.restore(action.id))
|
||||||
if (type === 'remove') return run(() => drive.remove(action.id))
|
if (type === 'remove') return run(() => drive.remove(action.id))
|
||||||
@ -222,11 +301,14 @@ export function useDriveController(user) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
view, parent, setParent, trail, items, stats, query, setQuery, search, loading,
|
view, parent, setParent, trail, items, stats, query, setQuery, search, loading,
|
||||||
|
spaces, activeSpaceId, activeSpace, canWrite, switchSpace, loadSpaces, reloadStats,
|
||||||
|
managedSpaceId, setManagedSpaceId, spaceDialog, setSpaceDialog,
|
||||||
|
pendingUploads, cancelPendingUpload,
|
||||||
error, setError, note, setNote, layout, setLayout, sort, setSort, dark, setDark,
|
error, setError, note, setNote, layout, setLayout, sort, setSort, dark, setDark,
|
||||||
sidebar, setSidebar, modal, setModal, action, value, setValue,
|
sidebar, setSidebar, modal, setModal, action, value, setValue,
|
||||||
moveTrail, moveItems, preview, setPreview, previewUrl, setPreviewUrl,
|
moveTrail, moveItems, preview, setPreview, previewUrl, setPreviewUrl,
|
||||||
busy, uploadState, drag, setDrag, filesInput,
|
busy, uploads, drag, setDrag, filesInput,
|
||||||
refresh, section, openEntry, download, uploadFiles, startAction,
|
refresh, section, openEntry, download, uploadFiles, startAction,
|
||||||
display, goRoot, moveBrowse, actionSubmit, handleAction, isTrash,
|
display, goRoot, moveBrowse, actionSubmit, handleAction, performItemAction, isTrash,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
226
frontend/src/hooks/useUploadQueue.js
Normal file
226
frontend/src/hooks/useUploadQueue.js
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { drive, message } from '@/api/client'
|
||||||
|
import { resetTransferRate, recordTransferProgress } from '@/lib/transfer-metrics'
|
||||||
|
|
||||||
|
// Every file uses Django's resumable upload endpoints. An interrupted chunk can
|
||||||
|
// be retried safely: the server's committed offset is checked before continuing.
|
||||||
|
const PART_SIZE = 8 * 1024 * 1024
|
||||||
|
const ACTIVE = new Set(['queued', 'uploading', 'paused', 'finishing', 'error'])
|
||||||
|
const isPending = job => ACTIVE.has(job.status) && job.status !== 'canceled'
|
||||||
|
|
||||||
|
export function useUploadQueue({ onComplete, onSettled }) {
|
||||||
|
const jobsRef = useRef([])
|
||||||
|
const [jobs, setJobs] = useState([])
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
const pausedRef = useRef(false)
|
||||||
|
const workerRef = useRef(false)
|
||||||
|
const requestRef = useRef(null)
|
||||||
|
const currentRef = useRef(null)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
const completeRef = useRef(onComplete)
|
||||||
|
completeRef.current = onComplete
|
||||||
|
const settledRef = useRef(onSettled)
|
||||||
|
settledRef.current = onSettled
|
||||||
|
const lastRenderRef = useRef(0)
|
||||||
|
|
||||||
|
const render = useCallback((force = true) => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (!force && now - lastRenderRef.current < 120) return
|
||||||
|
lastRenderRef.current = now
|
||||||
|
if (mountedRef.current) setJobs(jobsRef.current.map(({ file, cancelRequested, ...publicJob }) => ({ ...publicJob })))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cancelOnServer = async job => {
|
||||||
|
if (!job.sessionId) return
|
||||||
|
const id = job.sessionId
|
||||||
|
job.sessionId = null
|
||||||
|
try { await drive.cancelUpload(id) } catch (error) {
|
||||||
|
// The session may already have finished/deleted; don't requeue it.
|
||||||
|
if (error.response?.status !== 404) job.error = `Nettoyage serveur : ${message(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processJob = async job => {
|
||||||
|
currentRef.current = job.id
|
||||||
|
job.status = 'uploading'
|
||||||
|
job.error = ''
|
||||||
|
resetTransferRate(job)
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
if (!job.sessionId) {
|
||||||
|
const started = await drive.startUpload(job.file, job.parent, job.space)
|
||||||
|
job.sessionId = started.id
|
||||||
|
job.sent = started.offset
|
||||||
|
job.confirmed = started.offset
|
||||||
|
resetTransferRate(job)
|
||||||
|
} else {
|
||||||
|
const status = await drive.uploadStatus(job.sessionId)
|
||||||
|
job.sent = status.offset
|
||||||
|
job.confirmed = status.offset
|
||||||
|
resetTransferRate(job)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
while (job.sent < job.size && !job.cancelRequested && !pausedRef.current) {
|
||||||
|
const offset = job.sent
|
||||||
|
const part = job.file.slice(offset, Math.min(offset + PART_SIZE, job.size))
|
||||||
|
const controller = new AbortController()
|
||||||
|
requestRef.current = controller
|
||||||
|
const response = await drive.uploadChunk(job.sessionId, offset, part, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onUploadProgress: event => {
|
||||||
|
if (!job.cancelRequested && !pausedRef.current) {
|
||||||
|
// Multipart adds its own bytes: cap the counter to the actual file chunk.
|
||||||
|
recordTransferProgress(job, offset + Math.min(part.size, event.loaded || 0))
|
||||||
|
render(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
requestRef.current = null
|
||||||
|
recordTransferProgress(job, response.offset)
|
||||||
|
job.confirmed = response.offset
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
if (job.cancelRequested || pausedRef.current) return
|
||||||
|
job.status = 'finishing'
|
||||||
|
render()
|
||||||
|
const result = await drive.finishUpload(job.sessionId)
|
||||||
|
job.sessionId = null
|
||||||
|
job.sent = job.size
|
||||||
|
job.status = 'done'
|
||||||
|
job.speedBps = 0
|
||||||
|
job.etaSeconds = 0
|
||||||
|
render()
|
||||||
|
completeRef.current?.(result)
|
||||||
|
} catch (error) {
|
||||||
|
requestRef.current = null
|
||||||
|
// An aborted HTTP request may have reported optimistic progress; only
|
||||||
|
// a confirmed server offset is guaranteed to have been committed.
|
||||||
|
job.sent = job.confirmed || 0
|
||||||
|
resetTransferRate(job)
|
||||||
|
if (!job.cancelRequested && error?.code === 'ERR_CANCELED') {
|
||||||
|
job.status = pausedRef.current ? 'paused' : 'queued'
|
||||||
|
} else if (!job.cancelRequested && !pausedRef.current) {
|
||||||
|
job.status = 'error'
|
||||||
|
job.error = message(error)
|
||||||
|
// The displayed optimistic counter is reconciled with the server on retry.
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
requestRef.current = null
|
||||||
|
if (job.cancelRequested) {
|
||||||
|
await cancelOnServer(job)
|
||||||
|
job.status = 'canceled'
|
||||||
|
settledRef.current?.()
|
||||||
|
} else if (pausedRef.current && job.status !== 'done' && job.status !== 'error') {
|
||||||
|
job.status = 'paused'
|
||||||
|
}
|
||||||
|
currentRef.current = null
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly one worker per browser tab, preserving FIFO and server quota locking.
|
||||||
|
const work = useCallback(async () => {
|
||||||
|
if (workerRef.current || pausedRef.current) return
|
||||||
|
workerRef.current = true
|
||||||
|
try {
|
||||||
|
while (!pausedRef.current) {
|
||||||
|
const job = jobsRef.current.find(item => item.status === 'queued' && !item.cancelRequested)
|
||||||
|
if (!job) break
|
||||||
|
await processJob(job)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
workerRef.current = false
|
||||||
|
// Resume may have been requested while the aborted request was unwinding.
|
||||||
|
if (!pausedRef.current && jobsRef.current.some(item => item.status === 'queued')) queueMicrotask(work)
|
||||||
|
}
|
||||||
|
}, [render])
|
||||||
|
|
||||||
|
const addFiles = useCallback((files, parent, space) => {
|
||||||
|
const incoming = Array.from(files || [])
|
||||||
|
if (!incoming.length || space == null) return
|
||||||
|
const now = Date.now()
|
||||||
|
jobsRef.current.push(...incoming.map((file, index) => ({
|
||||||
|
id: `${now}-${index}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
file, name: file.name, size: file.size, sent: 0, parent, space,
|
||||||
|
sessionId: null, status: pausedRef.current ? 'paused' : 'queued',
|
||||||
|
cancelRequested: false, error: '', confirmed: 0,
|
||||||
|
speedBps: 0, etaSeconds: null, rateSampleAt: null, rateSampleBytes: 0,
|
||||||
|
})))
|
||||||
|
setExpanded(true)
|
||||||
|
render()
|
||||||
|
work()
|
||||||
|
}, [render, work])
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
pausedRef.current = true
|
||||||
|
setPaused(true)
|
||||||
|
for (const job of jobsRef.current) if (job.status === 'queued') job.status = 'paused'
|
||||||
|
requestRef.current?.abort()
|
||||||
|
for (const job of jobsRef.current) if (job.status !== 'done') { job.speedBps = 0; job.etaSeconds = null }
|
||||||
|
render()
|
||||||
|
}, [render])
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
pausedRef.current = false
|
||||||
|
setPaused(false)
|
||||||
|
for (const job of jobsRef.current) if (job.status === 'paused') job.status = 'queued'
|
||||||
|
render()
|
||||||
|
work()
|
||||||
|
}, [render, work])
|
||||||
|
|
||||||
|
const retry = useCallback(id => {
|
||||||
|
const job = jobsRef.current.find(entry => entry.id === id)
|
||||||
|
if (!job || job.status !== 'error') return
|
||||||
|
job.status = pausedRef.current ? 'paused' : 'queued'
|
||||||
|
job.error = ''
|
||||||
|
render()
|
||||||
|
work()
|
||||||
|
}, [render, work])
|
||||||
|
|
||||||
|
const cancel = useCallback(async id => {
|
||||||
|
const job = jobsRef.current.find(entry => entry.id === id)
|
||||||
|
if (!job || job.status === 'done' || job.status === 'canceled') return
|
||||||
|
job.cancelRequested = true
|
||||||
|
if (currentRef.current === id) {
|
||||||
|
requestRef.current?.abort()
|
||||||
|
// processJob owns final cleanup, even if session creation is still underway.
|
||||||
|
} else {
|
||||||
|
await cancelOnServer(job)
|
||||||
|
job.status = 'canceled'
|
||||||
|
settledRef.current?.()
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}, [render])
|
||||||
|
|
||||||
|
const clearFinished = useCallback(() => {
|
||||||
|
jobsRef.current = jobsRef.current.filter(job => !['done', 'canceled'].includes(job.status))
|
||||||
|
render()
|
||||||
|
}, [render])
|
||||||
|
|
||||||
|
// No silent data loss from an accidental reload/navigation away from the tab.
|
||||||
|
useEffect(() => {
|
||||||
|
const beforeUnload = event => {
|
||||||
|
if (!jobsRef.current.some(isPending)) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', beforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', beforeUnload)
|
||||||
|
}, [])
|
||||||
|
// Refresh the speed indicator when an upload stalls (without fake progress).
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (jobsRef.current.some(job => job.status === 'uploading')) render()
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [render])
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
return () => { mountedRef.current = false; requestRef.current?.abort() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { jobs, paused, expanded, setExpanded, addFiles, pause, resume, retry, cancel, clearFinished,
|
||||||
|
inProgress: jobs.some(isPending) }
|
||||||
|
}
|
||||||
11
frontend/src/lib/quota-utils.js
Normal file
11
frontend/src/lib/quota-utils.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/** Values sent to Django are integer bytes (null = no per-space limit). */
|
||||||
|
const GIB = 1024 ** 3
|
||||||
|
export const fromQuota = bytes => bytes == null ? '' : String(Number((bytes / GIB).toFixed(3)))
|
||||||
|
export function toQuota(value) {
|
||||||
|
if (value === '') return null
|
||||||
|
const trimmed = String(value).trim().replace(',', '.')
|
||||||
|
if (!/^\d+(\.\d{1,3})?$/.test(trimmed)) throw new Error('Entre un quota positif en Go, avec 3 décimales maximum.')
|
||||||
|
const bytes = Math.round(Number(trimmed) * GIB)
|
||||||
|
if (!Number.isSafeInteger(bytes)) throw new Error('Ce quota est trop élevé.')
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
41
frontend/src/lib/transfer-metrics.js
Normal file
41
frontend/src/lib/transfer-metrics.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// Measure actual bytes reported as uploaded by the browser (not file size / elapsed
|
||||||
|
// wall-clock time). Reset the rate on pause/retry so ETAs never remain stale.
|
||||||
|
export function resetTransferRate(job, now = Date.now()) {
|
||||||
|
job.speedBps = 0
|
||||||
|
job.etaSeconds = null
|
||||||
|
job.rateSampleAt = now
|
||||||
|
job.rateSampleBytes = job.sent || 0
|
||||||
|
job.lastProgressAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordTransferProgress(job, bytes, now = Date.now()) {
|
||||||
|
const sent = Math.min(job.size, Math.max(0, bytes))
|
||||||
|
if (sent < (job.sent || 0)) resetTransferRate(job, now)
|
||||||
|
job.sent = sent
|
||||||
|
if (job.rateSampleAt == null) resetTransferRate(job, now)
|
||||||
|
job.lastProgressAt = now
|
||||||
|
const elapsed = now - job.rateSampleAt
|
||||||
|
if (elapsed >= 350) {
|
||||||
|
const delta = Math.max(0, sent - job.rateSampleBytes)
|
||||||
|
const instant = delta * 1000 / Math.max(1, elapsed)
|
||||||
|
job.speedBps = job.speedBps > 0 ? job.speedBps * 0.35 + instant * 0.65 : instant
|
||||||
|
job.rateSampleAt = now
|
||||||
|
job.rateSampleBytes = sent
|
||||||
|
}
|
||||||
|
job.etaSeconds = job.speedBps > 0 && sent < job.size
|
||||||
|
? Math.ceil((job.size - sent) / job.speedBps)
|
||||||
|
: sent === job.size ? 0 : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentTransferSpeed(job, now = Date.now()) {
|
||||||
|
if (job.status !== 'uploading' || now - (job.lastProgressAt || 0) > 4000) return 0
|
||||||
|
return job.speedBps || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(value) {
|
||||||
|
if (!Number.isFinite(value) || value < 0) return 'Calcul…'
|
||||||
|
const total = Math.ceil(value)
|
||||||
|
if (total < 60) return `${total} s`
|
||||||
|
if (total < 3600) return `${Math.floor(total / 60)} min ${String(total % 60).padStart(2, '0')} s`
|
||||||
|
return `${Math.floor(total / 3600)} h ${String(Math.floor((total % 3600) / 60)).padStart(2, '0')} min`
|
||||||
|
}
|
||||||
@ -4,16 +4,17 @@ import DriveSidebar from '@/components/drive/DriveSidebar'
|
|||||||
import DriveToolbar from '@/components/drive/DriveToolbar'
|
import DriveToolbar from '@/components/drive/DriveToolbar'
|
||||||
import DriveExplorer from '@/components/drive/DriveExplorer'
|
import DriveExplorer from '@/components/drive/DriveExplorer'
|
||||||
import DriveDialogs from '@/components/drive/DriveDialogs'
|
import DriveDialogs from '@/components/drive/DriveDialogs'
|
||||||
|
import UploadTray from '@/components/drive/UploadTray'
|
||||||
|
|
||||||
export default function DrivePage({user, onLogout}) {
|
export default function DrivePage({user, onLogout}) {
|
||||||
const controller = useDriveController(user)
|
const controller = useDriveController(user)
|
||||||
const {sidebar, setSidebar, drag, setDrag, filesInput, uploadFiles, isTrash} = controller
|
const {sidebar, setSidebar, drag, setDrag, filesInput, uploadFiles, isTrash, canWrite} = controller
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh min-h-[540px] overflow-hidden bg-background"
|
<div className="flex h-dvh min-h-[540px] overflow-hidden bg-background"
|
||||||
onDragOver={e => {if(e.dataTransfer?.types?.includes('Files')){e.preventDefault();setDrag(true)}}}
|
onDragOver={e => {if(e.dataTransfer?.types?.includes('Files')){e.preventDefault();setDrag(true)}}}
|
||||||
onDragLeave={e => {if(!e.currentTarget.contains(e.relatedTarget))setDrag(false)}}
|
onDragLeave={e => {if(!e.currentTarget.contains(e.relatedTarget))setDrag(false)}}
|
||||||
onDrop={e => {e.preventDefault();setDrag(false);if(!isTrash)uploadFiles(e.dataTransfer.files)}}>
|
onDrop={e => {e.preventDefault();setDrag(false);if(!isTrash&&canWrite)uploadFiles(e.dataTransfer.files)}}>
|
||||||
{sidebar && <button className="fixed inset-0 z-30 bg-black/40 md:hidden" aria-label="Fermer le menu" onClick={() => setSidebar(false)} />}
|
{sidebar && <button className="fixed inset-0 z-30 bg-black/40 md:hidden" aria-label="Fermer le menu" onClick={() => setSidebar(false)} />}
|
||||||
<DriveSidebar controller={controller} user={user} onLogout={onLogout} />
|
<DriveSidebar controller={controller} user={user} onLogout={onLogout} />
|
||||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
@ -21,8 +22,9 @@ export default function DrivePage({user, onLogout}) {
|
|||||||
<DriveExplorer controller={controller} user={user} />
|
<DriveExplorer controller={controller} user={user} />
|
||||||
</main>
|
</main>
|
||||||
<input className="hidden" ref={filesInput} type="file" multiple onChange={e => uploadFiles(e.target.files)} />
|
<input className="hidden" ref={filesInput} type="file" multiple onChange={e => uploadFiles(e.target.files)} />
|
||||||
{drag && !isTrash && <div className="pointer-events-none fixed inset-3 z-50 grid place-items-center rounded-3xl border-4 border-dashed border-primary bg-background/90"><div className="text-center"><CloudUpload className="mx-auto mb-3 size-14 text-primary"/><h2 className="text-xl font-semibold">Dépose tes fichiers ici</h2><p className="mt-1 text-muted-foreground">Ils seront importés sur ton serveur.</p></div></div>}
|
{drag && !isTrash && canWrite && <div className="pointer-events-none fixed inset-3 z-50 grid place-items-center rounded-3xl border-4 border-dashed border-primary bg-background/90"><div className="text-center"><CloudUpload className="mx-auto mb-3 size-14 text-primary"/><h2 className="text-xl font-semibold">Dépose tes fichiers ici</h2><p className="mt-1 text-muted-foreground">Ils seront importés sur ton serveur.</p></div></div>}
|
||||||
<DriveDialogs controller={controller} user={user} />
|
<DriveDialogs controller={controller} user={user} />
|
||||||
|
<UploadTray uploads={controller.uploads} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/pages/InvitationPage.jsx
Normal file
55
frontend/src/pages/InvitationPage.jsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ArrowRight, HardDrive, LoaderCircle } from 'lucide-react'
|
||||||
|
import { drive, message } from '@/api/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {fmtSize} from '@/lib/drive-format'
|
||||||
|
|
||||||
|
export default function InvitationPage({ token }) {
|
||||||
|
const [info, setInfo] = useState(null)
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [displayName, setDisplayName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmation, setConfirmation] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
drive.invitePreview(token).then(setInfo).catch(exception => setError(message(exception)))
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const submit = async event => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (password !== confirmation) { setError('Les mots de passe sont différents.'); return }
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
await drive.inviteAccept(token, { username, password, email, display_name: displayName, first_name: firstName, last_name: lastName })
|
||||||
|
setDone(true)
|
||||||
|
} catch (exception) { setError(message(exception)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="flex min-h-dvh items-center justify-center bg-background p-4">
|
||||||
|
<div className="w-full max-w-lg space-y-6 rounded-3xl border bg-card p-7 shadow-xl sm:p-10">
|
||||||
|
<div className="flex items-center gap-3"><div className="drive-brand grid size-12 place-items-center rounded-2xl text-white"><HardDrive /></div><div><h1 className="text-xl font-bold">GoodDrive</h1><p className="text-xs text-muted-foreground">Un espace pour ceux qui en ont besoin.</p></div></div>
|
||||||
|
{done ? <div className="space-y-4"><h2 className="text-2xl font-semibold">Bienvenue sur GoodDrive !</h2><p>Ton espace personnel et ton espace partagé sont prêts. Tu peux maintenant te connecter.</p><Button onClick={() => window.location.assign('/')} className="w-full">Se connecter <ArrowRight /></Button></div>
|
||||||
|
: info ? <><div><h2 className="text-2xl font-semibold">Créer mon espace</h2><p className="mt-2 text-sm text-muted-foreground">Tu es invité dans l'arborescence de « {info.parent_name} ». Tu disposeras de « Mes fichiers », privé, et d’un accès à l’espace partagé attribué.</p>{info.quota_bytes != null && <p className="mt-2 text-sm font-medium">Espace attribué : {fmtSize(info.quota_bytes)}</p>}</div>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="block text-sm font-medium">Prénom<Input required maxLength={150} autoComplete="given-name" value={firstName} onChange={e=>setFirstName(e.target.value)}/></label><label className="block text-sm font-medium">Nom<Input required maxLength={150} autoComplete="family-name" value={lastName} onChange={e=>setLastName(e.target.value)}/></label></div>
|
||||||
|
<label className="block text-sm font-medium">Identifiant<Input required maxLength={150} autoComplete="username" value={username} onChange={e=>setUsername(e.target.value)} /></label>
|
||||||
|
<label className="block text-sm font-medium">Nom affiché de mon espace<Input required maxLength={120} value={displayName} onChange={e=>setDisplayName(e.target.value)} placeholder="Mon Drive"/></label>
|
||||||
|
<label className="block text-sm font-medium">E-mail (facultatif)<Input type="email" autoComplete="email" maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label>
|
||||||
|
<label className="block text-sm font-medium">Mot de passe<Input type="password" required autoComplete="new-password" value={password} onChange={e=>setPassword(e.target.value)}/></label>
|
||||||
|
<label className="block text-sm font-medium">Confirmer le mot de passe<Input type="password" required autoComplete="new-password" value={confirmation} onChange={e=>setConfirmation(e.target.value)}/></label>
|
||||||
|
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={busy}>{busy ? <><LoaderCircle className="animate-spin"/> Création du compte…</> : <>Créer mon compte <ArrowRight /></>}</Button>
|
||||||
|
</form></>
|
||||||
|
: <div className="flex items-center gap-2 text-sm text-muted-foreground">{error || <><LoaderCircle className="size-4 animate-spin"/> Vérification de ton invitation…</>}</div>}
|
||||||
|
<a href="/" className="block text-center text-xs text-muted-foreground underline">Retour à la connexion</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -37,13 +37,13 @@ export default function LoginPage({ onSigned }) {
|
|||||||
<div className="flex size-11 items-center justify-center rounded-2xl bg-white/15">
|
<div className="flex size-11 items-center justify-center rounded-2xl bg-white/15">
|
||||||
<HardDrive className="size-6" />
|
<HardDrive className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-semibold tracking-tight">Bocage Drive</span>
|
<span className="text-xl font-semibold tracking-tight">GoodDrive</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative space-y-4">
|
<div className="relative space-y-4">
|
||||||
<span className="rounded-full border border-white/20 px-3 py-1 text-xs font-medium">Votre espace privé</span>
|
<span className="rounded-full border border-white/20 px-3 py-1 text-xs font-medium">Votre espace privé</span>
|
||||||
<h2 className="text-4xl font-semibold leading-tight tracking-tight">Vos fichiers.<br />Votre serveur.<br />Votre contrôle.</h2>
|
<h2 className="text-4xl font-semibold leading-tight tracking-tight">Votre espace.<br />Vos fichiers.<br />Pour vous.</h2>
|
||||||
<p className="max-w-xs leading-relaxed text-blue-100">
|
<p className="max-w-xs leading-relaxed text-blue-100">
|
||||||
Un espace de stockage simple et sécurisé pour retrouver, classer et partager vos documents.
|
Un espace pour ceux qui en ont besoin. Des dossiers privés, à partager en confiance avec les personnes invitées.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="relative flex items-center gap-2 text-xs text-white/70">
|
<p className="relative flex items-center gap-2 text-xs text-white/70">
|
||||||
@ -56,7 +56,7 @@ export default function LoginPage({ onSigned }) {
|
|||||||
<span className="drive-brand flex size-10 items-center justify-center rounded-xl text-white">
|
<span className="drive-brand flex size-10 items-center justify-center rounded-xl text-white">
|
||||||
<HardDrive />
|
<HardDrive />
|
||||||
</span>
|
</span>
|
||||||
<b className="text-xl">Bocage Drive</b>
|
<b className="text-xl">GoodDrive</b>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Bon retour 👋</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Bon retour 👋</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Connectez-vous à votre espace de stockage.</p>
|
<p className="mt-2 text-sm text-muted-foreground">Connectez-vous à votre espace de stockage.</p>
|
||||||
|
|||||||
217
frontend/src/pages/MembersPage.jsx
Normal file
217
frontend/src/pages/MembersPage.jsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Check, Copy, KeyRound, Link2, ShieldCheck, Trash2, Users } from 'lucide-react'
|
||||||
|
import { drive, message } from '@/api/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import QuotaField from '@/components/drive/QuotaField'
|
||||||
|
import {fromQuota, toQuota} from '@/lib/quota-utils'
|
||||||
|
import {fmtSize} from '@/lib/drive-format'
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ key: 'can_view_parent', label: 'Voir les fichiers du parent' },
|
||||||
|
{ key: 'can_write_parent', label: 'Modifier les fichiers du parent' },
|
||||||
|
{ key: 'can_invite', label: 'Créer des sous-utilisateurs' },
|
||||||
|
]
|
||||||
|
function Permissions({ value, onChange, disabled = false }) {
|
||||||
|
return <div className="space-y-2">{options.map(opt => <label key={opt.key} className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1 text-sm">
|
||||||
|
<Checkbox disabled={disabled} checked={Boolean(value[opt.key])} onCheckedChange={checked => onChange({ ...value, [opt.key]:Boolean(checked), ...(opt.key==='can_view_parent' && !checked ? {can_write_parent:false} : opt.key==='can_write_parent' && checked ? {can_view_parent:true} : {}) })}/>{opt.label}
|
||||||
|
</label>)}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MembersPage({ controller, user, embedded = false }) {
|
||||||
|
const { spaces, activeSpaceId, loadSpaces, managedSpaceId } = controller
|
||||||
|
const [parentId, setParentId] = useState(managedSpaceId || user.space_id)
|
||||||
|
const [record, setRecord] = useState(null)
|
||||||
|
const [rights, setRights] = useState({can_view_parent:true,can_write_parent:false,can_invite:false})
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [info, setInfo] = useState('')
|
||||||
|
const [expires, setExpires] = useState('7')
|
||||||
|
const [invitationQuota, setInvitationQuota] = useState('')
|
||||||
|
const [rootQuota, setRootQuota] = useState('')
|
||||||
|
const [userQuery, setUserQuery] = useState('')
|
||||||
|
const [userResults, setUserResults] = useState([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState(null)
|
||||||
|
const [directRights, setDirectRights] = useState({can_write:false,can_invite:false})
|
||||||
|
const mine = spaces.find(s => s.id === user.space_id)
|
||||||
|
const managed = spaces.filter(s => !s.is_private && s.can_invite && (!embedded || s.id === managedSpaceId))
|
||||||
|
|
||||||
|
const managedKey = managed.map(space => space.id).join(',')
|
||||||
|
useEffect(() => { if (managedSpaceId) setParentId(managedSpaceId) }, [managedSpaceId])
|
||||||
|
useEffect(() => {
|
||||||
|
if (managed.length && !managed.some(space => space.id === parentId)) setParentId(managed[0].id)
|
||||||
|
}, [managedKey, parentId])
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
if (!managedKey.split(',').includes(String(parentId))) {setRecord(null);return}
|
||||||
|
try { setRecord(await drive.members(parentId)); setError('') }
|
||||||
|
catch (e) { setError(message(e)) }
|
||||||
|
}, [parentId,managedKey])
|
||||||
|
useEffect(() => { reload() }, [reload])
|
||||||
|
useEffect(() => { setTitle(mine?.display_name || '') }, [mine?.display_name])
|
||||||
|
useEffect(() => { setRootQuota(fromQuota(mine?.quota_bytes)) }, [mine?.quota_bytes])
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setBusy(true);setError('');setUrl('')
|
||||||
|
try {
|
||||||
|
const inv = await drive.invite({ ...rights, expires_days:Number(expires), parent_space:parentId, quota_bytes:toQuota(invitationQuota) })
|
||||||
|
setUrl(`${window.location.origin}/invite/${inv.token}`)
|
||||||
|
setInfo('Invitation créée ! Le lien n’est affiché qu’une seule fois : copie-le maintenant.')
|
||||||
|
await reload()
|
||||||
|
} catch(e) { setError(e?.response ? message(e) : (e.message || message(e))) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const change = async (id, data) => {
|
||||||
|
setBusy(true);setError('')
|
||||||
|
try {await drive.memberPatch(id,data);await reload();await loadSpaces();controller.reloadStats();setInfo('Paramètres de l’espace mis à jour.')}
|
||||||
|
catch(e){setError(e?.response ? message(e) : (e.message || message(e)))}finally{setBusy(false)}
|
||||||
|
}
|
||||||
|
const revoke = async id => {
|
||||||
|
setBusy(true)
|
||||||
|
try {await drive.revokeInvite(id);await reload();setInfo('Invitation révoquée.')}
|
||||||
|
catch(e){setError(message(e))}finally{setBusy(false)}
|
||||||
|
}
|
||||||
|
const saveRootQuota = async () => {
|
||||||
|
setBusy(true);setError('')
|
||||||
|
try {
|
||||||
|
await drive.spaceQuota(user.space_id, toQuota(rootQuota))
|
||||||
|
await loadSpaces(); await reload(); controller.reloadStats()
|
||||||
|
setInfo('Limite de stockage enregistrée.')
|
||||||
|
} catch(e) {setError(e?.response ? message(e) : (e.message || message(e)))}
|
||||||
|
finally {setBusy(false)}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAccount?.username === userQuery || userQuery.trim().length < 2) { setUserResults([]); return }
|
||||||
|
let alive = true
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setSearching(true)
|
||||||
|
try { const users = await drive.searchUsers(userQuery.trim(), parentId); if(alive) setUserResults(users) }
|
||||||
|
catch(e) { if(alive) setError(message(e)) }
|
||||||
|
finally { if(alive) setSearching(false) }
|
||||||
|
}, 300)
|
||||||
|
return () => { alive = false; clearTimeout(timer) }
|
||||||
|
}, [userQuery, parentId, selectedAccount?.username])
|
||||||
|
const addExisting = async () => {
|
||||||
|
if (!selectedAccount) return
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
await drive.addUser(selectedAccount.id,parentId,directRights.can_write,directRights.can_invite)
|
||||||
|
setInfo(`Accès accordé à ${selectedAccount.first_name} ${selectedAccount.last_name}`.trim())
|
||||||
|
setSelectedAccount(null);setUserQuery('');setUserResults([])
|
||||||
|
await reload();await loadSpaces()
|
||||||
|
} catch(e) { setError(message(e)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const updateGrant = async (grantId, data) => {
|
||||||
|
setBusy(true);setError('')
|
||||||
|
try { await drive.grantPatch(grantId,data);await reload();setInfo('Droits mis à jour.') }
|
||||||
|
catch(e){setError(message(e))}finally{setBusy(false)}
|
||||||
|
}
|
||||||
|
const revokeGrant = async grantId => {
|
||||||
|
setBusy(true);setError('')
|
||||||
|
try {await drive.grantRemove(grantId);await reload();setInfo('Accès révoqué.')}
|
||||||
|
catch(e){setError(message(e))}finally{setBusy(false)}
|
||||||
|
}
|
||||||
|
const rename = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try {await drive.renameSpace(title);await loadSpaces();setInfo('Nom de ton espace enregistré.')}
|
||||||
|
catch(e){setError(message(e))}finally{setBusy(false)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section className={embedded ? "min-h-0 flex-1 overflow-y-auto p-4 sm:p-6" : "min-h-0 flex-1 overflow-y-auto p-4 sm:p-7"}>
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
{!embedded && <div><h1 className="text-2xl font-semibold">Espaces & invitations</h1></div>}
|
||||||
|
{error && <div className="rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">{error}</div>}
|
||||||
|
{info && <div className="rounded-xl border bg-muted p-3 text-sm">{info}</div>}
|
||||||
|
<div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 font-semibold">Nom de mon espace</h2>
|
||||||
|
<div className="flex flex-wrap gap-2"><Input className="max-w-sm" value={title} onChange={e=>setTitle(e.target.value)} maxLength={120}/><Button disabled={busy||!title.trim()} onClick={rename}>Enregistrer</Button></div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">Le nom visible peut être modifié sans changer le dossier enregistré sur le serveur.</p>
|
||||||
|
</div>
|
||||||
|
{user.is_admin && <div className="rounded-2xl border bg-card p-5 space-y-4">
|
||||||
|
<h2 className="font-semibold">Quota de mon espace racine</h2>
|
||||||
|
<QuotaField id="root-quota" value={rootQuota} onChange={setRootQuota} disabled={busy}/>
|
||||||
|
<p className="text-xs text-muted-foreground">Utilisé avec tous les sous-espaces : {fmtSize(mine?.used_bytes || 0)}. La corbeille compte aussi.</p>
|
||||||
|
<Button variant="outline" disabled={busy} onClick={saveRootQuota}>Enregistrer la limite</Button>
|
||||||
|
</div>}
|
||||||
|
{managed.length > 0 ? <>
|
||||||
|
{managed.length > 1 && <div className="rounded-2xl border bg-card p-5">
|
||||||
|
<label className="block text-sm font-semibold">Espace à gérer
|
||||||
|
<select className="mt-2 w-full rounded-lg border bg-background p-2" value={parentId} onChange={e=>{setParentId(Number(e.target.value));setSelectedAccount(null);setUserQuery('')}}>
|
||||||
|
{managed.map(space=><option key={space.id} value={space.id}>{space.display_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>}
|
||||||
|
<div className="rounded-2xl border bg-card p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 font-semibold"><Users className="size-5 text-primary"/> Ajouter un utilisateur existant</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Cherche son identifiant, vérifie son nom et prénom, puis autorise l'accès à cet espace. Ses fichiers personnels restent privés.</p>
|
||||||
|
<Input value={userQuery} onChange={e=>{setUserQuery(e.target.value);setSelectedAccount(null)}} placeholder="Rechercher par nom d’utilisateur…" aria-label="Rechercher un utilisateur" />
|
||||||
|
{searching && <p className="text-xs text-muted-foreground">Recherche en cours…</p>}
|
||||||
|
{!!userResults.length && <div className="max-h-52 space-y-1 overflow-auto rounded-lg border p-1" role="listbox" aria-label="Utilisateurs trouvés">
|
||||||
|
{userResults.map(account=><button type="button" key={account.id} onClick={()=>{setSelectedAccount(account);setUserQuery(account.username);setUserResults([])}} className="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-muted">
|
||||||
|
<span className="font-medium">{[account.first_name,account.last_name].filter(Boolean).join(' ') || account.username}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">@{account.username}</span>
|
||||||
|
</button>)}
|
||||||
|
</div>}
|
||||||
|
{selectedAccount && <div className="space-y-3 rounded-xl border p-3">
|
||||||
|
<p className="text-sm">Utilisateur sélectionné : <strong>{[selectedAccount.first_name,selectedAccount.last_name].filter(Boolean).join(' ')}</strong> (@{selectedAccount.username})</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm"><Checkbox checked={directRights.can_write} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_write:!!v}))}/> Autoriser les modifications dans cet espace</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm"><Checkbox checked={directRights.can_invite} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_invite:!!v}))}/> Autoriser la gestion des utilisateurs de cet espace</label>
|
||||||
|
<Button disabled={busy} onClick={addExisting}>Ajouter cet utilisateur</Button>
|
||||||
|
</div>}
|
||||||
|
{!!record?.grants?.length && <div className="space-y-2 border-t pt-3"><h3 className="text-sm font-semibold">Utilisateurs ajoutés directement</h3>
|
||||||
|
{record.grants.map(g=><DirectMember key={g.id} grant={g} busy={busy} save={data=>updateGrant(g.id,data)} remove={()=>revokeGrant(g.id)}/>)}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border bg-card p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 font-semibold"><KeyRound className="size-5 text-primary"/> Créer une invitation</div>
|
||||||
|
<Permissions value={rights} onChange={setRights}/>
|
||||||
|
<QuotaField id="invitation-quota" value={invitationQuota} onChange={setInvitationQuota} disabled={busy}/>
|
||||||
|
<label className="block text-sm">Expiration du lien
|
||||||
|
<select value={expires} onChange={e=>setExpires(e.target.value)} className="ml-3 rounded-lg border bg-background px-2 py-1"><option value="1">1 jour</option><option value="7">7 jours</option><option value="14">14 jours</option><option value="30">30 jours</option></select>
|
||||||
|
</label>
|
||||||
|
<Button onClick={create} disabled={busy}><Link2/> Générer une invitation</Button>
|
||||||
|
{url && <div className="rounded-xl border bg-muted/50 p-3"><p className="mb-2 text-xs font-medium">Lien privé, à transmettre à la personne invitée</p><div className="flex gap-2"><Input readOnly value={url}/><Button variant="outline" onClick={async()=>{try{await navigator.clipboard.writeText(url);setInfo('Lien copié.')}catch{setInfo('Sélectionne et copie le lien affiché.')}}}><Copy/></Button></div></div>}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border bg-card p-5 space-y-4"><h2 className="flex items-center gap-2 font-semibold"><Users className="size-5 text-primary"/> Sous-utilisateurs et sous-espaces</h2>
|
||||||
|
{!record?.children?.length && <p className="text-sm text-muted-foreground">Aucun sous-utilisateur dans cet espace.</p>}
|
||||||
|
{record?.children?.map(child=><MemberRow key={child.id} child={child} busy={busy} save={data=>change(child.id,data)} onInvalid={setError}/>)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 flex items-center gap-2 font-semibold"><ShieldCheck className="size-5 text-primary"/> Invitations</h2>
|
||||||
|
<div className="space-y-2">{record?.invitations?.map(inv=><div key={inv.id} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm"><span>Créée le {new Date(inv.created_at).toLocaleDateString('fr-FR')} · {inv.quota_bytes == null ? 'Sans limite dédiée' : fmtSize(inv.quota_bytes)} · {inv.used?'Utilisée':inv.revoked?'Révoquée':new Date(inv.expires_at)<new Date()?'Expirée':'En attente'}</span>{!inv.used&&!inv.revoked&&<Button size="icon-sm" variant="ghost" title="Révoquer" disabled={busy} onClick={()=>revoke(inv.id)}><Trash2 className="size-4"/></Button>}</div>)}</div>
|
||||||
|
</div>
|
||||||
|
</> : <p className="rounded-2xl border bg-card p-5 text-sm text-muted-foreground">La création de sous-utilisateurs n’est pas autorisée pour ton compte.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberRow({child,save,busy,onInvalid}) {
|
||||||
|
const [rights,setRights]=useState({can_view_parent:child.can_view_parent,can_write_parent:child.can_write_parent,can_invite:child.can_invite})
|
||||||
|
const [quota,setQuota]=useState(fromQuota(child.quota_bytes))
|
||||||
|
useEffect(()=>{setRights({can_view_parent:child.can_view_parent,can_write_parent:child.can_write_parent,can_invite:child.can_invite});setQuota(fromQuota(child.quota_bytes))},[child])
|
||||||
|
if (!child.has_account) return <div className="rounded-xl border p-4">
|
||||||
|
<div className="text-sm font-semibold">{child.display_name}</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Espace partagé indépendant · paramètres accessibles par clic droit dans « Espaces partagés ».</p>
|
||||||
|
</div>
|
||||||
|
return <div className="rounded-xl border p-4"><div className="mb-2 flex flex-wrap items-center justify-between gap-2"><div><div className="font-medium">{child.display_name}</div><div className="text-xs text-muted-foreground">{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}</div></div><div className="flex gap-2"><Button size="sm" variant="outline" disabled={busy} onClick={()=>{try{save({...rights,...(quota === fromQuota(child.quota_bytes) ? {} : {quota_bytes:toQuota(quota)})})}catch(e){onInvalid(e.message)}}}><Check className="size-4"/> Appliquer</Button><Button size="sm" variant="outline" disabled={busy} onClick={()=>save({is_active:!child.is_active})}>{child.is_active?'Suspendre':'Réactiver'}</Button></div></div><Permissions value={rights} onChange={setRights}/>
|
||||||
|
<div className="mt-3 border-t pt-3 space-y-3"><QuotaField id={`quota-${child.id}`} value={quota} onChange={setQuota} disabled={busy}/>
|
||||||
|
<p className="text-xs text-muted-foreground">Utilisé : {fmtSize(child.used_bytes || 0)}{child.reserved_bytes > 0 ? ` · Transferts réservés : ${fmtSize(child.reserved_bytes)}` : ''} · Disponible : {child.available_bytes == null ? 'Sans limite' : fmtSize(child.available_bytes)}</p>
|
||||||
|
</div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function DirectMember({grant,save,remove,busy}) {
|
||||||
|
const [canWrite,setCanWrite] = useState(grant.can_write)
|
||||||
|
const [canInvite,setCanInvite] = useState(grant.can_invite)
|
||||||
|
useEffect(()=>{setCanWrite(grant.can_write);setCanInvite(grant.can_invite)},[grant])
|
||||||
|
return <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3">
|
||||||
|
<div className="min-w-36"><div className="text-sm font-semibold">{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}</div><div className="text-xs text-muted-foreground">@{grant.username}</div></div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-xs"><Checkbox checked={canWrite} onCheckedChange={v=>setCanWrite(!!v)}/> Écriture</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs"><Checkbox checked={canInvite} onCheckedChange={v=>setCanInvite(!!v)}/> Invitations</label>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={()=>save({can_write:canWrite,can_invite:canInvite})}>Enregistrer</Button>
|
||||||
|
<Button size="sm" variant="ghost" disabled={busy} onClick={remove} title="Retirer l’accès"><Trash2 className="size-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
27
frontend/tests/drive-fixes.test.js
Normal file
27
frontend/tests/drive-fixes.test.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
const source = f => readFileSync(new URL(`../${f}`, import.meta.url),'utf8')
|
||||||
|
|
||||||
|
test('all APIs and signed previews use the same Vite proxy', () => {
|
||||||
|
assert.match(source('vite.config.js'), /['"]\/api['"]\s*:/)
|
||||||
|
assert.match(source('src/api/client.js'), /baseURL:\s*['"]\/api['"]/)
|
||||||
|
assert.doesNotMatch(source('src/api/client.js'), /hostname\}:1029/)
|
||||||
|
})
|
||||||
|
test('a transient error never logs out users, refresh has a single promise', () => {
|
||||||
|
const client=source('src/api/client.js')
|
||||||
|
assert.match(client, /refreshPromise/)
|
||||||
|
assert.match(client, /if \(authError\(refreshError\)\)/)
|
||||||
|
assert.match(source('src/App.jsx'), /connectionError/)
|
||||||
|
})
|
||||||
|
test('space management and invitations use context dialogs, not a dedicated navigation tab', () => {
|
||||||
|
assert.doesNotMatch(source('src/config/navigation.js'), /members|invitations/i)
|
||||||
|
assert.match(source('src/components/drive/DriveDialogs.jsx'), /MembersDialog/)
|
||||||
|
assert.match(source('src/components/drive/ContextActions.jsx'), /Accès et invitations/)
|
||||||
|
assert.match(source('src/components/drive/DriveSidebar.jsx'), /onContextMenu/)
|
||||||
|
})
|
||||||
|
test('previews are checked before showing them and PDF can load in iframe', () => {
|
||||||
|
assert.match(source('src/hooks/useDriveController.js'), /method:'HEAD'/)
|
||||||
|
assert.match(source('src/components/drive/dialogs/PreviewDialog.jsx'), /<iframe/)
|
||||||
|
assert.match(source('../backend/drive/views_content.py'), /xframe_options_sameorigin/)
|
||||||
|
})
|
||||||
19
frontend/tests/quotas.test.js
Normal file
19
frontend/tests/quotas.test.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {fromQuota,toQuota} from '../src/lib/quota-utils.js'
|
||||||
|
|
||||||
|
test('Un quota illimité est envoyé en null',()=>{
|
||||||
|
assert.equal(toQuota(''),null)
|
||||||
|
assert.equal(fromQuota(null),'')
|
||||||
|
})
|
||||||
|
test('Les Go deviennent des octets entiers',()=>{
|
||||||
|
assert.equal(toQuota('10'),10737418240)
|
||||||
|
assert.equal(toQuota('1,5'),1610612736)
|
||||||
|
assert.equal(toQuota('0'),0)
|
||||||
|
assert.equal(fromQuota(1610612736),'1.5')
|
||||||
|
})
|
||||||
|
test('Les tailles malformées ou imprécises sont refusées',()=>{
|
||||||
|
for(const value of ['-3','1e10','1.1234','abc','Infinity','10000000000']){
|
||||||
|
assert.throws(()=>toQuota(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
31
frontend/tests/simplification.test.js
Normal file
31
frontend/tests/simplification.test.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const src = file => readFileSync(new URL(`../src/${file}`, import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('navigation GoodDrive sans Récents ni Favoris', () => {
|
||||||
|
const nav = src('config/navigation.js')
|
||||||
|
assert.doesNotMatch(nav, /recent|starred|Récents|Favoris/)
|
||||||
|
assert.doesNotMatch(nav, /Espaces & invitations/)
|
||||||
|
assert.match(src('components/drive/ContextActions.jsx'), /Accès et invitations/)
|
||||||
|
assert.match(nav, /Corbeille/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inscription sur invitation : soumission réelle et formulaire minimal', () => {
|
||||||
|
const page = src('pages/InvitationPage.jsx')
|
||||||
|
assert.match(page, /<form onSubmit=\{submit\}/)
|
||||||
|
assert.match(page, /<Button type="submit"/)
|
||||||
|
assert.doesNotMatch(page, /Permissions reçues/)
|
||||||
|
assert.match(page, /drive\.inviteAccept\(token/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aucune action favorite dans la liste, la fenêtre ou le contrôleur', () => {
|
||||||
|
for (const file of [
|
||||||
|
'components/drive/FileList.jsx',
|
||||||
|
'components/drive/dialogs/ItemActionsDialog.jsx',
|
||||||
|
'hooks/useDriveController.js',
|
||||||
|
]) {
|
||||||
|
assert.doesNotMatch(src(file), /is_starred|handleAction\('star'\)/, file)
|
||||||
|
}
|
||||||
|
})
|
||||||
29
frontend/tests/transfer-metrics.test.js
Normal file
29
frontend/tests/transfer-metrics.test.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { currentTransferSpeed, formatDuration, recordTransferProgress, resetTransferRate } from '../src/lib/transfer-metrics.js'
|
||||||
|
|
||||||
|
test('débit en octets/s et ETA restant calculés avec les octets transmis', () => {
|
||||||
|
const job = { status: 'uploading', size: 1_000_000, sent: 0 }
|
||||||
|
resetTransferRate(job, 1000)
|
||||||
|
recordTransferProgress(job, 100_000, 2000)
|
||||||
|
assert.equal(job.speedBps, 100_000)
|
||||||
|
assert.equal(job.etaSeconds, 9)
|
||||||
|
assert.equal(currentTransferSpeed(job, 2500), 100_000)
|
||||||
|
assert.equal(currentTransferSpeed(job, 7001), 0)
|
||||||
|
recordTransferProgress(job, 300_000, 3000)
|
||||||
|
assert.equal(job.speedBps, 165_000)
|
||||||
|
assert.equal(job.etaSeconds, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annulation/pause et temps restant formatés correctement', () => {
|
||||||
|
const job = { status: 'uploading', size: 100, sent: 40, speedBps: 50, etaSeconds: 2 }
|
||||||
|
resetTransferRate(job, 2000)
|
||||||
|
assert.equal(job.speedBps, 0)
|
||||||
|
assert.equal(job.etaSeconds, null)
|
||||||
|
assert.equal(formatDuration(null), 'Calcul…')
|
||||||
|
assert.equal(formatDuration(72), '1 min 12 s')
|
||||||
|
assert.equal(formatDuration(3705), '1 h 01 min')
|
||||||
|
job.status = 'paused'
|
||||||
|
job.speedBps = 50
|
||||||
|
assert.equal(currentTransferSpeed(job, 2100), 0)
|
||||||
|
})
|
||||||
40
frontend/tests/transfers-live.test.js
Normal file
40
frontend/tests/transfers-live.test.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
const src = path => readFileSync(new URL(`../src/${path}`, import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('chaque import passe par le protocole fractionné avec progression en octets', () => {
|
||||||
|
const manager = src('hooks/useUploadQueue.js')
|
||||||
|
const api = src('api/client.js')
|
||||||
|
assert.match(manager, /onUploadProgress: event/)
|
||||||
|
assert.match(manager, /Math\.min\(part\.size, event\.loaded/)
|
||||||
|
assert.match(manager, /drive\.startUpload\(/)
|
||||||
|
assert.match(manager, /drive\.uploadStatus\(/)
|
||||||
|
assert.match(manager, /drive\.uploadChunk\(/)
|
||||||
|
assert.match(manager, /drive\.finishUpload\(/)
|
||||||
|
assert.match(manager, /drive\.cancelUpload\(/)
|
||||||
|
assert.match(manager, /requestRef\.current\?\.abort\(\)/)
|
||||||
|
assert.match(api, /cancelUpload:id=>api\.delete/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('un rafraichissement affiche un avertissement tant qu’un import reste en cours', () => {
|
||||||
|
const manager = src('hooks/useUploadQueue.js')
|
||||||
|
assert.match(manager, /addEventListener\('beforeunload'/)
|
||||||
|
assert.match(manager, /event\.preventDefault\(\)/)
|
||||||
|
assert.match(manager, /event\.returnValue = ''/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('les dossiers partagés sont actualisés silencieusement sans rechargement', () => {
|
||||||
|
const controller = src('hooks/useDriveController.js')
|
||||||
|
assert.match(controller, /setInterval\(poll, 4000\)/)
|
||||||
|
assert.match(controller, /document\.visibilityState/)
|
||||||
|
assert.match(controller, /refresh\(true\)/)
|
||||||
|
assert.match(controller, /requestSequence\.current/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('les longs titres et les boutons du menu ne débordent plus', () => {
|
||||||
|
const dialog = src('components/drive/dialogs/ItemActionsDialog.jsx')
|
||||||
|
assert.match(dialog, /break-all/)
|
||||||
|
assert.match(dialog, /grid-cols-1/)
|
||||||
|
assert.match(dialog, /min-w-0/)
|
||||||
|
})
|
||||||
75
frontend/tests/upload-multipart.test.js
Normal file
75
frontend/tests/upload-multipart.test.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
|
||||||
|
// The live client uses Axios. This isolated test checks its request construction
|
||||||
|
// without needing a browser or an installed dependency tree.
|
||||||
|
const clientSource = await readFile(new URL('../src/api/client.js', import.meta.url), 'utf8')
|
||||||
|
const calls = []
|
||||||
|
const clients = []
|
||||||
|
|
||||||
|
globalThis.window ??= { location: { hostname: 'localhost' } }
|
||||||
|
globalThis.__goodDriveTestAxios = {
|
||||||
|
create(defaults) {
|
||||||
|
const instance = {
|
||||||
|
defaults,
|
||||||
|
interceptors: { request: { use() {} }, response: { use() {} } },
|
||||||
|
async post(url, body, config) {
|
||||||
|
calls.push({ url, body, config, defaults })
|
||||||
|
if (url.endsWith('/uploads/start/')) return { data: { id: 'test-upload-id', offset: 0 } }
|
||||||
|
if (url.endsWith('/chunk/')) {
|
||||||
|
return { data: { offset: Number(body.get('offset')) + body.get('part').size } }
|
||||||
|
}
|
||||||
|
return { data: { items: [] } }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
clients.push(instance)
|
||||||
|
return instance
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(clientSource, /import axios from ['"]axios['"]/, 'Unexpected Axios import')
|
||||||
|
const sourceWithMock = clientSource.replace(/import axios from ['"]axios['"]/, 'const axios = globalThis.__goodDriveTestAxios')
|
||||||
|
const { drive } = await import(`data:text/javascript;base64,${Buffer.from(sourceWithMock).toString('base64')}`)
|
||||||
|
|
||||||
|
function assertMultipartRequest(call) {
|
||||||
|
assert.ok(call.body instanceof FormData, `${call.url} must send FormData`)
|
||||||
|
assert.ok(
|
||||||
|
!Object.keys(call.defaults.headers ?? {}).some(key => key.toLowerCase() === 'content-type'),
|
||||||
|
'Do not force application/json globally; let Axios/browser set multipart boundary',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
!Object.keys(call.config?.headers ?? {}).some(key => key.toLowerCase() === 'content-type' && call.config.headers[key] === 'application/json'),
|
||||||
|
'Do not override multipart with application/json on this request',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('small-file upload sends FormData, with no global JSON content type', async () => {
|
||||||
|
calls.length = 0
|
||||||
|
const file = new File(['abc'], 'demo.txt', { type: 'text/plain' })
|
||||||
|
await drive.upload([file], 12, 7)
|
||||||
|
const upload = calls.find(call => call.url === '/drive/uploads/')
|
||||||
|
assert.ok(upload)
|
||||||
|
assertMultipartRequest(upload)
|
||||||
|
assert.equal(upload.body.get('files').name, 'demo.txt')
|
||||||
|
assert.equal(upload.body.get('parent'), '12')
|
||||||
|
assert.equal(upload.body.get('space'), '7')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resumable upload sends JSON metadata and multipart binary chunks', async () => {
|
||||||
|
calls.length = 0
|
||||||
|
const file = new File([new Uint8Array(8 * 1024 * 1024 + 3)], 'video.bin')
|
||||||
|
await drive.uploadResumable(file, null, 7)
|
||||||
|
const start = calls.find(call => call.url === '/drive/uploads/start/')
|
||||||
|
const parts = calls.filter(call => call.url.endsWith('/chunk/'))
|
||||||
|
const finish = calls.find(call => call.url.endsWith('/finish/'))
|
||||||
|
assert.ok(start)
|
||||||
|
assert.equal(start.body.name, 'video.bin')
|
||||||
|
assert.equal(start.body.size, file.size)
|
||||||
|
assert.ok(!(start.body instanceof FormData))
|
||||||
|
assert.equal(parts.length, 2)
|
||||||
|
for (const part of parts) assertMultipartRequest(part)
|
||||||
|
assert.equal(parts[0].body.get('part').size, 8 * 1024 * 1024)
|
||||||
|
assert.equal(parts[1].body.get('part').size, 3)
|
||||||
|
assert.ok(finish)
|
||||||
|
})
|
||||||
@ -15,6 +15,17 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 1028,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_PROXY_TARGET || 'http://backend:1029',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
preview: {
|
preview: {
|
||||||
allowedHosts: [
|
allowedHosts: [
|
||||||
'boulangerie.louismazin.ovh',
|
'boulangerie.louismazin.ovh',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user