This commit is contained in:
Louis Mazin 2026-10-14 19:17:56 +02:00
parent 1b2b2c414d
commit 3fea8a85c8
18 changed files with 333 additions and 104 deletions

View File

@ -0,0 +1,101 @@
"""Small valid, editable blank ODF/DOCX documents and inert previews.
No cloud APIs, external requests, macros or document execution.
"""
from html import escape
from io import BytesIO
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile, BadZipFile
import xml.etree.ElementTree as ET
ODF_TYPES = {
'odt': ('application/vnd.oasis.opendocument.text', 'text'),
'ods': ('application/vnd.oasis.opendocument.spreadsheet', 'spreadsheet'),
'odp': ('application/vnd.oasis.opendocument.presentation', 'presentation'),
}
BLANK_TYPES = {'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
**{ext: mime for ext, (mime, _) in ODF_TYPES.items()},
**{ext: 'text/plain; charset=utf-8' for ext in ('txt', 'md', 'py', 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'json', 'xml', 'csv')}}
ODF_NS = 'urn:oasis:names:tc:opendocument:xmlns:'
OFFICE = '{' + ODF_NS + 'office:1.0}'
TEXT = '{' + ODF_NS + 'text:1.0}'
TABLE = '{' + ODF_NS + 'table:1.0}'
DRAW = '{' + ODF_NS + 'drawing:1.0}'
MAX_DOC_BYTES = 35 * 1024 * 1024
MAX_CONTENT_BYTES = 12 * 1024 * 1024
def blank_file(extension):
if extension not in BLANK_TYPES:
raise ValueError('Format non autorisé.')
if extension in ODF_TYPES:
# Valid empty templates tested with LibreOffice, no server dependency.
return (Path(__file__).with_name('templates') / f'blank.{extension}').read_bytes()
if extension != 'docx':
return b'' if extension != 'json' else b'{}\n'
out = BytesIO()
with ZipFile(out, 'w', ZIP_DEFLATED) as archive:
archive.writestr('[Content_Types].xml', '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>''')
archive.writestr('_rels/.rels', '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>''')
archive.writestr('word/document.xml', '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p/><w:sectPr/></w:body></w:document>''')
return out.getvalue()
def render_odf_preview(path):
"""Render plain text/cells/slides only; no untrusted HTML or external resources."""
path = Path(path)
ext = path.suffix.lower().lstrip('.')
if ext not in ODF_TYPES or path.stat().st_size > MAX_DOC_BYTES:
raise ValueError('Format ou taille de document incompatible.')
try:
with ZipFile(path) as archive:
info = archive.getinfo('content.xml')
if info.file_size > MAX_CONTENT_BYTES:
raise ValueError('Document trop volumineux.')
with archive.open(info) as stream:
raw = stream.read(MAX_CONTENT_BYTES + 1)
if len(raw) > MAX_CONTENT_BYTES or b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
raise ValueError('Document non autorisé.')
root = ET.fromstring(raw)
except (OSError, KeyError, BadZipFile, ET.ParseError, RuntimeError, EOFError) as exc:
raise ValueError('Document LibreOffice illisible.') from exc
# Cap rendered elements and text to keep the preview responsive and inert.
count = 0
def t(node):
return escape(' '.join(''.join(node.itertext()).split())[:4000])
sections = []
if ext == 'odt':
for el in root.iter():
if el.tag in (TEXT + 'p', TEXT + 'h'):
count += 1
if count > 2500: break
tag = 'h2' if el.tag == TEXT + 'h' else 'p'
sections.append(f'<{tag}>' + (t(el) or '&nbsp;') + f'</{tag}>')
elif ext == 'ods':
for table in root.iter(TABLE + 'table'):
if len(sections) >= 12: break
sections.append('<h2>' + escape(table.attrib.get(TABLE + 'name', 'Feuille')) + '</h2><table>')
for row in table.iter(TABLE + 'table-row'):
count += 1
if count > 1500: break
cells = []
for cell in row:
if cell.tag in (TABLE + 'table-cell', TABLE + 'covered-table-cell'):
cell_text = t(cell)
repeat = min(int(cell.attrib.get(TABLE + 'number-columns-repeated', '1') or '1'), 100)
cells.extend(['<td>' + cell_text + '</td>'] * min(repeat, max(0, 60 - len(cells))))
if len(cells) >= 60: break
if cells: sections.append('<tr>' + ''.join(cells) + '</tr>')
sections.append('</table>')
else:
for page in root.iter(DRAW + 'page'):
count += 1
if count > 120: break
name = escape(page.attrib.get(DRAW + 'name', f'Diapositive {count}'))
paras = [t(p) for p in page.iter() if p.tag in (TEXT + 'p', TEXT + 'h')][:100]
sections.append('<section><h2>' + name + '</h2>' + ''.join('<p>' + x + '</p>' for x in paras) + '</section>')
content = ''.join(sections) or '<p>Document vide</p>'
return ('''<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
body{margin:0;background:#e9ecf1;color:#20242b;font:15px/1.6 Arial,sans-serif}main{box-sizing:border-box;max-width:1080px;margin:24px auto;padding:40px 48px;min-height:80vh;background:#fff;box-shadow:0 2px 14px #0001}
p{white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0}h2{font-size:19px}table{border-collapse:collapse;width:max-content;max-width:100%;margin:14px 0}td{min-width:80px;border:1px solid #c9ced8;padding:6px 9px}section{margin:25px auto;padding:20px;border:1px solid #ddd;min-height:220px} @media(max-width:650px){main{margin:0;padding:18px}}</style></head><body><main>'''
+ content + '</main></body></html>')

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,63 @@
"""Regression tests for New > File and LibreOffice safe previews."""
import tempfile
from pathlib import Path
from zipfile import ZipFile
from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient
from .document_formats import ODF_TYPES, blank_file, render_odf_preview
from .models import DriveItem
class DocumentFormatsTests(SimpleTestCase):
def test_valid_blank_office_files_have_expected_structure(self):
for extension in ('docx', 'odt', 'ods', 'odp'):
with self.subTest(ext=extension):
content = blank_file(extension)
with tempfile.TemporaryDirectory() as temp:
p = Path(temp) / f'blank.{extension}'
p.write_bytes(content)
with ZipFile(p) as archive:
self.assertIsNone(archive.testzip())
self.assertIn('word/document.xml' if extension == 'docx' else 'content.xml', archive.namelist())
if extension in ODF_TYPES:
html = render_odf_preview(p)
self.assertIn('<html', html)
self.assertNotIn('<script', html)
def test_disallow_active_xml(self):
with tempfile.TemporaryDirectory() as temp:
p = Path(temp) / 'unsafe.odt'
with ZipFile(p, 'w') as archive:
archive.writestr('content.xml', b'<!DOCTYPE doc [<!ENTITY x SYSTEM "file:///etc/passwd">]><doc>&x;</doc>')
with self.assertRaises(ValueError):
render_odf_preview(p)
class NewFileEndpointTests(TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
override = override_settings(DRIVE_STORAGE_ROOT=self.root, FILE_UPLOAD_TEMP_DIR=str(self.root / '.tmp'))
override.enable()
self.addCleanup(override.disable)
self.user = get_user_model().objects.create_superuser('newfilesadmin', 'new@example.com', 'secret')
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.space = self.client.get('/api/auth/me/').data['space_id']
def test_create_lo_file_and_reject_duplicate(self):
payload = {'name': 'Feuille.ods', 'type': 'ods', 'space': self.space}
response = self.client.post('/api/drive/files/create/', payload, format='json')
self.assertEqual(response.status_code, 201, response.data)
item = DriveItem.objects.get(pk=response.data['id'])
self.assertEqual(item.size, (self.root / 'Feuille.ods').stat().st_size)
self.assertTrue(item.storage_location.endswith('Feuille.ods'))
self.assertEqual(self.client.post('/api/drive/files/create/', payload, format='json').status_code, 409)
self.assertEqual(DriveItem.objects.filter(name='Feuille.ods').count(), 1)
def test_forbidden_extensions_and_empty_extensions(self):
for payload in [{'name':'x.exe','type':'exe','space':self.space},
{'name':'x.txt','type':'ods','space':self.space}]:
self.assertEqual(self.client.post('/api/drive/files/create/', payload, format='json').status_code, 400)

View File

@ -29,6 +29,7 @@ urlpatterns=[
path('trash/',views.empty_trash),
path('stats/',views.stats),
path('folders/',views.create_folder),
path('files/create/',views.create_file),
path('uploads/',views.upload),
path('uploads/start/',views.upload_start),
path('uploads/pending/',views.upload_pending),

View File

@ -4,7 +4,7 @@ Implementations are grouped by responsibility without changing route names.
"""
from .views_items import (me, listing, stats, create_folder, update_item,
from .views_items import (me, listing, stats, create_folder, create_file, update_item,
trash_item, restore_item, delete_item, empty_trash)
from .views_transfers import (upload, upload_start, upload_status, upload_chunk,
upload_finish, upload_cancel, upload_pending)

View File

@ -12,6 +12,7 @@ from .services import error, get_item, readable
from .spaces import storage_path
from .permissions import item_rights
from .word_preview import render_word_preview
from .document_formats import ODF_TYPES, render_odf_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'}
@ -22,7 +23,7 @@ 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'):
if item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp')):
return 'text/html; charset=utf-8'
if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}:
return mime
@ -75,9 +76,9 @@ 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'):
if inline=='1' and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp')):
try:
html = render_word_preview(path)
html = render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path)
except ValueError:
raise Http404
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')

View File

@ -1,4 +1,6 @@
"""Space-bound navigation, folder management and trash endpoints."""
import mimetypes
import uuid
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Sum, Count
@ -8,10 +10,11 @@ from rest_framework.response import Response
from .models import DriveItem, DriveSpace, ItemGrant
from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space
from .permissions import has_space_right, item_rights
from .quotas import quota_state
from .quotas import quota_state, locked_quota_tree, assert_space_capacity, QuotaExceeded
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, move_to_trash, cleanup_empty_trash_container, NameConflict
from .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, cleanup_empty_trash_container, file_location, NameConflict
from .trash_cleanup import delete_trashed_item, has_active_uploads_in_tree
from .document_formats import BLANK_TYPES, blank_file
@api_view(['GET'])
@ -128,6 +131,54 @@ def create_folder(request):
return Response(item_info(folder,request.user),status=201)
@api_view(['POST'])
def create_file(request):
"""Create a real, valid blank local file; never accept arbitrary file contents."""
try:
extension = str(request.data.get('type', '')).lower().strip().lstrip('.')
if extension not in BLANK_TYPES:
return error('Type de fichier non autorisé.')
name = valid_name(request.data.get('name'))
if not name.lower().endswith('.' + extension):
return error('L’extension ne correspond pas au type demandé.')
space, parent = writable_destination(request.user, request.data.get('space'), request.data.get('parent'))
content = blank_file(extension)
except ValueError as exc:
return error(str(exc))
except PermissionError as exc:
return error(str(exc), 403)
destination = None
created = False
committed = False
try:
with transaction.atomic():
with locked_quota_tree(space):
assert_space_capacity(space, len(content))
destination = assert_available(space, parent, name)
prepare_parent(space, parent)
with destination.open('xb') as outfile:
created = True
outfile.write(content)
node = DriveItem(owner=request.user, space=space, parent=parent, name=name,
is_folder=False, storage_key=str(uuid.uuid4()),
storage_location=file_location(space, parent, name),
size=len(content), mime_type=BLANK_TYPES[extension])
node.full_clean()
node.save()
committed = True
return Response(item_info(node, request.user), status=201)
except (NameConflict, FileExistsError) as exc:
return error(str(exc), 409)
except QuotaExceeded as exc:
return error(str(exc), 413)
except (OSError, ValidationError) as exc:
return error(f'Création impossible : {exc}', 409)
finally:
# Do not leave an orphan file if a DB transaction rolls back.
if created and not committed and destination is not None:
destination.unlink(missing_ok=True)
@api_view(['PATCH'])
def update_item(request,item_id):
item=get_item(item_id)

View File

@ -18,6 +18,7 @@ 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 .document_formats import render_odf_preview
from .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS
from .views_content import safe_preview_type, read_bytes
@ -177,9 +178,9 @@ 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'):
if inline and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp')):
try:
html = render_word_preview(path)
html = render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path)
except ValueError:
raise Http404
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')

View File

@ -80,6 +80,7 @@ export const drive={
updateUserAccess:(id,kind,spaceId,permissions)=>api.patch(`/drive/admin/users/${id}/access/${kind}/${spaceId}/`,{permissions}).then(r=>r.data),
stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data),
folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data),
createFile:(name,type,parent,space)=>api.post('/drive/files/create/',{name,type,parent,space}).then(r=>r.data),
patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data),
trash:id=>api.post(`/drive/items/${id}/trash/`),
emptyTrash:()=>api.delete('/drive/trash/').then(r=>r.data),

View File

@ -2,12 +2,14 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import ContextActions, {spaceOptions, mobileDots} from './ContextActions'
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from 'lucide-react'
import { Database, FolderPlus, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
import { Database, FolderPlus, FilePlus2, FileText, ExternalLink, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
import { localFileGroups, googleFileTypes } from '@/config/creatable-files'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel } from '@/components/ui/dropdown-menu'
import { sections } from '@/config/navigation'
import { fmtSize } from '@/lib/drive-format'
export default function DriveSidebar({ controller, user, onLogout }) {
const { sidebar, view, search, setValue, setModal, setSidebar, section, stats, spaces, activeSpaceId, activeSpace, switchSpace, canWrite,
const { sidebar, view, search, setValue, setModal, setSidebar, section, stats, spaces, activeSpaceId, activeSpace, switchSpace, canWrite, setNewFileType,
pendingUploads, cancelPendingUpload, uploads, setSpaceDialog, setManagedSpaceId } = controller
const [spaceMenu,setSpaceMenu] = useState(null)
@ -55,6 +57,12 @@ export default function DriveSidebar({ controller, user, onLogout }) {
setSidebar(false)
}
const newFile = type => {
setNewFileType(type.extension)
setValue(`${type.suggestedName}.${type.extension}`)
setModal('new-file')
setSidebar(false)
}
return (
<aside className="drive-sidebar flex flex-col" data-open={sidebar}>
<div className="flex h-20 items-center gap-3 px-5">
@ -72,9 +80,31 @@ export default function DriveSidebar({ controller, user, onLogout }) {
</div>
<div className="px-4 pb-6 pt-3">
<Button onClick={newFolder} disabled={!canWrite || view==='trash'} className="h-11 w-full justify-start gap-2 rounded-xl shadow-sm">
<FolderPlus className="size-5" /> Nouveau dossier
</Button>
<DropdownMenu>
<DropdownMenuTrigger render={<Button disabled={!canWrite || view !== 'files' && view !== 'shared'} className="h-11 w-full justify-start gap-2 rounded-xl shadow-sm"/>}>
<FilePlus2 className="size-5"/> Nouveau <ChevronDown className="ml-auto size-4"/>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="min-w-52">
<DropdownMenuSub>
<DropdownMenuSubTrigger><FileText className="size-4"/> Fichier</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-52">
{localFileGroups.map(group => <DropdownMenuSub key={group.label}>
<DropdownMenuSubTrigger>{group.label}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-52">
{group.types.map(type => <DropdownMenuItem key={type.extension} onClick={() => newFile(type)}>{type.label} <span className="ml-auto text-xs text-muted-foreground">.{type.extension}</span></DropdownMenuItem>)}
</DropdownMenuSubContent>
</DropdownMenuSub>)}
<DropdownMenuSeparator/>
<DropdownMenuLabel>Google Drive · création externe</DropdownMenuLabel>
{googleFileTypes.map(type => <DropdownMenuItem key={type.url} onClick={() => window.open(type.url, '_blank', 'noopener,noreferrer')}>
{type.label} <ExternalLink className="ml-auto size-3.5"/>
</DropdownMenuItem>)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator/>
<DropdownMenuItem onClick={newFolder}><FolderPlus className="size-4"/> Dossier</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="min-h-0 overflow-y-auto px-3 pb-4">

View File

@ -6,6 +6,6 @@ import { HardDrive, ChevronRight, Folder } from 'lucide-react'
export default function ItemFormDialog({controller: c}) {
const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c
return (
<Dialog open={modal==='folder'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription className={modal==='move' ? undefined : 'sr-only'}>{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : 'Nouveau nom'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.permissions?.includes('add')).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
<Dialog open={modal==='folder'||modal==='new-file'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='new-file'?'Créer un fichier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription className={modal==='move' ? undefined : 'sr-only'}>{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : modal==='new-file' ? 'Nom du nouveau fichier' : 'Nouveau nom'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.permissions?.includes('add')).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
)
}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { LoaderCircle, FileText, Download, RefreshCcw, Maximize2, Minimize2 } from 'lucide-react'
@ -18,57 +18,32 @@ function Preview({ item, url, onError, fullscreen }) {
const name = item?.name?.toLowerCase() || ''
const mime = (item?.mime_type || '').toLowerCase()
const mediaHeight = fullscreen ? 'max-h-full' : 'max-h-[70dvh]'
const frameHeight = fullscreen ? 'h-full min-h-0' : 'h-[70dvh]'
const frameHeight = fullscreen ? 'h-full min-h-0' : 'h-[min(70dvh,760px)]'
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 (/\.(docx|odt|ods|odp)$/.test(name)) return <iframe title={item.name} src={url} onError={onError} sandbox="" className={`${frameHeight} min-w-0 w-full flex-1 rounded-md bg-white`} />
if (mime === 'application/pdf' || name.endsWith('.pdf')) return <iframe title={item.name} src={url} onError={onError} className={`block ${frameHeight} min-w-0 w-full flex-1 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>
if (mime.startsWith('text/') || mime === 'application/json' || mime === 'application/xml' || /\.(txt|md|csv|json|xml|log|py|js|jsx|ts|tsx|css|html|svg)$/.test(name)) return <iframe title={item.name} src={url} onError={onError} className={`${textHeight} w-full rounded-md bg-white`} sandbox="" />
if (mime.startsWith('text/') || mime === 'application/json' || mime === 'application/xml' || /\.(txt|md|csv|json|xml|log|py|js|jsx|ts|tsx|css|html|svg)$/.test(name)) return <iframe title={item.name} src={url} onError={onError} className={`${textHeight} min-w-0 w-full flex-1 rounded-md bg-white`} sandbox="" />
return <p className="p-8 text-sm text-muted-foreground">Aperçu non disponible pour ce type de fichier.</p>
}
export default function PreviewDialog({ controller: c }) {
const { preview, setPreview, previewUrl, setPreviewUrl, download, openEntry } = c
const dialogRef = useRef(null)
const [fullscreen, setFullscreen] = useState(false)
// Browser fullscreen and fallback window-sized dialog use the same layout.
// Escape in native fullscreen restores the normal dialog automatically.
// Maximize the *card* only. Never call the browser Fullscreen API.
useEffect(() => {
const update = () => {
if (!document.fullscreenElement) setFullscreen(false)
else setFullscreen(document.fullscreenElement === dialogRef.current)
}
document.addEventListener('fullscreenchange', update)
return () => document.removeEventListener('fullscreenchange', update)
}, [])
if (!preview) setFullscreen(false)
}, [preview])
const close = () => {
if (document.fullscreenElement === dialogRef.current) {
document.exitFullscreen?.().catch(() => {})
}
setFullscreen(false)
setPreview(null)
setPreviewUrl('')
}
const toggleFullscreen = async () => {
if (document.fullscreenElement === dialogRef.current) {
await document.exitFullscreen()
return
}
const el = dialogRef.current
if (el?.requestFullscreen) {
try {
await el.requestFullscreen()
setFullscreen(true)
return
} catch { /* Fallback for browsers that block fullscreen. */ }
}
setFullscreen(value => !value)
}
const toggleFullscreen = () => setFullscreen(value => !value)
const previewFailed = previewUrl === 'unsupported'
const handleMediaError = () => {
@ -77,19 +52,16 @@ export default function PreviewDialog({ controller: c }) {
return <Dialog open={!!preview} onOpenChange={value => { if (!value) close() }}>
<DialogContent
ref={dialogRef}
showCloseButton={false}
data-preview-fullscreen={fullscreen ? 'true' : undefined}
className={fullscreen
? 'gooddrive-preview-dialog grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 overflow-hidden border-0 bg-background p-4'
: 'gooddrive-preview-dialog grid max-h-[calc(100dvh-1rem)] w-[calc(100vw-1.5rem)] max-w-[min(96vw,1100px)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden'}
// The dialog's usual centering uses the CSS `translate` property (Tailwind v4),
// not `transform`. Reset both or the fullscreen popup stays offset.
? 'gooddrive-preview-dialog grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 overflow-hidden rounded-xl bg-background p-4'
: 'gooddrive-preview-dialog grid max-h-[calc(100dvh-1rem)] w-[calc(100vw-1.5rem)] max-w-[min(96vw,1100px)] sm:max-w-[min(96vw,1100px)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden'}
style={fullscreen ? {
position: 'fixed', inset: 0, top: 0, left: 0, right: 0, bottom: 0,
position: 'fixed', top: '12px', left: '12px', right: '12px', bottom: '12px',
translate: 'none', transform: 'none', margin: 0,
boxSizing: 'border-box', width: '100vw', height: '100dvh',
maxWidth: 'none', maxHeight: 'none', borderRadius: 0,
width: 'calc(100vw - 24px)', maxWidth: 'none',
height: 'calc(100dvh - 24px)', maxHeight: 'none', boxSizing: 'border-box',
} : undefined}
>
<DialogHeader className="min-w-0">
@ -97,7 +69,7 @@ export default function PreviewDialog({ controller: c }) {
<DialogDescription>{preview && !preview.is_folder ? fmtSize(preview.size) : ''}</DialogDescription>
</DialogHeader>
<div className={fullscreen
? 'flex min-h-0 h-full w-full items-center justify-center overflow-auto rounded-xl bg-muted/60'
? 'flex min-h-0 h-full w-full items-center justify-center overflow-hidden rounded-xl bg-muted/60'
: 'flex min-h-36 max-h-[72dvh] w-full items-center justify-center overflow-auto rounded-xl bg-muted/60'}>
{preview && <Preview item={preview} url={previewUrl} fullscreen={fullscreen} onError={handleMediaError}/>}
</div>

View File

@ -0,0 +1,34 @@
/** Actual locally stored formats. Extend together with backend/document_formats.py
* and the preview dispatchers whenever supporting another document type. */
export const localFileGroups = [
{ label: 'Documents', types: [
{ label: 'Word', extension: 'docx', suggestedName: 'Document' },
{ label: 'Texte', extension: 'txt', suggestedName: 'Texte' },
{ label: 'Markdown', extension: 'md', suggestedName: 'Notes' },
] },
{ label: 'Code', types: [
{ label: 'Python', extension: 'py', suggestedName: 'script' },
{ label: 'JavaScript', extension: 'js', suggestedName: 'script' },
{ label: 'React JSX', extension: 'jsx', suggestedName: 'Composant' },
{ label: 'TypeScript', extension: 'ts', suggestedName: 'script' },
{ label: 'React TSX', extension: 'tsx', suggestedName: 'Composant' },
{ label: 'CSS', extension: 'css', suggestedName: 'styles' },
{ label: 'HTML', extension: 'html', suggestedName: 'index' },
{ label: 'JSON', extension: 'json', suggestedName: 'data' },
{ label: 'XML', extension: 'xml', suggestedName: 'document' },
{ label: 'CSV', extension: 'csv', suggestedName: 'tableau' },
] },
{ label: 'LibreOffice', types: [
{ label: 'Writer', extension: 'odt', suggestedName: 'Document' },
{ label: 'Calc', extension: 'ods', suggestedName: 'Classeur' },
{ label: 'Impress', extension: 'odp', suggestedName: 'Présentation' },
] },
]
// Google-native Docs/Sheets/Slides are cloud objects, not blank local files.
// These links create them on Google's service; they are not saved to GoodDrive.
export const googleFileTypes = [
{ label: 'Google Docs (externe)', url: 'https://docs.google.com/document/u/0/create' },
{ label: 'Google Sheets (externe)', url: 'https://docs.google.com/spreadsheets/u/0/create' },
{ label: 'Google Slides (externe)', url: 'https://docs.google.com/presentation/u/0/create' },
]

View File

@ -28,6 +28,7 @@ export function useDriveController(user) {
const [modal, setModal] = useState(null)
const [action, setAction] = useState(null)
const [value, setValue] = useState('')
const [newFileType, setNewFileType] = useState('txt')
const [moveFolder, setMoveFolder] = useState(null)
const [moveTrail, setMoveTrail] = useState([])
const [moveItems, setMoveItems] = useState([])
@ -320,6 +321,7 @@ export function useDriveController(user) {
const actionSubmit = event => {
event.preventDefault()
if (modal === 'folder') return run(() => drive.folder(value,parent,view==='shared'?sharedFolderAccess.space_id:activeSpaceId))
if (modal === 'new-file') return run(() => drive.createFile(value,newFileType,parent,view==='shared'?sharedFolderAccess.space_id:activeSpaceId))
if (modal === 'rename') return run(() => drive.patch(action.id, { name: value }))
if (modal === 'move') return run(() => drive.patch(action.id, { parent: moveFolder }))
}
@ -372,7 +374,7 @@ export function useDriveController(user) {
managedSpaceId, setManagedSpaceId, spaceDialog, setSpaceDialog,
pendingUploads, cancelPendingUpload,
error, setError, note, setNote, layout, setLayout, sort, setSort, dark, setDark,
sidebar, setSidebar, modal, setModal, action, value, setValue,
sidebar, setSidebar, modal, setModal, action, value, setValue, newFileType, setNewFileType,
moveTrail, moveItems, preview, setPreview, previewUrl, setPreviewUrl,
busy, emptyingTrash, emptyTrash, uploads, drag, setDrag, filesInput,
refresh, section, openEntry, download, uploadFiles, startAction,

View File

@ -42,21 +42,8 @@
.drive-file:focus-within .row-action{opacity:1}
@media(max-width:767px){.drive-sidebar{position:fixed;z-index:35;inset:0 auto 0 0;transform:translateX(-110%);transition:transform .2s;box-shadow:0 15px 30px #0002}.drive-sidebar[data-open="true"]{transform:translateX(0)}}
/* Le dialogue d'aperçu garde normalement une translation de centrage.
En plein écran, cette translation doit être supprimée, y compris
avant le rendu React qui suit l'événement fullscreenchange. */
.gooddrive-preview-dialog:fullscreen,
/* Le mode agrandi change uniquement les dimensions de la card.
Il ne doit pas être forcé en plein écran de navigateur. */
.gooddrive-preview-dialog[data-preview-fullscreen="true"] {
position: fixed !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100dvh !important;
max-width: none !important;
max-height: none !important;
margin: 0 !important;
translate: none !important;
transform: none !important;
border-radius: 0 !important;
transition: width 120ms ease, height 120ms ease;
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ArrowLeft, Download, File, Folder, FolderOpen, LoaderCircle, Eye, X, Image, FileText, ShieldCheck, Maximize2, Minimize2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { drive, message, publicFileUrl } from '@/api/client'
@ -7,7 +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(/\.(docx|odt|ods|odp)$/.test(n))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'
@ -17,14 +17,14 @@ function typeOf(item) {
function Viewer({item,token,fullscreen}) {
const kind=typeOf(item)
const src=publicFileUrl(token,item.id,true)
const mediaHeight=fullscreen?'max-h-[calc(100dvh-5rem)]':'max-h-[72vh]'
const frameHeight=fullscreen?'h-[calc(100dvh-5rem)]':'h-[70vh]'
const mediaHeight=fullscreen?'max-h-full':'max-h-full'
const frameHeight='h-full min-h-0'
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==='word')return <iframe title={item.name} src={src} sandbox="" className={`${frameHeight} min-w-0 w-full flex-1 rounded-xl bg-white`}/>
if(kind==='pdf')return <iframe title={item.name} src={src} className={`${frameHeight} min-w-0 w-full flex-1 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"/>
if(kind==='text')return <iframe sandbox="" title={item.name} src={src} className={`${frameHeight} w-full rounded-xl bg-white`}/>
if(kind==='text')return <iframe sandbox="" title={item.name} src={src} className={`${frameHeight} min-w-0 w-full flex-1 rounded-xl bg-white`}/>
return <div className="p-10 text-center text-muted-foreground">Aperçu indisponible pour ce format.</div>
}
export default function PublicSharePage({token}) {
@ -33,27 +33,10 @@ export default function PublicSharePage({token}) {
const [selected,setSelected]=useState(null)
const [error,setError]=useState('')
const [loading,setLoading]=useState(true)
const viewerRef=useRef(null)
const [fullscreen,setFullscreen]=useState(false)
const [fullscreenSupported,setFullscreenSupported]=useState(false)
useEffect(()=>{
setFullscreenSupported(Boolean(document.fullscreenEnabled))
const update=()=>setFullscreen(document.fullscreenElement===viewerRef.current)
document.addEventListener('fullscreenchange', update)
return ()=>document.removeEventListener('fullscreenchange', update)
},[])
const closeViewer=()=>{
if(document.fullscreenElement===viewerRef.current) document.exitFullscreen?.().catch(()=>{})
setSelected(null)
}
const toggleFullscreen=async()=>{
try {
if(document.fullscreenElement===viewerRef.current) await document.exitFullscreen()
else await viewerRef.current?.requestFullscreen()
} catch {
// Browsers may deny fullscreen; leave the normal viewer usable.
}
}
const closeViewer=()=>{setSelected(null);setFullscreen(false)}
const toggleFullscreen=()=>setFullscreen(value=>!value)
useEffect(()=>{if(!selected)setFullscreen(false)},[selected])
const load=useCallback(async()=>{
setLoading(true)
try { setData(await drive.publicBrowse(token,parent));setError('') }
@ -93,9 +76,11 @@ export default function PublicSharePage({token}) {
</>}
</main>
</div>
{selected && <div ref={viewerRef} className="fixed inset-0 z-50 flex flex-col bg-black/95 p-3 text-white sm:p-6" role="dialog" aria-modal="true" aria-label={`Visualiser ${selected.name}`}>
<div className="mb-4 flex items-center justify-between gap-2"><p className="min-w-0 truncate font-medium">{selected.name}</p><div className="flex items-center gap-2">{fullscreenSupported && !['video', 'audio'].includes(typeOf(selected)) && <Button variant="secondary" size="icon" aria-label={fullscreen ? "Quitter le plein écran" : "Afficher en plein écran"} title={fullscreen ? "Réduire" : "Plein écran"} onClick={toggleFullscreen}>{fullscreen ? <Minimize2 className="size-5"/> : <Maximize2 className="size-5"/>}</Button>}<a className="inline-flex items-center gap-2 rounded-xl bg-white/15 px-3 py-2 text-sm hover:bg-white/25" href={publicFileUrl(token,selected.id)}><Download className="size-4"/> Télécharger</a><Button variant="secondary" size="icon" aria-label="Fermer l’aperçu" onClick={closeViewer}><X className="size-5"/></Button></div></div>
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto"><Viewer item={selected} token={token} fullscreen={fullscreen}/></div>
{selected && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-3 sm:p-6" role="presentation" onMouseDown={event=>{if(event.target===event.currentTarget)closeViewer()}}>
<div role="dialog" aria-modal="true" aria-label={`Visualiser ${selected.name}`} className={`flex min-h-0 w-full flex-col overflow-hidden rounded-2xl bg-card p-3 text-foreground shadow-2xl sm:p-4 ${fullscreen?'h-full max-w-none':'h-[min(82dvh,850px)] max-w-5xl'}`}>
<div className="mb-4 flex items-center justify-between gap-2"><p className="min-w-0 truncate font-medium">{selected.name}</p><div className="flex items-center gap-2">{!['video', 'audio'].includes(typeOf(selected)) && <Button variant="secondary" size="icon" aria-label={fullscreen ? "Quitter le plein écran" : "Afficher en plein écran"} title={fullscreen ? "Réduire" : "Plein écran"} onClick={toggleFullscreen}>{fullscreen ? <Minimize2 className="size-5"/> : <Maximize2 className="size-5"/>}</Button>}<a className="inline-flex items-center gap-2 rounded-xl bg-white/15 px-3 py-2 text-sm hover:bg-white/25" href={publicFileUrl(token,selected.id)}><Download className="size-4"/> Télécharger</a><Button variant="secondary" size="icon" aria-label="Fermer l’aperçu" onClick={closeViewer}><X className="size-5"/></Button></div></div>
<div className="flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden rounded-xl bg-muted"><Viewer item={selected} token={token} fullscreen={fullscreen}/></div>
</div>
</div>}
</div>
}