This commit is contained in:
Louis Mazin 2026-10-13 23:47:19 +02:00
parent 97a25e4df8
commit ac44c6ed8b
55 changed files with 3365 additions and 490 deletions

View File

@ -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.

View 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.

View File

@ -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()),

View File

@ -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.

View 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')),
]

View File

@ -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'),
),
]

View 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)),
]

View 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 = [
]

View 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),
]

View 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'),
),
]

View 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))]

View File

@ -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 = [
]

View File

@ -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
View 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.'
)

View File

@ -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
View 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()

View File

@ -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)

View File

@ -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),

View File

@ -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

View File

@ -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

View File

@ -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)

View 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))

View File

@ -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
]})

View File

@ -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)}} />

View File

@ -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.'}

View 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 })
}

View File

@ -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} />
</> </>
} }

View File

@ -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>
) )

View File

@ -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>
) )
} }

View File

@ -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>

View File

@ -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>
) )

View File

@ -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') }}

View 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>
}

View 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>
)
}

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View 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>
}

View File

@ -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>
} }

View File

@ -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>
}

View File

@ -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',
}

View File

@ -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,
} }
} }

View 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) }
}

View 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
}

View 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`
}

View File

@ -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>
) )
} }

View 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>
}

View File

@ -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>

View 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>
}

View 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/)
})

View 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))
}
})

View 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)
}
})

View 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)
})

View 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/)
})

View 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)
})

View File

@ -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',