diff --git a/backend/drive/document_formats.py b/backend/drive/document_formats.py new file mode 100644 index 0000000..81e1706 --- /dev/null +++ b/backend/drive/document_formats.py @@ -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', '''''') + archive.writestr('_rels/.rels', '''''') + archive.writestr('word/document.xml', '''''') + 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' 2500: break + tag = 'h2' if el.tag == TEXT + 'h' else 'p' + sections.append(f'<{tag}>' + (t(el) or ' ') + f'') + elif ext == 'ods': + for table in root.iter(TABLE + 'table'): + if len(sections) >= 12: break + sections.append('

' + escape(table.attrib.get(TABLE + 'name', 'Feuille')) + '

') + 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([''] * min(repeat, max(0, 60 - len(cells)))) + if len(cells) >= 60: break + if cells: sections.append('' + ''.join(cells) + '') + sections.append('
' + cell_text + '
') + 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('

' + name + '

' + ''.join('

' + x + '

' for x in paras) + '
') + content = ''.join(sections) or '

Document vide

' + return ('''
''' + + content + '
') diff --git a/backend/drive/templates/blank.odp b/backend/drive/templates/blank.odp new file mode 100644 index 0000000..887ab36 Binary files /dev/null and b/backend/drive/templates/blank.odp differ diff --git a/backend/drive/templates/blank.ods b/backend/drive/templates/blank.ods new file mode 100644 index 0000000..977b141 Binary files /dev/null and b/backend/drive/templates/blank.ods differ diff --git a/backend/drive/templates/blank.odt b/backend/drive/templates/blank.odt new file mode 100644 index 0000000..7b1bff8 Binary files /dev/null and b/backend/drive/templates/blank.odt differ diff --git a/backend/drive/test_create_documents.py b/backend/drive/test_create_documents.py new file mode 100644 index 0000000..b9f3636 --- /dev/null +++ b/backend/drive/test_create_documents.py @@ -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(']>&x;') + 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) diff --git a/backend/drive/urls.py b/backend/drive/urls.py index 2d3bd8a..33e4c9c 100644 --- a/backend/drive/urls.py +++ b/backend/drive/urls.py @@ -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), diff --git a/backend/drive/views.py b/backend/drive/views.py index 292930b..6d8238f 100644 --- a/backend/drive/views.py +++ b/backend/drive/views.py @@ -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) diff --git a/backend/drive/views_content.py b/backend/drive/views_content.py index 49fae46..78397d0 100644 --- a/backend/drive/views_content.py +++ b/backend/drive/views_content.py @@ -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') diff --git a/backend/drive/views_items.py b/backend/drive/views_items.py index bc0ebb5..4153055 100644 --- a/backend/drive/views_items.py +++ b/backend/drive/views_items.py @@ -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) diff --git a/backend/drive/views_shares.py b/backend/drive/views_shares.py index 4dc3a97..5ce7a34 100644 --- a/backend/drive/views_shares.py +++ b/backend/drive/views_shares.py @@ -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') diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 37c6515..b573aa6 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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), diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index 70ff9b9..cc4b669 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -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 (