diff --git a/CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md b/CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md deleted file mode 100644 index 2b5c2ec..0000000 --- a/CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md +++ /dev/null @@ -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. diff --git a/backend/dockerfile b/backend/dockerfile index 0a056ef..5f1fe37 100644 --- a/backend/dockerfile +++ b/backend/dockerfile @@ -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"] \ No newline at end of file +CMD ["sh", "/app/entrypoint.sh"] \ No newline at end of file diff --git a/backend/drive/management/commands/ensure_admin.py b/backend/drive/management/commands/ensure_admin.py new file mode 100644 index 0000000..81df509 --- /dev/null +++ b/backend/drive/management/commands/ensure_admin.py @@ -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éé.')) diff --git a/backend/drive/tests.py b/backend/drive/tests.py index db185bb..8456078 100644 --- a/backend/drive/tests.py +++ b/backend/drive/tests.py @@ -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!')) diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py index e879318..807f623 100644 --- a/backend/drive/views_spaces.py +++ b/backend/drive/views_spaces.py @@ -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}) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 63e8add..10a5eb4 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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), diff --git a/frontend/src/components/drive/ContextActions.jsx b/frontend/src/components/drive/ContextActions.jsx index df6d125..c5aaa6b 100644 --- a/frontend/src/components/drive/ContextActions.jsx +++ b/frontend/src/components/drive/ContextActions.jsx @@ -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 ···. */ diff --git a/frontend/src/components/drive/DriveDialogs.jsx b/frontend/src/components/drive/DriveDialogs.jsx index e120947..5a0ba64 100644 --- a/frontend/src/components/drive/DriveDialogs.jsx +++ b/frontend/src/components/drive/DriveDialogs.jsx @@ -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 }) { + } diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index 88fb3ba..2f902b2 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -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) diff --git a/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx b/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx new file mode 100644 index 0000000..fe0990a --- /dev/null +++ b/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx @@ -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 {if (!open && !busy) setSpaceDialog(null)}}> + + + Supprimer l’espace + + L’espace « {space?.display_name} », ses fichiers, sa corbeille, ses transferts et ses sous-espaces autonomes seront supprimés définitivement. + + +
+ +

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.

+
+
+
+ + setConfirmation(e.target.value)} + autoComplete="off" autoFocus disabled={busy}/> +
+ {localError &&

{localError}

} + + + + +
+
+
+} diff --git a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx index a17d364..40e1e39 100644 --- a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx +++ b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx @@ -48,7 +48,7 @@ export default function SpaceSettingsDialog({controller: c}) { setSpaceDialog(null) } - return {if(!open&&!busy)setSpaceDialog(null)}}> + return {if(!open&&!busy)setSpaceDialog(null)}}> {manage ? 'Gérer l’espace' : 'Créer un espace partagé'} diff --git a/frontend/tests/drive-fixes.test.js b/frontend/tests/drive-fixes.test.js deleted file mode 100644 index f12d9fa..0000000 --- a/frontend/tests/drive-fixes.test.js +++ /dev/null @@ -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'), /