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