diff --git a/backend/drive/management/commands/flatten_drive_root.py b/backend/drive/management/commands/flatten_drive_root.py new file mode 100644 index 0000000..2235b5b --- /dev/null +++ b/backend/drive/management/commands/flatten_drive_root.py @@ -0,0 +1,172 @@ +"""Move the legacy physical /storage/root layer into /storage. + +This is deliberately an operator-triggered maintenance operation: the application +must be stopped and both the database and the storage must be backed up first. +A journal permits safely retrying if the process is interrupted between filesystem +renames and the atomic database update. +""" +import json +import os +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.db.models import F +from django.db.models.functions import Substr + +from drive.models import DriveItem, DriveSpace + +JOURNAL_NAME = '.gooddrive-flatten-root.json' +PREFIX = 'root/' + + +def present(path): + """Like exists(), but also detect dangling symlinks so they cannot be overwritten.""" + return path.exists() or path.is_symlink() + + +class Command(BaseCommand): + help = ('Supprime le dossier physique /root hérité des anciennes versions. ' + 'Sans --apply, effectue uniquement les vérifications.') + + def add_arguments(self, parser): + parser.add_argument('--apply', action='store_true', + help='Effectue les déplacements et met à jour les chemins SQL.') + + def handle(self, *args, **options): + storage = Path(settings.DRIVE_STORAGE_ROOT).resolve() + old_root = storage / 'root' + journal = storage / JOURNAL_NAME + apply = options['apply'] + + root = DriveSpace.objects.filter(parent__isnull=True, private_owner__isnull=True).order_by('pk').first() + if root is None: + self.stdout.write('Aucun espace racine initialisé : rien à modifier.') + return + if root.relative_path not in ('root', ''): + raise CommandError(f'Chemin racine inattendu : {root.relative_path!r}.') + if old_root.is_symlink() or (present(old_root) and not old_root.is_dir()): + raise CommandError('Le chemin root est un lien symbolique ou n’est pas un dossier.') + + if present(journal): + try: + state = json.loads(journal.read_text(encoding='utf-8')) + except (OSError, ValueError) as exc: + raise CommandError('Journal de migration illisible : ne pas le supprimer sans vérification.') from exc + names = state.get('entries') + if (state.get('version') != 1 or state.get('root_id') != root.pk + or not isinstance(names, list) or len(names) != len(set(names)) + or any(not isinstance(name, str) or not name or name in ('.', '..') + or '/' in name or '\\' in name or '\x00' in name for name in names)): + raise CommandError('Journal de migration invalide : intervention manuelle nécessaire.') + resumed = True + else: + names = sorted(path.name for path in old_root.iterdir()) if old_root.exists() else [] + resumed = False + + if root.relative_path == '': + # Previous attempt may have committed SQL but been interrupted before + # removing the journal. Do not move untracked content in this state. + if journal.exists(): + for name in names: + if present(old_root / name) or not present(storage / name): + raise CommandError('La base est migrée, mais les déplacements sont incomplets : contrôle manuel nécessaire.') + if old_root.exists() and any(old_root.iterdir()): + raise CommandError('Le dossier root contient encore des éléments : intervention manuelle nécessaire.') + if apply: + if old_root.exists(): + old_root.rmdir() + journal.unlink() + self.stdout.write(self.style.SUCCESS('Migration déjà appliquée ; journal nettoyé.')) + else: + self.stdout.write('Base déjà migrée. --apply nettoiera le journal restant.') + elif old_root.exists(): + if any(old_root.iterdir()): + raise CommandError('La base utilise déjà /storage, mais /storage/root contient des données non suivies. Aucun déplacement automatique.') + if apply: + old_root.rmdir() + self.stdout.write(self.style.SUCCESS('Dossier root vide supprimé.')) + else: + self.stdout.write('Base déjà migrée. --apply supprimera le dossier root vide.') + else: + self.stdout.write(self.style.SUCCESS('Déjà configuré : la racine est directement /storage.')) + return + + # Only normalize the legacy root and descendants. Other spaces and + # existing physical paths are never overwritten or merged implicitly. + spaces = list(DriveSpace.objects.all().order_by('pk')) + changing = [s for s in spaces if s.pk == root.pk or s.relative_path.startswith(PREFIX)] + other_paths = {s.relative_path.casefold() for s in spaces if s not in changing} + new_paths = ['' if s.pk == root.pk else s.relative_path[len(PREFIX):] for s in changing] + if (any(p.startswith('/') or not p or p.startswith('../') or '/..' in p + for p in new_paths if p != '') + or len({p.casefold() for p in new_paths}) != len(new_paths) + or any(p.casefold() in other_paths for p in new_paths)): + raise CommandError('Collision ou chemin invalide parmi les espaces SQL : aucune modification.') + + # A referenced physical file cannot be lost due to a typo or an + # already-missing root. Empty storage_location is legacy and is left as-is. + if not resumed: + for location in DriveItem.objects.filter(is_folder=False, storage_location__startswith=PREFIX).values_list('storage_location', flat=True).iterator(): + if not location.startswith(PREFIX) or '\\' in location or '..' in Path(location).parts: + raise CommandError(f'Chemin de fichier invalide en base : {location!r}.') + if not (storage / location).is_file(): + raise CommandError(f'Fichier référencé manquant : {location!r}. Aucun déplacement.') + + for name in names: + source, target = old_root / name, storage / name + if present(source) and source.is_symlink(): + raise CommandError(f'Lien symbolique dans root : {name!r}. Migration refusée.') + if present(source) and present(target): + raise CommandError(f'Collision : {source} et {target} existent déjà. Aucun écrasement.') + if not present(source) and (not resumed or not present(target)): + raise CommandError(f'Élément introuvable : {source}. Migration interrompue.') + + count_files = DriveItem.objects.filter(storage_location__startswith=PREFIX).count() + self.stdout.write(f'Racine actuelle : {storage}/root -> {storage}') + self.stdout.write(f'{len(names)} élément(s) physiques ; {len(changing)} espace(s) ; {count_files} chemin(s) de fichier à modifier.') + if not apply: + self.stdout.write(self.style.WARNING('Simulation uniquement. Arrête GoodDrive, sauvegarde SQL et /storage, puis lance --apply.')) + return + + if not resumed: + # Journal persisted before the first rename, for retry after a crash. + state = {'version': 1, 'root_id': root.pk, 'entries': names} + try: + with journal.open('x', encoding='utf-8') as stream: + json.dump(state, stream, ensure_ascii=False) + stream.flush() + os.fsync(stream.fileno()) + except FileExistsError as exc: + raise CommandError('Une autre migration est déjà en cours.') from exc + + try: + for name in names: + source, target = old_root / name, storage / name + if present(source): + if present(target): + raise CommandError(f'Destination occupée : {target}. Migration interrompue.') + source.rename(target) # same filesystem: does not copy large files + if old_root.exists(): + old_root.rmdir() # refuses unexpected new files; do not erase content + # Verify the new physical destinations before committing the SQL paths. + for location in DriveItem.objects.filter( + is_folder=False, storage_location__startswith=PREFIX + ).values_list('storage_location', flat=True).iterator(): + if not (storage / location[len(PREFIX):]).is_file(): + raise CommandError(f'Fichier inaccessible après déplacement : {location!r}.') + with transaction.atomic(): + # Leave descendants coherent with the root; update paths with + # explicit IDs, not by renaming actual DriveItems (logical folders). + for space, new_path in zip(changing, new_paths): + DriveSpace.objects.filter(pk=space.pk).update(relative_path=new_path) + DriveItem.objects.filter(storage_location__startswith=PREFIX).update( + storage_location=Substr(F('storage_location'), len(PREFIX) + 1)) + journal.unlink() + except Exception as exc: + raise CommandError( + 'Conversion interrompue : journal conservé. Laisse GoodDrive arrêté ' + f'et relance la même commande --apply après résolution. Détail : {exc}' + ) from exc + self.stdout.write(self.style.SUCCESS('Terminé : /storage est la racine physique, sans dossier root.')) diff --git a/start.sh b/start.sh index 3f5ce38..7654481 100644 --- a/start.sh +++ b/start.sh @@ -40,7 +40,9 @@ echo "Démarrage Django sur :1035" python3 manage.py migrate --noinput -python manage.py ensure_admin +python3 manage.py ensure_admin + +python3 manage.py flatten_drive_root python3 manage.py runserver 0.0.0.0:1035 & BACKEND_PID=$!