nice
This commit is contained in:
parent
ac44c6ed8b
commit
ef5b14d084
@ -1,42 +0,0 @@
|
||||
# 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.
|
||||
@ -14,4 +14,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . /app/
|
||||
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:1029"]
|
||||
CMD ["sh", "/app/entrypoint.sh"]
|
||||
45
backend/drive/management/commands/ensure_admin.py
Normal file
45
backend/drive/management/commands/ensure_admin.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""Create the initial superuser from environment, without ever updating another."""
|
||||
from django.db.models import Q
|
||||
from decouple import config
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Crée le premier administrateur depuis ADMIN_* si aucun administrateur n’existe.'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
User = get_user_model()
|
||||
if User.objects.filter(Q(is_superuser=True) | Q(is_staff=True)).exists():
|
||||
self.stdout.write('Administrateur déjà présent : aucune modification.')
|
||||
return
|
||||
|
||||
username = config('ADMIN_USERNAME', default='').strip()
|
||||
password = config('ADMIN_PASSWORD', default='')
|
||||
first_name = config('ADMIN_FIRST_NAME', default='').strip()
|
||||
last_name = config('ADMIN_LAST_NAME', default='').strip()
|
||||
email = config('ADMIN_EMAIL', default='').strip()
|
||||
if not username or not password:
|
||||
self.stdout.write(self.style.WARNING('ADMIN_USERNAME ou ADMIN_PASSWORD absent : création ignorée.'))
|
||||
return
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if User.objects.select_for_update().filter(Q(is_superuser=True) | Q(is_staff=True)).exists():
|
||||
return
|
||||
if User.objects.filter(username=username).exists():
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'Identifiant déjà utilisé : compte existant inchangé, création ignorée.'))
|
||||
return
|
||||
User.objects.create_superuser(
|
||||
username=username, password=password, email=email,
|
||||
first_name=first_name, last_name=last_name,
|
||||
)
|
||||
except IntegrityError:
|
||||
# Another container could have initialized the superuser in parallel.
|
||||
if User.objects.filter(Q(is_superuser=True) | Q(is_staff=True)).exists():
|
||||
self.stdout.write('Administrateur déjà initialisé par un autre processus.')
|
||||
return
|
||||
raise
|
||||
self.stdout.write(self.style.SUCCESS('Administrateur initial créé.'))
|
||||
@ -526,3 +526,99 @@ class InterruptedUploadTests(HierarchicalDriveTests):
|
||||
result=self.client.patch(f'/api/drive/spaces/{self.root}/quota/',
|
||||
{'quota_bytes':999},format='json')
|
||||
self.assertEqual(result.status_code,400,result.data)
|
||||
|
||||
|
||||
class SharedSpaceDeletionTests(TestCase):
|
||||
"""Deleting managed spaces may never erase an attached user's data."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
override = override_settings(DRIVE_STORAGE_ROOT=Path(self.tmp.name),
|
||||
FILE_UPLOAD_TEMP_DIR=str(Path(self.tmp.name) / 'tmp'))
|
||||
override.enable()
|
||||
self.addCleanup(override.disable)
|
||||
self.admin = get_user_model().objects.create_superuser('admin', 'admin@example.test', 'Strong-pass123!')
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.admin)
|
||||
self.root = self.client.get('/api/auth/me/').data['space_id']
|
||||
|
||||
def create_space(self, name, parent=None):
|
||||
reply = self.client.post('/api/drive/spaces/create/',
|
||||
{'display_name': name, 'parent_space': parent or self.root}, format='json')
|
||||
self.assertEqual(reply.status_code, 201, reply.data)
|
||||
return reply.data['id']
|
||||
|
||||
def delete(self, space_id, confirmed):
|
||||
return self.client.delete(f'/api/drive/spaces/{space_id}/',
|
||||
{'confirm_name': confirmed}, format='json')
|
||||
|
||||
def test_deleting_managed_subtree_removes_files_and_uploads(self):
|
||||
from .models import UploadSession
|
||||
parent = self.create_space('Projet éphémère')
|
||||
child = self.create_space('Sous-projet', parent)
|
||||
uploaded = self.client.post('/api/drive/uploads/',
|
||||
{'space': child, 'files': [SimpleUploadedFile('notes.txt', b'bonjour')]}, format='multipart')
|
||||
self.assertEqual(uploaded.status_code, 201, uploaded.data)
|
||||
item = DriveItem.objects.get(pk=uploaded.data['items'][0]['id'])
|
||||
path = Path(self.tmp.name) / item.storage_location
|
||||
self.assertTrue(path.exists())
|
||||
UploadSession.objects.create(owner=self.admin, space_id=child,
|
||||
name='inachevé.zip', total_size=1024)
|
||||
parent_path = Path(self.tmp.name) / DriveSpace.objects.get(pk=parent).relative_path
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
result = self.delete(parent, 'Projet éphémère')
|
||||
self.assertEqual(result.status_code, 200, result.data)
|
||||
self.assertFalse(DriveSpace.objects.filter(pk__in=[parent, child]).exists())
|
||||
self.assertFalse(DriveItem.objects.filter(pk=item.pk).exists())
|
||||
self.assertFalse(parent_path.exists())
|
||||
|
||||
def test_refuses_wrong_confirmation_root_and_attached_user_space(self):
|
||||
managed = self.create_space('Confidentiel')
|
||||
self.assertEqual(self.delete(managed, 'autre').status_code, 400)
|
||||
self.assertEqual(self.delete(self.root, 'Racine').status_code, 403)
|
||||
from .spaces import ensure_private_space, create_space
|
||||
assigned = create_space(get_user_model().objects.create_user('alice', password='Strong-pass123!'),
|
||||
DriveSpace.objects.get(pk=managed), 'Alice')
|
||||
ensure_private_space(assigned.user)
|
||||
response = self.delete(managed, 'Confidentiel')
|
||||
self.assertEqual(response.status_code, 409, response.data)
|
||||
self.assertTrue(DriveSpace.objects.filter(pk=managed).exists())
|
||||
self.assertTrue(DriveSpace.objects.filter(pk=assigned.pk).exists())
|
||||
|
||||
def test_non_manager_cannot_delete_managed_space(self):
|
||||
managed = self.create_space('Team')
|
||||
alice = get_user_model().objects.create_user('alice', password='Strong-pass123!')
|
||||
self.client.force_authenticate(user=alice)
|
||||
self.assertEqual(self.delete(managed, 'Team').status_code, 403)
|
||||
|
||||
|
||||
class BootstrapAdminCommandTests(TestCase):
|
||||
def test_bootstraps_from_env_only_when_no_superuser_exists(self):
|
||||
from unittest.mock import patch
|
||||
from django.core.management import call_command
|
||||
env = {'ADMIN_USERNAME': 'rootuser', 'ADMIN_PASSWORD': 'Safe-pass123!',
|
||||
'ADMIN_FIRST_NAME': 'Ada', 'ADMIN_LAST_NAME': 'Test',
|
||||
'ADMIN_EMAIL': 'root@example.test'}
|
||||
with patch.dict('os.environ', env):
|
||||
call_command('ensure_admin')
|
||||
account = get_user_model().objects.get(username='rootuser')
|
||||
self.assertTrue(account.is_superuser)
|
||||
self.assertTrue(account.is_staff)
|
||||
self.assertEqual(account.first_name, 'Ada')
|
||||
self.assertTrue(account.check_password('Safe-pass123!'))
|
||||
with patch.dict('os.environ', {'ADMIN_PASSWORD': 'Different-pass!'}):
|
||||
call_command('ensure_admin')
|
||||
account.refresh_from_db()
|
||||
self.assertTrue(account.check_password('Safe-pass123!'))
|
||||
self.assertEqual(get_user_model().objects.filter(is_superuser=True).count(), 1)
|
||||
|
||||
def test_existing_username_is_not_promoted_without_permission(self):
|
||||
from unittest.mock import patch
|
||||
from django.core.management import call_command
|
||||
user = get_user_model().objects.create_user('rootuser', password='Current-pass123!')
|
||||
with patch.dict('os.environ', {'ADMIN_USERNAME': 'rootuser', 'ADMIN_PASSWORD': 'Other-pass123!'}):
|
||||
call_command('ensure_admin')
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_superuser)
|
||||
self.assertTrue(user.check_password('Current-pass123!'))
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
"""Invitation-only registration and hierarchical space management."""
|
||||
import secrets
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from datetime import timedelta
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
@ -10,7 +15,7 @@ from rest_framework.decorators import api_view, permission_classes, throttle_cla
|
||||
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 .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession
|
||||
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
|
||||
@ -340,11 +345,13 @@ def create_shared_space(request):
|
||||
return Response(space_info(space,request.user),status=201)
|
||||
|
||||
|
||||
@api_view(['PATCH'])
|
||||
@api_view(['PATCH','DELETE'])
|
||||
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)
|
||||
if request.method == 'DELETE':
|
||||
return delete_shared_space(request, space_id)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if 'display_name' in request.data:
|
||||
@ -356,3 +363,79 @@ def edit_shared_space(request,space_id):
|
||||
_update_quota(space,request.data['quota_bytes'])
|
||||
except ValueError as exc:return error(str(exc))
|
||||
return Response(space_info(space,request.user))
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids):
|
||||
"""Executed only AFTER commit: keep SQL changes atomic before touching disk."""
|
||||
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
|
||||
folder = (root / relative_path).resolve()
|
||||
if folder == root or not folder.is_relative_to(root):
|
||||
_logger.error('Invalid space path for disk cleanup: %s', relative_path)
|
||||
return
|
||||
# Old files may predate the numbered storage directories.
|
||||
for location in legacy_locations:
|
||||
path = (root / location).resolve()
|
||||
if path.is_relative_to(root) and not path.is_relative_to(folder):
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
_logger.exception('Unable to remove legacy file: %s', path)
|
||||
try:
|
||||
if folder.exists():
|
||||
shutil.rmtree(folder)
|
||||
except OSError:
|
||||
_logger.exception('Unable to remove deleted space directory: %s', folder)
|
||||
temporary_root = Path(settings.FILE_UPLOAD_TEMP_DIR).resolve()
|
||||
for upload_id in temp_ids:
|
||||
try:
|
||||
(temporary_root / f'drive-{upload_id}.part').unlink(missing_ok=True)
|
||||
except OSError:
|
||||
_logger.exception('Unable to remove canceled upload %s', upload_id)
|
||||
|
||||
|
||||
def delete_shared_space(request, space_id):
|
||||
"""Permanently delete a manually managed space and its independent descendants.
|
||||
|
||||
Never delete root, assigned account spaces, or ANY private space, including a
|
||||
private descendant. All cleanup is committed before disk deletion starts.
|
||||
"""
|
||||
confirm_name = request.data.get('confirm_name')
|
||||
if not isinstance(confirm_name, str):
|
||||
return error('Confirme la suppression en saisissant le nom exact de l’espace.')
|
||||
with transaction.atomic():
|
||||
try:
|
||||
space = DriveSpace.objects.select_for_update().select_related('parent').get(pk=space_id)
|
||||
except DriveSpace.DoesNotExist:
|
||||
return error('Espace introuvable.', 404)
|
||||
if not space.parent_id or space.user_id or space.private_owner_id or not space.managed_by_id:
|
||||
return error('Les espaces personnels, attribués et racine ne peuvent pas être supprimés.', 403)
|
||||
if not manager(request.user, space):
|
||||
return error('Suppression interdite.', 403)
|
||||
if confirm_name != space.display_name:
|
||||
return error('Le nom de confirmation ne correspond pas au nom de l’espace.')
|
||||
# Also lock the parent: creation and deletion of siblings are serialized.
|
||||
list(DriveSpace.objects.select_for_update().filter(pk=space.parent_id))
|
||||
descendants = list(DriveSpace.objects.select_for_update().filter(
|
||||
Q(relative_path=space.relative_path) |
|
||||
Q(relative_path__startswith=space.relative_path + '/')
|
||||
).order_by('-relative_path'))
|
||||
if any(child.user_id or child.private_owner_id or not child.managed_by_id for child in descendants):
|
||||
return error('Cet espace contient un espace attribué ou privé. Réorganise les sous-utilisateurs avant de le supprimer.', 409)
|
||||
ids = [child.pk for child in descendants]
|
||||
files = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list(
|
||||
'storage_location', 'storage_key'))
|
||||
legacy_locations = [location or f'{key[:2]}/{key}'
|
||||
for location, key in files if key and not location]
|
||||
upload_ids = list(UploadSession.objects.select_for_update().filter(
|
||||
space_id__in=ids).values_list('pk', flat=True))
|
||||
# Children of DriveItem use CASCADE; item ownership and other accounts
|
||||
# are left intact, only metadata from these owned spaces is deleted.
|
||||
DriveItem.objects.filter(space_id__in=ids).delete()
|
||||
for child in descendants:
|
||||
child.delete() # bottom-up, as DriveSpace.parent uses PROTECT
|
||||
transaction.on_commit(lambda: _remove_deleted_space_files(
|
||||
space.relative_path, legacy_locations, upload_ids))
|
||||
return Response({'deleted_space_ids': ids})
|
||||
|
||||
@ -89,6 +89,7 @@ export const drive={
|
||||
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),
|
||||
deleteSpace:(id,confirm_name)=>api.delete(`/drive/spaces/${id}/`,{data:{confirm_name}}).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),
|
||||
|
||||
@ -22,6 +22,7 @@ export const fileOptions = (item, trash) => {
|
||||
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},
|
||||
...(space.has_account || space.is_private || !space.parent_id ? [] : [{id:'delete',title:'Supprimer l’espace',icon:Trash2,danger:true}]),
|
||||
] : []
|
||||
|
||||
/** Keyboard-accessible viewport-clamped menu used on desktop right-click and mobile ···. */
|
||||
|
||||
@ -4,6 +4,7 @@ import DeleteDialog from './dialogs/DeleteDialog'
|
||||
import PreviewDialog from './dialogs/PreviewDialog'
|
||||
import SpaceSettingsDialog from './dialogs/SpaceSettingsDialog'
|
||||
import MembersDialog from './dialogs/MembersDialog'
|
||||
import DeleteSpaceDialog from './dialogs/DeleteSpaceDialog'
|
||||
|
||||
/** Groups the Drive's existing dialogs without coupling them to the page markup. */
|
||||
export default function DriveDialogs({ controller, user }) {
|
||||
@ -13,6 +14,7 @@ export default function DriveDialogs({ controller, user }) {
|
||||
<DeleteDialog controller={controller} />
|
||||
<PreviewDialog controller={controller} />
|
||||
<SpaceSettingsDialog controller={controller} />
|
||||
<DeleteSpaceDialog controller={controller} />
|
||||
<MembersDialog controller={controller} user={user} />
|
||||
</>
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
||||
const space=spaceMenu?.space
|
||||
if(!space)return
|
||||
if(command==='manage')setSpaceDialog({mode:'manage',spaceId:space.id})
|
||||
if(command==='delete')setSpaceDialog({mode:'delete',spaceId:space.id})
|
||||
if(command==='members'){
|
||||
setManagedSpaceId(space.id)
|
||||
|
||||
|
||||
72
frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx
Normal file
72
frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, Trash2 } from 'lucide-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'
|
||||
|
||||
/** Destructive operation: never allow deletion without typing the virtual name. */
|
||||
export default function DeleteSpaceDialog({controller: c}) {
|
||||
const {spaceDialog, setSpaceDialog, spaces, activeSpaceId, switchSpace, loadSpaces, setError, setNote} = c
|
||||
const isOpen = spaceDialog?.mode === 'delete'
|
||||
const space = spaces.find(s => s.id === spaceDialog?.spaceId)
|
||||
const [confirmation, setConfirmation] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [localError, setLocalError] = useState('')
|
||||
useEffect(() => {
|
||||
setConfirmation('')
|
||||
setLocalError('')
|
||||
}, [spaceDialog?.spaceId, isOpen])
|
||||
|
||||
async function remove(event) {
|
||||
event.preventDefault()
|
||||
if (!space || confirmation !== space.display_name || busy) return
|
||||
setBusy(true)
|
||||
setLocalError('')
|
||||
try {
|
||||
const response = await drive.deleteSpace(space.id, confirmation)
|
||||
const deleted = response.deleted_space_ids || []
|
||||
if (deleted.includes(activeSpaceId)) {
|
||||
const mine = spaces.find(s => s.is_private)
|
||||
if (mine) switchSpace(mine.id)
|
||||
}
|
||||
setSpaceDialog(null)
|
||||
await loadSpaces()
|
||||
setNote('Espace supprimé définitivement.')
|
||||
} catch (exception) {
|
||||
const detail = message(exception)
|
||||
setLocalError(detail)
|
||||
setError(detail)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <Dialog open={isOpen} onOpenChange={open => {if (!open && !busy) setSpaceDialog(null)}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive"><Trash2 className="size-5"/> Supprimer l’espace</DialogTitle>
|
||||
<DialogDescription>
|
||||
L’espace « {space?.display_name} », ses fichiers, sa corbeille, ses transferts et ses sous-espaces autonomes seront supprimés définitivement.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-destructive"/>
|
||||
<p>Action irréversible. Si l’espace contient des sous-utilisateurs avec leur propre compte, la suppression sera bloquée pour protéger leurs données privées.</p>
|
||||
</div>
|
||||
<form onSubmit={remove} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-delete-space">Tape « {space?.display_name} » pour confirmer</Label>
|
||||
<Input id="confirm-delete-space" value={confirmation} onChange={e => setConfirmation(e.target.value)}
|
||||
autoComplete="off" autoFocus disabled={busy}/>
|
||||
</div>
|
||||
{localError && <p role="alert" className="text-sm text-destructive">{localError}</p>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={()=>setSpaceDialog(null)}>Annuler</Button>
|
||||
<Button type="submit" variant="destructive" disabled={busy || !space || confirmation !== space.display_name}>
|
||||
{busy ? 'Suppression…' : 'Supprimer définitivement'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
@ -48,7 +48,7 @@ export default function SpaceSettingsDialog({controller: c}) {
|
||||
setSpaceDialog(null)
|
||||
|
||||
}
|
||||
return <Dialog open={!!spaceDialog} onOpenChange={open=>{if(!open&&!busy)setSpaceDialog(null)}}>
|
||||
return <Dialog open={!!spaceDialog && ['manage','create'].includes(spaceDialog.mode)} 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>
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
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/)
|
||||
})
|
||||
@ -1,19 +0,0 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
@ -1,31 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
@ -1,29 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@ -1,40 +0,0 @@
|
||||
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/)
|
||||
})
|
||||
@ -1,75 +0,0 @@
|
||||
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)
|
||||
})
|
||||
29
start.sh
Normal file
29
start.sh
Normal file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /home/container/backend
|
||||
# Lors du premier démarrage, laisser à la base le temps de devenir disponible.
|
||||
tries=0
|
||||
until python manage.py migrate --noinput; do
|
||||
tries=$((tries+1))
|
||||
if ((tries >= 30)); then
|
||||
echo "Impossible d'appliquer les migrations après 30 tentatives." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
python manage.py ensure_admin
|
||||
|
||||
python manage.py runserver 0.0.0.0:1029 &
|
||||
backend_pid=$!
|
||||
cd /home/container/frontend
|
||||
export VITE_API_PROXY_TARGET="${VITE_API_PROXY_TARGET:-http://127.0.0.1:1029}"
|
||||
npm run dev -- --host 0.0.0.0 --port 1028 &
|
||||
frontend_pid=$!
|
||||
|
||||
cleanup() {
|
||||
kill "$backend_pid" "$frontend_pid" 2>/dev/null || true
|
||||
wait "$backend_pid" "$frontend_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT SIGINT SIGTERM
|
||||
wait -n "$backend_pid" "$frontend_pid"
|
||||
Loading…
x
Reference in New Issue
Block a user