nice
This commit is contained in:
parent
a087070a2e
commit
a5fb4ad3fa
@ -82,7 +82,7 @@ class DriveItem(models.Model):
|
|||||||
indexes = [models.Index(fields=['owner','parent']), models.Index(fields=['space','trashed_at'])]
|
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 {'.', '..', '.gooddrive-trash'} 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:
|
if len(self.name) > 255:
|
||||||
raise ValidationError('Nom trop long.')
|
raise ValidationError('Nom trop long.')
|
||||||
|
|||||||
@ -60,7 +60,7 @@ def valid_name(name):
|
|||||||
if not isinstance(name,str) or not name.strip() or len(name)>255:
|
if not isinstance(name,str) or not name.strip() or len(name)>255:
|
||||||
raise ValueError('Nom invalide (1 à 255 caractères).')
|
raise ValueError('Nom invalide (1 à 255 caractères).')
|
||||||
name=name.strip()
|
name=name.strip()
|
||||||
if name in ('.','..') or any(c in name for c in ('/','\\','\x00','\r','\n')):
|
if name in ('.','..', '.gooddrive-trash') 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
|
return name
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,8 @@ def disk_path(location):
|
|||||||
|
|
||||||
|
|
||||||
def assert_available(space, parent, name, exclude=None):
|
def assert_available(space, parent, name, exclude=None):
|
||||||
siblings = DriveItem.objects.filter(space=space, parent=parent, name=name)
|
# Trashed entries have their own isolated physical location. Names can be reused.
|
||||||
|
siblings = DriveItem.objects.filter(space=space, parent=parent, name=name, trashed_at__isnull=True)
|
||||||
if exclude is not None:
|
if exclude is not None:
|
||||||
siblings = siblings.exclude(pk=exclude)
|
siblings = siblings.exclude(pk=exclude)
|
||||||
if siblings.exists():
|
if siblings.exists():
|
||||||
@ -72,8 +73,8 @@ def relocate_item(item, new_parent, new_name):
|
|||||||
The on-disk move is reversed if the database transaction fails. The caller
|
The on-disk move is reversed if the database transaction fails. The caller
|
||||||
must ensure authorization and that new_parent is not a descendant of item.
|
must ensure authorization and that new_parent is not a descendant of item.
|
||||||
"""
|
"""
|
||||||
old = (file_location(item.space, item.parent, item.name) if item.is_folder
|
old = item.storage_location or (file_location(item.space, item.parent, item.name)
|
||||||
else item.storage_location or f'{item.storage_key[:2]}/{item.storage_key}')
|
if item.is_folder else f'{item.storage_key[:2]}/{item.storage_key}')
|
||||||
new = file_location(item.space, new_parent, new_name)
|
new = file_location(item.space, new_parent, new_name)
|
||||||
source = disk_path(old)
|
source = disk_path(old)
|
||||||
target = assert_available(item.space, new_parent, new_name, exclude=item.pk)
|
target = assert_available(item.space, new_parent, new_name, exclude=item.pk)
|
||||||
@ -92,15 +93,7 @@ def relocate_item(item, new_parent, new_name):
|
|||||||
source.rename(target)
|
source.rename(target)
|
||||||
moved = True
|
moved = True
|
||||||
if item.is_folder:
|
if item.is_folder:
|
||||||
prefix = old.rstrip('/') + '/'
|
_update_descendant_locations(item, old, new)
|
||||||
affected = list(DriveItem.objects.filter(
|
|
||||||
space=item.space, storage_location__startswith=prefix
|
|
||||||
).only('pk', 'storage_location'))
|
|
||||||
for child in affected:
|
|
||||||
child.storage_location = new + '/' + child.storage_location[len(prefix):]
|
|
||||||
if affected:
|
|
||||||
DriveItem.objects.bulk_update(affected, ['storage_location'], batch_size=500)
|
|
||||||
else:
|
|
||||||
item.storage_location = new
|
item.storage_location = new
|
||||||
item.name = new_name
|
item.name = new_name
|
||||||
item.parent = new_parent
|
item.parent = new_parent
|
||||||
@ -112,3 +105,77 @@ def relocate_item(item, new_parent, new_name):
|
|||||||
if target.exists() and not source.exists():
|
if target.exists() and not source.exists():
|
||||||
target.rename(source)
|
target.rename(source)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _update_descendant_locations(item, old, new):
|
||||||
|
"""Keep physical paths in sync, including legacy folders without a stored path.
|
||||||
|
|
||||||
|
Entries already moved independently to trash keep their own location.
|
||||||
|
"""
|
||||||
|
old_prefix = old.rstrip('/') + '/'
|
||||||
|
new_prefix = new.rstrip('/') + '/'
|
||||||
|
pending = [item.pk]
|
||||||
|
while pending:
|
||||||
|
batch, pending = pending[:300], pending[300:]
|
||||||
|
children = list(DriveItem.objects.filter(parent_id__in=batch).select_related('parent', 'space'))
|
||||||
|
pending.extend(child.pk for child in children if child.is_folder)
|
||||||
|
changed = []
|
||||||
|
for child in children:
|
||||||
|
location = child.storage_location
|
||||||
|
if not location and child.is_folder:
|
||||||
|
# Historical folders used only their logical name as a disk path.
|
||||||
|
location = file_location(child.space, child.parent, child.name)
|
||||||
|
if location and location.startswith(old_prefix):
|
||||||
|
child.storage_location = new_prefix + location[len(old_prefix):]
|
||||||
|
changed.append(child)
|
||||||
|
if changed:
|
||||||
|
DriveItem.objects.bulk_update(changed, ['storage_location'], batch_size=300)
|
||||||
|
|
||||||
|
|
||||||
|
def trash_location(item):
|
||||||
|
"""Each deleted object gets a separate directory, regardless of display name."""
|
||||||
|
return f'.gooddrive-trash/{item.pk}/{item.name}'
|
||||||
|
|
||||||
|
|
||||||
|
def move_to_trash(item, *, migrate_existing=False):
|
||||||
|
"""Atomic disk/SQL move. Preserve the original name/parent for restoration."""
|
||||||
|
from django.utils import timezone
|
||||||
|
from .trash_cleanup import has_active_uploads_in_tree
|
||||||
|
if (item.trashed_at is not None) != migrate_existing:
|
||||||
|
raise ValueError('État de corbeille inattendu.')
|
||||||
|
if migrate_existing and item.storage_location == trash_location(item):
|
||||||
|
return item
|
||||||
|
if has_active_uploads_in_tree(item):
|
||||||
|
raise ValueError('Un transfert est encore en cours dans ce dossier.')
|
||||||
|
old = item.storage_location or (file_location(item.space, item.parent, item.name)
|
||||||
|
if item.is_folder else f'{item.storage_key[:2]}/{item.storage_key}')
|
||||||
|
new = trash_location(item)
|
||||||
|
source, target = disk_path(old), disk_path(new)
|
||||||
|
if not source.exists() or (item.is_folder != source.is_dir()):
|
||||||
|
raise FileNotFoundError('Élément absent du stockage, déplacement impossible.')
|
||||||
|
if target.exists() or target.is_symlink():
|
||||||
|
raise NameConflict('La destination de la corbeille est déjà utilisée.')
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=False)
|
||||||
|
moved = False
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
source.rename(target)
|
||||||
|
moved = True
|
||||||
|
if item.is_folder:
|
||||||
|
_update_descendant_locations(item, old, new)
|
||||||
|
item.storage_location = new
|
||||||
|
if migrate_existing:
|
||||||
|
item.save(update_fields=['storage_location'])
|
||||||
|
else:
|
||||||
|
item.trashed_at = timezone.now()
|
||||||
|
item.save(update_fields=['storage_location', 'trashed_at', 'updated_at'])
|
||||||
|
except Exception:
|
||||||
|
if moved and target.exists() and not source.exists():
|
||||||
|
target.rename(source)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# Leave the isolation folder only while it contains the deleted element.
|
||||||
|
if not target.exists():
|
||||||
|
try: target.parent.rmdir()
|
||||||
|
except OSError: pass
|
||||||
|
return item
|
||||||
|
|||||||
87
backend/drive/test_trash_isolation.py
Normal file
87
backend/drive/test_trash_isolation.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"""Run: USE_SQLITE=true python manage.py test drive.test_trash_isolation"""
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from .models import DriveItem
|
||||||
|
from .spaces import ensure_space
|
||||||
|
from .storage_layout import disk_path
|
||||||
|
|
||||||
|
|
||||||
|
class TrashIsolationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temp.cleanup)
|
||||||
|
self.root = Path(temp.name)
|
||||||
|
config = override_settings(DRIVE_STORAGE_ROOT=self.root, FILE_UPLOAD_TEMP_DIR=str(self.root / '.tmp'))
|
||||||
|
config.enable()
|
||||||
|
self.addCleanup(config.disable)
|
||||||
|
self.user = get_user_model().objects.create_superuser('trash-tester', 'trash@example.org', 'password')
|
||||||
|
self.space = ensure_space(self.user)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
def new_file(self, name='rapport.txt', content=b'original'):
|
||||||
|
from uuid import uuid4
|
||||||
|
location = f'{self.space.relative_path}/{name}' if self.space.relative_path else name
|
||||||
|
path = disk_path(location)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(content)
|
||||||
|
return DriveItem.objects.create(space=self.space, owner=self.user, name=name, size=len(content),
|
||||||
|
mime_type='text/plain', storage_key=str(uuid4()), storage_location=location)
|
||||||
|
|
||||||
|
def test_reuse_same_name_multiple_times_and_restore(self):
|
||||||
|
first = self.new_file(content=b'first')
|
||||||
|
self.assertEqual(self.client.post(f'/api/drive/items/{first.pk}/trash/').status_code, 200)
|
||||||
|
first.refresh_from_db()
|
||||||
|
self.assertEqual(disk_path(first.storage_location).read_bytes(), b'first')
|
||||||
|
second = self.new_file(content=b'second')
|
||||||
|
self.assertEqual(self.client.post(f'/api/drive/items/{second.pk}/trash/').status_code, 200)
|
||||||
|
second.refresh_from_db()
|
||||||
|
self.assertNotEqual(first.storage_location, second.storage_location)
|
||||||
|
active = self.new_file(content=b'third')
|
||||||
|
response = self.client.post(f'/api/drive/items/{first.pk}/restore/')
|
||||||
|
self.assertEqual(response.status_code, 200, response.data)
|
||||||
|
self.assertEqual(response.data['name'], 'rapport (restauré 2).txt')
|
||||||
|
first.refresh_from_db()
|
||||||
|
self.assertEqual(disk_path(first.storage_location).read_bytes(), b'first')
|
||||||
|
self.assertEqual(disk_path(second.storage_location).read_bytes(), b'second')
|
||||||
|
self.assertEqual(disk_path(active.storage_location).read_bytes(), b'third')
|
||||||
|
self.assertEqual(self.client.delete(f'/api/drive/items/{second.pk}/delete/').status_code, 204)
|
||||||
|
self.assertTrue(disk_path(active.storage_location).exists())
|
||||||
|
|
||||||
|
def test_trashed_folder_frees_name_and_restores_contents(self):
|
||||||
|
folder = self.client.post('/api/drive/folders/', {'name': 'Cours', 'space':self.space.pk}, format='json')
|
||||||
|
self.assertEqual(folder.status_code, 201, folder.data)
|
||||||
|
original = DriveItem.objects.get(pk=folder.data['id'])
|
||||||
|
location = '/'.join(part for part in (self.space.relative_path, 'Cours', 'file.txt') if part)
|
||||||
|
target = disk_path(location)
|
||||||
|
target.write_bytes(b'present')
|
||||||
|
from uuid import uuid4
|
||||||
|
child = DriveItem.objects.create(space=self.space, owner=self.user, parent=original,
|
||||||
|
name='file.txt', storage_key=str(uuid4()), storage_location=location)
|
||||||
|
self.assertEqual(self.client.post(f'/api/drive/items/{original.pk}/trash/').status_code, 200)
|
||||||
|
child.refresh_from_db()
|
||||||
|
self.assertTrue(disk_path(child.storage_location).is_file())
|
||||||
|
replacement = self.client.post('/api/drive/folders/', {'name': 'Cours', 'space':self.space.pk}, format='json')
|
||||||
|
self.assertEqual(replacement.status_code, 201, replacement.data)
|
||||||
|
restored = self.client.post(f'/api/drive/items/{original.pk}/restore/')
|
||||||
|
self.assertEqual(restored.status_code, 200, restored.data)
|
||||||
|
self.assertEqual(restored.data['name'], 'Cours (restauré 2)')
|
||||||
|
child.refresh_from_db()
|
||||||
|
self.assertEqual(disk_path(child.storage_location).read_bytes(), b'present')
|
||||||
|
|
||||||
|
def test_migrate_existing_trashed_without_resetting_retention(self):
|
||||||
|
item = self.new_file()
|
||||||
|
when = timezone.now() - timedelta(days=4)
|
||||||
|
DriveItem.objects.filter(pk=item.pk).update(trashed_at=when)
|
||||||
|
call_command('migrate_trash_storage', '--apply', verbosity=0)
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(item.trashed_at, when)
|
||||||
|
self.assertEqual(disk_path(item.storage_location).read_bytes(), b'original')
|
||||||
|
self.new_file(content=b'new copy')
|
||||||
|
call_command('migrate_trash_storage', '--apply', verbosity=0)
|
||||||
22
backend/drive/test_word_preview.py
Normal file
22
backend/drive/test_word_preview.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZipFile
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
from .word_preview import render_word_preview
|
||||||
|
|
||||||
|
|
||||||
|
class WordPreviewTests(SimpleTestCase):
|
||||||
|
def test_docx_text_escaped_and_tables(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / 'note.docx'
|
||||||
|
document = b'''<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||||
|
<w:body><w:p><w:r><w:t><script>alert(1)</script></w:t></w:r></w:p>
|
||||||
|
<w:tbl><w:tr><w:tc><w:p><w:r><w:t>Tableau</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
|
||||||
|
</w:body></w:document>'''
|
||||||
|
with ZipFile(path, 'w') as archive:
|
||||||
|
archive.writestr('word/document.xml', document)
|
||||||
|
html = render_word_preview(path)
|
||||||
|
self.assertIn('<script>', html)
|
||||||
|
self.assertNotIn('<script>', html)
|
||||||
|
self.assertIn('<table>', html)
|
||||||
|
self.assertIn('Tableau', html)
|
||||||
@ -29,18 +29,33 @@ def delete_trashed_item(item):
|
|||||||
if has_active_uploads_in_tree(item):
|
if has_active_uploads_in_tree(item):
|
||||||
raise ValueError('Un transfert est encore en cours dans ce dossier.')
|
raise ValueError('Un transfert est encore en cours dans ce dossier.')
|
||||||
files = [disk_path(key) for key in descendant_files(item)]
|
files = [disk_path(key) for key in descendant_files(item)]
|
||||||
folder = disk_path(file_location(item.space, item.parent, item.name)) if item.is_folder else None
|
# A child may have been trashed separately, into another isolated directory.
|
||||||
|
folders = []
|
||||||
|
queue = [item.pk] if item.is_folder else []
|
||||||
|
if item.is_folder:
|
||||||
|
folders.append(disk_path(item.storage_location or file_location(item.space, item.parent, item.name)))
|
||||||
|
while queue:
|
||||||
|
batch, queue = queue[:300], queue[300:]
|
||||||
|
children = list(DriveItem.objects.filter(parent_id__in=batch, is_folder=True).select_related('parent','space'))
|
||||||
|
queue.extend(child.pk for child in children)
|
||||||
|
for child in children:
|
||||||
|
folders.append(disk_path(child.storage_location or file_location(child.space, child.parent, child.name)))
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
item.delete()
|
item.delete()
|
||||||
def remove_files():
|
def remove_files():
|
||||||
for path in files:
|
for path in files:
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
if folder and folder.is_dir() and not folder.is_symlink():
|
for folder in sorted(set(folders), key=lambda p:len(p.parts), reverse=True):
|
||||||
# Only remove empty directories, never user data untracked by SQL.
|
if folder.is_dir() and not folder.is_symlink():
|
||||||
for candidate in sorted(folder.rglob('*'), key=lambda p:len(p.parts), reverse=True):
|
# Never recursively erase untracked files.
|
||||||
if candidate.is_dir() and not candidate.is_symlink():
|
|
||||||
try: candidate.rmdir()
|
|
||||||
except OSError: pass
|
|
||||||
try: folder.rmdir()
|
try: folder.rmdir()
|
||||||
except OSError: pass
|
except OSError: pass
|
||||||
|
# Remove empty UUID trash containers (never the shared .trash root).
|
||||||
|
trash_root = disk_path('.gooddrive-trash')
|
||||||
|
for path in [*files, *folders]:
|
||||||
|
parent = path.parent
|
||||||
|
while parent != trash_root and parent.is_relative_to(trash_root):
|
||||||
|
try: parent.rmdir()
|
||||||
|
except OSError: break
|
||||||
|
parent = parent.parent
|
||||||
transaction.on_commit(remove_files)
|
transaction.on_commit(remove_files)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from rest_framework.response import Response
|
|||||||
from .services import error, get_item, readable
|
from .services import error, get_item, readable
|
||||||
from .spaces import storage_path
|
from .spaces import storage_path
|
||||||
from .permissions import item_rights
|
from .permissions import item_rights
|
||||||
|
from .word_preview import render_word_preview
|
||||||
|
|
||||||
IMAGE_TYPES = {'image/jpeg','image/png','image/gif','image/webp','image/avif','image/bmp'}
|
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'}
|
VIDEO_TYPES = {'video/mp4','video/webm','video/ogg','video/quicktime','video/x-m4v'}
|
||||||
@ -21,6 +22,8 @@ TEXT_TYPES = {'text/plain','text/csv','text/markdown','text/x-python','text/java
|
|||||||
|
|
||||||
def safe_preview_type(item):
|
def safe_preview_type(item):
|
||||||
mime = (mimetypes.guess_type(item.name)[0] or item.mime_type or '').lower()
|
mime = (mimetypes.guess_type(item.name)[0] or item.mime_type or '').lower()
|
||||||
|
if item.name.lower().endswith('.docx'):
|
||||||
|
return 'text/html; charset=utf-8'
|
||||||
if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}:
|
if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}:
|
||||||
return mime
|
return mime
|
||||||
if mime in TEXT_TYPES or mime in {'text/html','image/svg+xml'} or mime.startswith('text/'):
|
if mime in TEXT_TYPES or mime in {'text/html','image/svg+xml'} or mime.startswith('text/'):
|
||||||
@ -72,6 +75,16 @@ def content(request,item_id):
|
|||||||
if not path.is_file():raise Http404
|
if not path.is_file():raise Http404
|
||||||
size=path.stat().st_size
|
size=path.stat().st_size
|
||||||
content_type=media if inline=='1' else 'application/octet-stream'
|
content_type=media if inline=='1' else 'application/octet-stream'
|
||||||
|
if inline=='1' and item.name.lower().endswith('.docx'):
|
||||||
|
try:
|
||||||
|
html = render_word_preview(path)
|
||||||
|
except ValueError:
|
||||||
|
raise Http404
|
||||||
|
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
||||||
|
response['Cache-Control'] = 'private, no-store'
|
||||||
|
response['Content-Security-Policy'] = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||||
|
response['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
return response
|
||||||
name=item.name
|
name=item.name
|
||||||
byte_range=request.headers.get('Range')
|
byte_range=request.headers.get('Range')
|
||||||
# Native video/audio controls commonly request Range: bytes=0-.
|
# Native video/audio controls commonly request Range: bytes=0-.
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space,
|
|||||||
from .permissions import has_space_right, item_rights
|
from .permissions import has_space_right, item_rights
|
||||||
from .quotas import quota_state
|
from .quotas import quota_state
|
||||||
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination
|
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination
|
||||||
from .storage_layout import assert_available, prepare_parent, relocate_item, NameConflict
|
from .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, NameConflict
|
||||||
from .trash_cleanup import delete_trashed_item
|
from .trash_cleanup import delete_trashed_item
|
||||||
|
|
||||||
|
|
||||||
@ -169,7 +169,10 @@ def trash_item(request,item_id):
|
|||||||
item=get_item(item_id)
|
item=get_item(item_id)
|
||||||
if not readable(item,request.user) or not writable(item,request.user,'delete'):return error('Accès refusé.',403)
|
if not readable(item,request.user) or not writable(item,request.user,'delete'):return error('Accès refusé.',403)
|
||||||
if item.trashed_at:return error('Élément déjà dans la corbeille.')
|
if item.trashed_at:return error('Élément déjà dans la corbeille.')
|
||||||
item.trashed_at=timezone.now();item.save(update_fields=['trashed_at','updated_at'])
|
try:
|
||||||
|
move_to_trash(item)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return error(f'Mise à la corbeille impossible : {exc}',409)
|
||||||
return Response({'ok':True})
|
return Response({'ok':True})
|
||||||
|
|
||||||
|
|
||||||
@ -178,12 +181,36 @@ def restore_item(request,item_id):
|
|||||||
item=get_item(item_id)
|
item=get_item(item_id)
|
||||||
if not writable(item,request.user,'delete'):return error('Accès refusé.',403)
|
if not writable(item,request.user,'delete'):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.')
|
||||||
if item.parent and any(n.trashed_at for n in chain(item.parent)):
|
parent = None if item.parent and any(n.trashed_at for n in chain(item.parent)) else item.parent
|
||||||
|
# If the name has already been recreated, automatically restore with a suffix.
|
||||||
|
name = item.name
|
||||||
try:
|
try:
|
||||||
relocate_item(item,None,item.name)
|
from .storage_layout import assert_available
|
||||||
|
try:
|
||||||
|
assert_available(item.space, parent, name, exclude=item.pk)
|
||||||
|
except NameConflict:
|
||||||
|
stem, dot, extension = name.rpartition('.')
|
||||||
|
if not stem or not dot:
|
||||||
|
stem, extension = name, ''
|
||||||
|
suffix = f'.{extension}' if extension else ''
|
||||||
|
for index in range(2, 10002):
|
||||||
|
tag = f' (restauré {index})'
|
||||||
|
candidate = stem[:255-len(tag)-len(suffix)] + tag + suffix
|
||||||
|
try:
|
||||||
|
assert_available(item.space, parent, candidate, exclude=item.pk)
|
||||||
|
name = candidate
|
||||||
|
break
|
||||||
|
except NameConflict:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
return error('Impossible de trouver un nom libre pour restaurer cet élément.',409)
|
||||||
|
# Do not expose the element in active listings before its physical move.
|
||||||
|
with transaction.atomic():
|
||||||
|
relocate_item(item, parent, name)
|
||||||
|
item.trashed_at = None
|
||||||
|
item.save(update_fields=['trashed_at','updated_at'])
|
||||||
except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc:
|
except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc:
|
||||||
return error(f'Restauration impossible : {exc}',409)
|
return error(f'Restauration impossible : {exc}',409)
|
||||||
item.trashed_at=None;item.save()
|
|
||||||
return Response(item_info(item,request.user))
|
return Response(item_info(item,request.user))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ from rest_framework.response import Response
|
|||||||
from .models import DriveItem, ItemGrant, PublicItemLink
|
from .models import DriveItem, ItemGrant, PublicItemLink
|
||||||
from .services import error, get_item, readable, writable, chain, item_info
|
from .services import error, get_item, readable, writable, chain, item_info
|
||||||
from .spaces import storage_path
|
from .spaces import storage_path
|
||||||
|
from .word_preview import render_word_preview
|
||||||
from .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS
|
from .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS
|
||||||
from .views_content import safe_preview_type, read_bytes
|
from .views_content import safe_preview_type, read_bytes
|
||||||
|
|
||||||
@ -176,6 +177,18 @@ def public_file(request,token,item_id):
|
|||||||
if inline and not preview:return error('Aperçu indisponible pour ce format.',415)
|
if inline and not preview:return error('Aperçu indisponible pour ce format.',415)
|
||||||
content_type=preview if inline else 'application/octet-stream'
|
content_type=preview if inline else 'application/octet-stream'
|
||||||
size=path.stat().st_size
|
size=path.stat().st_size
|
||||||
|
if inline and item.name.lower().endswith('.docx'):
|
||||||
|
try:
|
||||||
|
html = render_word_preview(path)
|
||||||
|
except ValueError:
|
||||||
|
raise Http404
|
||||||
|
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
||||||
|
response['Cache-Control'] = 'no-store'
|
||||||
|
response['Content-Security-Policy'] = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||||
|
response['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
response['X-Frame-Options'] = 'SAMEORIGIN'
|
||||||
|
response['Referrer-Policy'] = 'no-referrer'
|
||||||
|
return response
|
||||||
byte_range=request.headers.get('Range')
|
byte_range=request.headers.get('Range')
|
||||||
if byte_range:
|
if byte_range:
|
||||||
match=re.fullmatch(r'bytes=(\d*)-(\d*)',byte_range.strip())
|
match=re.fullmatch(r'bytes=(\d*)-(\d*)',byte_range.strip())
|
||||||
|
|||||||
124
backend/drive/word_preview.py
Normal file
124
backend/drive/word_preview.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""Offline, inert HTML previews for Word .docx files (no external services)."""
|
||||||
|
import base64
|
||||||
|
from html import escape
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZipFile, BadZipFile
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
A = '{http://schemas.openxmlformats.org/drawingml/2006/main}'
|
||||||
|
R = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}'
|
||||||
|
MAX_DOCX = 35 * 1024 * 1024
|
||||||
|
MAX_XML = 16 * 1024 * 1024
|
||||||
|
MAX_IMAGE = 2 * 1024 * 1024
|
||||||
|
MAX_IMAGES = 12
|
||||||
|
IMAGE_MIME = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif', '.webp': 'image/webp'}
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_read(zf, path, limit=MAX_XML):
|
||||||
|
try:
|
||||||
|
item = zf.getinfo(path)
|
||||||
|
except KeyError:
|
||||||
|
return b''
|
||||||
|
if item.file_size > limit:
|
||||||
|
raise ValueError('Document Word trop volumineux pour un aperçu.')
|
||||||
|
with zf.open(item) as source:
|
||||||
|
result = source.read(limit + 1)
|
||||||
|
if len(result) > limit or b'<!DOCTYPE' in result.upper() or b'<!ENTITY' in result.upper():
|
||||||
|
raise ValueError('Contenu Word non autorisé.')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def render_word_preview(path):
|
||||||
|
"""Extract safe paragraphs, tables and embedded raster images from a DOCX.
|
||||||
|
|
||||||
|
Never include hyperlinks, user HTML, macros, external sources or SVG.
|
||||||
|
Rendered inside a sandboxed iframe with strict CSP.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
if path.suffix.lower() != '.docx' or path.stat().st_size > MAX_DOCX:
|
||||||
|
raise ValueError('Aperçu disponible uniquement pour les fichiers DOCX de moins de 35 Mo.')
|
||||||
|
try:
|
||||||
|
with ZipFile(path) as zf:
|
||||||
|
raw = _zip_read(zf, 'word/document.xml')
|
||||||
|
if not raw:
|
||||||
|
raise ValueError('Document Word invalide.')
|
||||||
|
document = ET.fromstring(raw)
|
||||||
|
relationships = {}
|
||||||
|
relxml = _zip_read(zf, 'word/_rels/document.xml.rels', 2 * 1024 * 1024)
|
||||||
|
if relxml:
|
||||||
|
for node in ET.fromstring(relxml):
|
||||||
|
if node.attrib.get('TargetMode') == 'External':
|
||||||
|
continue
|
||||||
|
target = node.attrib.get('Target', '')
|
||||||
|
if target.startswith('media/') and '..' not in target.split('/'):
|
||||||
|
relationships[node.attrib.get('Id')] = 'word/' + target
|
||||||
|
images = {}
|
||||||
|
def image_tag(rid):
|
||||||
|
name = relationships.get(rid)
|
||||||
|
if not name or len(images) >= MAX_IMAGES and name not in images:
|
||||||
|
return ''
|
||||||
|
ext = Path(name).suffix.lower()
|
||||||
|
if ext not in IMAGE_MIME:
|
||||||
|
return ''
|
||||||
|
if name not in images:
|
||||||
|
raw_image = _zip_read(zf, name, MAX_IMAGE)
|
||||||
|
if not raw_image:
|
||||||
|
return ''
|
||||||
|
images[name] = 'data:' + IMAGE_MIME[ext] + ';base64,' + base64.b64encode(raw_image).decode('ascii')
|
||||||
|
return '<img src="' + images[name] + '" alt="Image du document" />'
|
||||||
|
def paragraph(p):
|
||||||
|
fragments = []
|
||||||
|
for run in p.iter(W + 'r'):
|
||||||
|
run_props = run.find(W + 'rPr')
|
||||||
|
styles = []
|
||||||
|
if run_props is not None:
|
||||||
|
if run_props.find(W + 'b') is not None: styles.append('font-weight:bold')
|
||||||
|
if run_props.find(W + 'i') is not None: styles.append('font-style:italic')
|
||||||
|
if run_props.find(W + 'u') is not None: styles.append('text-decoration:underline')
|
||||||
|
bits = []
|
||||||
|
for child in run.iter():
|
||||||
|
if child.tag == W + 't': bits.append(escape(child.text or ''))
|
||||||
|
elif child.tag == W + 'tab': bits.append(' ')
|
||||||
|
elif child.tag == W + 'br': bits.append('<br>')
|
||||||
|
elif child.tag == A + 'blip': bits.append(image_tag(child.attrib.get(R + 'embed')))
|
||||||
|
if bits:
|
||||||
|
content = ''.join(bits)
|
||||||
|
if styles: content = '<span style="' + ';'.join(styles) + '">' + content + '</span>'
|
||||||
|
fragments.append(content)
|
||||||
|
content = ''.join(fragments) or ' '
|
||||||
|
props = p.find(W + 'pPr')
|
||||||
|
style = props.find(W + 'pStyle') if props is not None else None
|
||||||
|
name = style.attrib.get(W + 'val', '').lower() if style is not None else ''
|
||||||
|
if 'title' in name or 'titre' in name: tag = 'h1'
|
||||||
|
elif 'heading1' in name or 'titre1' in name: tag = 'h2'
|
||||||
|
elif 'heading2' in name or 'titre2' in name: tag = 'h3'
|
||||||
|
else: tag = 'p'
|
||||||
|
return '<' + tag + '>' + content + '</' + tag + '>'
|
||||||
|
def table(tbl):
|
||||||
|
rows = []
|
||||||
|
for row in tbl.findall(W + 'tr'):
|
||||||
|
cells = []
|
||||||
|
for cell in row.findall(W + 'tc'):
|
||||||
|
cells.append('<td>' + ''.join(render_node(el) for el in cell if el.tag in (W + 'p', W + 'tbl')) + '</td>')
|
||||||
|
rows.append('<tr>' + ''.join(cells) + '</tr>')
|
||||||
|
return '<table>' + ''.join(rows) + '</table>'
|
||||||
|
def render_node(el):
|
||||||
|
if el.tag == W + 'p': return paragraph(el)
|
||||||
|
if el.tag == W + 'tbl': return table(el)
|
||||||
|
return ''
|
||||||
|
body = document.find(W + 'body')
|
||||||
|
if body is None:
|
||||||
|
raise ValueError('Document Word invalide.')
|
||||||
|
content = ''.join(render_node(element) for element in body)
|
||||||
|
except (BadZipFile, ET.ParseError, RuntimeError, EOFError, KeyError, OSError) as exc:
|
||||||
|
raise ValueError('Ce fichier DOCX ne peut pas être affiché.') from exc
|
||||||
|
title = escape(path.name)
|
||||||
|
return ('''<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>''' + title + '''</title><style>
|
||||||
|
:root{color-scheme:light}body{margin:0;background:#e9ebef;color:#20232a;font:15px/1.6 Arial,Helvetica,sans-serif}
|
||||||
|
article{box-sizing:border-box;max-width:850px;min-height:95vh;margin:24px auto;padding:58px 62px;background:#fff;box-shadow:0 2px 20px #0001;border-radius:3px;overflow-wrap:anywhere}
|
||||||
|
h1{font-size:26px}h2{font-size:22px}h3{font-size:18px}p{margin:0 0 12px;white-space:pre-wrap}
|
||||||
|
table{border-collapse:collapse;max-width:100%;margin:14px 0}td{border:1px solid #cad0d8;padding:7px 11px;vertical-align:top}td p{margin:0 0 5px}
|
||||||
|
img{max-width:100%;height:auto;display:block;margin:12px 0}@media(max-width:650px){article{padding:26px 18px;margin:0;border-radius:0}}
|
||||||
|
</style></head><body><article>''' + content + '</article></body></html>')
|
||||||
@ -15,6 +15,7 @@ function Preview({ item, url, onError, fullscreen }) {
|
|||||||
const textHeight = fullscreen ? 'h-full min-h-0' : 'h-[65dvh]'
|
const textHeight = fullscreen ? 'h-full min-h-0' : 'h-[65dvh]'
|
||||||
|
|
||||||
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={`${mediaHeight} max-w-full object-contain`} />
|
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={`${mediaHeight} max-w-full object-contain`} />
|
||||||
|
if (name.endsWith('.docx')) return <iframe title={item.name} src={url} onError={onError} sandbox="" className={`${frameHeight} w-full rounded-md bg-white`} />
|
||||||
if (mime === 'application/pdf' || name.endsWith('.pdf')) return <iframe title={item.name} src={url} onError={onError} className={`${frameHeight} w-full rounded-md bg-white`} />
|
if (mime === 'application/pdf' || name.endsWith('.pdf')) return <iframe title={item.name} src={url} onError={onError} className={`${frameHeight} 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={`${mediaHeight} w-full`} src={url}>Vidéo non supportée par le navigateur.</video>
|
if (mime.startsWith('video/') || /\.(mp4|webm|ogv|mov|m4v)$/.test(name)) return <video key={url} onError={onError} controls playsInline preload="metadata" className={`${mediaHeight} 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('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>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Users, LoaderCircle, Trash2, KeyRound } from 'lucide-react'
|
import { Users, LoaderCircle, Trash2, KeyRound, Menu } from 'lucide-react'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
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 { drive, message } from '@/api/client'
|
import { drive, message } from '@/api/client'
|
||||||
import PermissionsEditor from '@/components/drive/PermissionsEditor'
|
import PermissionsEditor from '@/components/drive/PermissionsEditor'
|
||||||
|
|
||||||
export default function AdminUsersPage(){
|
export default function AdminUsersPage({onOpenSidebar}){
|
||||||
const [query,setQuery] = useState('')
|
const [query,setQuery] = useState('')
|
||||||
const [record,setRecord] = useState({users:[]})
|
const [record,setRecord] = useState({users:[]})
|
||||||
const [busy,setBusy] = useState(true)
|
const [busy,setBusy] = useState(true)
|
||||||
@ -65,7 +65,10 @@ export default function AdminUsersPage(){
|
|||||||
}
|
}
|
||||||
return <div className="min-h-0 flex-1 overflow-auto p-4 sm:p-7">
|
return <div className="min-h-0 flex-1 overflow-auto p-4 sm:p-7">
|
||||||
<div className="mx-auto max-w-5xl space-y-5">
|
<div className="mx-auto max-w-5xl space-y-5">
|
||||||
<h1 className="flex items-center gap-3 text-2xl font-semibold"><Users/> Utilisateurs</h1>
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="button" variant="outline" size="icon" className="shrink-0 md:hidden" aria-label="Ouvrir le menu" onClick={onOpenSidebar}><Menu className="size-5" /></Button>
|
||||||
|
<h1 className="flex items-center gap-3 text-2xl font-semibold"><Users className="hidden sm:block"/> Utilisateurs</h1>
|
||||||
|
</div>
|
||||||
<Input placeholder="Rechercher par nom, identifiant ou e-mail…" value={query} onChange={e=>setQuery(e.target.value)}/>
|
<Input placeholder="Rechercher par nom, identifiant ou e-mail…" value={query} onChange={e=>setQuery(e.target.value)}/>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
{busy?<LoaderCircle className="size-6 animate-spin"/>:<div className="overflow-x-auto rounded-xl border bg-card">
|
{busy?<LoaderCircle className="size-6 animate-spin"/>:<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default function DrivePage({user, onLogout}) {
|
|||||||
{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">
|
||||||
{controller.view === 'users' && user.is_admin ? <AdminUsersPage /> : <>
|
{controller.view === 'users' && user.is_admin ? <AdminUsersPage onOpenSidebar={() => setSidebar(true)} /> : <>
|
||||||
<DriveToolbar controller={controller} />
|
<DriveToolbar controller={controller} />
|
||||||
<DriveExplorer controller={controller} user={user} />
|
<DriveExplorer controller={controller} user={user} />
|
||||||
</>}
|
</>}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { fmtSize } from '@/lib/drive-format'
|
|||||||
function typeOf(item) {
|
function typeOf(item) {
|
||||||
const n = (item.name || '').toLowerCase(), m=(item.mime_type || '').toLowerCase()
|
const n = (item.name || '').toLowerCase(), m=(item.mime_type || '').toLowerCase()
|
||||||
if(m.startsWith('image/') && m!=='image/svg+xml' || /\.(png|jpe?g|gif|webp|bmp|avif)$/.test(n)) return 'image'
|
if(m.startsWith('image/') && m!=='image/svg+xml' || /\.(png|jpe?g|gif|webp|bmp|avif)$/.test(n)) return 'image'
|
||||||
|
if(n.endsWith('.docx'))return 'word'
|
||||||
if(m==='application/pdf' || n.endsWith('.pdf'))return 'pdf'
|
if(m==='application/pdf' || n.endsWith('.pdf'))return 'pdf'
|
||||||
if(m.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/.test(n))return 'video'
|
if(m.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/.test(n))return 'video'
|
||||||
if(m.startsWith('audio/') || /\.(mp3|ogg|wav|m4a|flac|aac)$/.test(n))return 'audio'
|
if(m.startsWith('audio/') || /\.(mp3|ogg|wav|m4a|flac|aac)$/.test(n))return 'audio'
|
||||||
@ -19,6 +20,7 @@ function Viewer({item,token,fullscreen}) {
|
|||||||
const mediaHeight=fullscreen?'max-h-[calc(100dvh-5rem)]':'max-h-[72vh]'
|
const mediaHeight=fullscreen?'max-h-[calc(100dvh-5rem)]':'max-h-[72vh]'
|
||||||
const frameHeight=fullscreen?'h-[calc(100dvh-5rem)]':'h-[70vh]'
|
const frameHeight=fullscreen?'h-[calc(100dvh-5rem)]':'h-[70vh]'
|
||||||
if(kind==='image')return <img className={`mx-auto ${mediaHeight} max-w-full rounded-lg object-contain`} alt={item.name} src={src}/>
|
if(kind==='image')return <img className={`mx-auto ${mediaHeight} max-w-full rounded-lg object-contain`} alt={item.name} src={src}/>
|
||||||
|
if(kind==='word')return <iframe title={item.name} src={src} sandbox="" className={`${frameHeight} w-full rounded-xl bg-white`}/>
|
||||||
if(kind==='pdf')return <iframe title={item.name} src={src} className={`${frameHeight} w-full rounded-xl bg-white`}/>
|
if(kind==='pdf')return <iframe title={item.name} src={src} className={`${frameHeight} w-full rounded-xl bg-white`}/>
|
||||||
if(kind==='video')return <video controls playsInline preload="metadata" src={src} className={`${mediaHeight} w-full rounded-xl`}/>
|
if(kind==='video')return <video controls playsInline preload="metadata" src={src} className={`${mediaHeight} w-full rounded-xl`}/>
|
||||||
if(kind==='audio')return <audio controls src={src} className="w-full"/>
|
if(kind==='audio')return <audio controls src={src} className="w-full"/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user