This commit is contained in:
Louis Mazin 2026-10-14 18:47:43 +02:00
parent a087070a2e
commit a5fb4ad3fa
14 changed files with 409 additions and 35 deletions

View File

@ -82,7 +82,7 @@ class DriveItem(models.Model):
indexes = [models.Index(fields=['owner','parent']), models.Index(fields=['space','trashed_at'])]
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.')
if len(self.name) > 255:
raise ValidationError('Nom trop long.')

View File

@ -60,7 +60,7 @@ def valid_name(name):
if not isinstance(name,str) or not name.strip() or len(name)>255:
raise ValueError('Nom invalide (1 à 255 caractères).')
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.')
return name

View File

@ -48,7 +48,8 @@ def disk_path(location):
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:
siblings = siblings.exclude(pk=exclude)
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
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
else item.storage_location or f'{item.storage_key[:2]}/{item.storage_key}')
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 = file_location(item.space, new_parent, new_name)
source = disk_path(old)
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)
moved = True
if item.is_folder:
prefix = old.rstrip('/') + '/'
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:
_update_descendant_locations(item, old, new)
item.storage_location = new
item.name = new_name
item.parent = new_parent
@ -112,3 +105,77 @@ def relocate_item(item, new_parent, new_name):
if target.exists() and not source.exists():
target.rename(source)
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

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

View 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>&lt;script&gt;alert(1)&lt;/script&gt;</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('&lt;script&gt;', html)
self.assertNotIn('<script>', html)
self.assertIn('<table>', html)
self.assertIn('Tableau', html)

View File

@ -29,18 +29,33 @@ def delete_trashed_item(item):
if has_active_uploads_in_tree(item):
raise ValueError('Un transfert est encore en cours dans ce dossier.')
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():
item.delete()
def remove_files():
for path in files:
path.unlink(missing_ok=True)
if folder and folder.is_dir() and not folder.is_symlink():
# Only remove empty directories, never user data untracked by SQL.
for candidate in sorted(folder.rglob('*'), key=lambda p:len(p.parts), reverse=True):
if candidate.is_dir() and not candidate.is_symlink():
try: candidate.rmdir()
except OSError: pass
for folder in sorted(set(folders), key=lambda p:len(p.parts), reverse=True):
if folder.is_dir() and not folder.is_symlink():
# Never recursively erase untracked files.
try: folder.rmdir()
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)

View File

@ -11,6 +11,7 @@ from rest_framework.response import Response
from .services import error, get_item, readable
from .spaces import storage_path
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'}
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):
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'}:
return mime
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
size=path.stat().st_size
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
byte_range=request.headers.get('Range')
# Native video/audio controls commonly request Range: bytes=0-.

View File

@ -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 .quotas import quota_state
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
@ -169,7 +169,10 @@ def trash_item(request,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 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})
@ -178,12 +181,36 @@ def restore_item(request,item_id):
item=get_item(item_id)
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 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:
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:
return error(f'Restauration impossible : {exc}',409)
item.trashed_at=None;item.save()
return Response(item_info(item,request.user))

View File

@ -17,6 +17,7 @@ from rest_framework.response import Response
from .models import DriveItem, ItemGrant, PublicItemLink
from .services import error, get_item, readable, writable, chain, item_info
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 .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)
content_type=preview if inline else 'application/octet-stream'
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')
if byte_range:
match=re.fullmatch(r'bytes=(\d*)-(\d*)',byte_range.strip())

View 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('&emsp;')
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 '&nbsp;'
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>')

View File

@ -15,6 +15,7 @@ function Preview({ item, url, onError, fullscreen }) {
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 (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.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>

View File

@ -1,12 +1,12 @@
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 { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { drive, message } from '@/api/client'
import PermissionsEditor from '@/components/drive/PermissionsEditor'
export default function AdminUsersPage(){
export default function AdminUsersPage({onOpenSidebar}){
const [query,setQuery] = useState('')
const [record,setRecord] = useState({users:[]})
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">
<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)}/>
{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">

View File

@ -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)} />}
<DriveSidebar controller={controller} user={user} onLogout={onLogout} />
<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} />
<DriveExplorer controller={controller} user={user} />
</>}

View File

@ -7,6 +7,7 @@ import { fmtSize } from '@/lib/drive-format'
function typeOf(item) {
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(n.endsWith('.docx'))return 'word'
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('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 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==='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==='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"/>