diff --git a/backend/drive/google_shortcuts.py b/backend/drive/google_shortcuts.py new file mode 100644 index 0000000..5583ecc --- /dev/null +++ b/backend/drive/google_shortcuts.py @@ -0,0 +1,62 @@ +"""Safe previews for Google Drive Desktop shortcut files (.gdoc/.gsheet/.gslides). + +Google-native documents are not stored inside these shortcuts. The preview +opens the linked document on Google with its normal Google permissions. +""" +from html import escape +import json +from pathlib import Path +import re +from urllib.parse import parse_qs, urlsplit + +SHORTCUT_TYPES = { + '.gdoc': ('document', 'Google Docs'), + '.gsheet': ('spreadsheets', 'Google Sheets'), + '.gslides': ('presentation', 'Google Slides'), +} +MAX_SHORTCUT_SIZE = 64 * 1024 +GOOGLE_ID = re.compile(r'^[A-Za-z0-9_-]{10,}$') + + +def google_shortcut_url(path): + """Read a tiny Google shortcut and reject untrusted/off-domain redirects.""" + path = Path(path) + subtype, _ = SHORTCUT_TYPES.get(path.suffix.lower(), (None, None)) + if subtype is None or path.stat().st_size > MAX_SHORTCUT_SIZE: + raise ValueError('Fichier Google incompatible.') + try: + data = json.loads(path.read_text(encoding='utf-8-sig')) + url = data['url'] + except (OSError, ValueError, KeyError, TypeError, UnicodeError) as exc: + raise ValueError('Raccourci Google illisible.') from exc + if not isinstance(url, str) or len(url) > 4096: + raise ValueError('URL Google invalide.') + parsed = urlsplit(url) + if parsed.scheme != 'https' or parsed.username or parsed.password or parsed.port or parsed.fragment: + raise ValueError('Lien Google non autorisé.') + if parsed.hostname == 'docs.google.com': + prefix = f'/{subtype}/d/' + if not parsed.path.startswith(prefix): + raise ValueError('Lien Google incompatible avec le document.') + doc_id = parsed.path[len(prefix):].split('/')[0] + if not GOOGLE_ID.fullmatch(doc_id): + raise ValueError('ID Google incorrect.') + elif parsed.hostname == 'drive.google.com' and parsed.path == '/open': + doc_id = parse_qs(parsed.query).get('id', [''])[0] + if not GOOGLE_ID.fullmatch(doc_id): + raise ValueError('ID Google incorrect.') + else: + raise ValueError('Lien Google non autorisé.') + return url + + +def render_google_shortcut(path): + url = google_shortcut_url(path) + label = SHORTCUT_TYPES[Path(path).suffix.lower()][1] + return ('''
+

''' + escape(label) + '''

Ce fichier est un raccourci Google. Son contenu reste hébergé sur Google Drive.

+Ouvrir sur Google +

L'accès dépend des autorisations du document Google.

''') diff --git a/backend/drive/test_google_shortcuts.py b/backend/drive/test_google_shortcuts.py new file mode 100644 index 0000000..288c16f --- /dev/null +++ b/backend/drive/test_google_shortcuts.py @@ -0,0 +1,36 @@ +"""No Google account is needed to validate safe link rendering.""" +import json +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import TestCase +from .google_shortcuts import google_shortcut_url, render_google_shortcut + +class GoogleShortcutTests(TestCase): + def check(self, ext, url, accepted): + with TemporaryDirectory() as temp: + path = Path(temp) / f'example{ext}' + path.write_text(json.dumps({'url': url}), encoding='utf-8') + if accepted: + self.assertEqual(google_shortcut_url(path),url) + html = render_google_shortcut(path) + self.assertIn('Ouvrir sur Google', html) + self.assertIn('rel="noopener noreferrer"',html) + else: + with self.assertRaises(ValueError): google_shortcut_url(path) + + def test_google_shortcuts(self): + self.check('.gdoc', 'https://docs.google.com/document/d/AbcDefGhijkLMN123/edit', True) + self.check('.gsheet', 'https://docs.google.com/spreadsheets/d/AbcDefGhijkLMN123/edit', True) + self.check('.gslides', 'https://docs.google.com/presentation/d/AbcDefGhijkLMN123/edit', True) + self.check('.gdoc', 'https://docs.google.com/spreadsheets/d/AbcDefGhijkLMN123/edit', False) + self.check('.gdoc', 'https://docs.google.com.evil.test/document/d/AbcDefGhijkLMN123/edit', False) + self.check('.gdoc', 'http://docs.google.com/document/d/AbcDefGhijkLMN123/edit', False) + self.check('.gdoc', 'https://drive.google.com/open?id=AbcDefGhijkLMN123', True) + + def test_reject_invalid_contents(self): + with TemporaryDirectory() as temp: + path = Path(temp) / 'broken.gdoc' + path.write_text('{broken',encoding='utf-8') + with self.assertRaises(ValueError): google_shortcut_url(path) + path.write_text('x'*70000,encoding='utf-8') + with self.assertRaises(ValueError): google_shortcut_url(path) diff --git a/backend/drive/views_content.py b/backend/drive/views_content.py index 78397d0..2022395 100644 --- a/backend/drive/views_content.py +++ b/backend/drive/views_content.py @@ -13,6 +13,7 @@ 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 +from .google_shortcuts import SHORTCUT_TYPES, render_google_shortcut 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'} @@ -23,7 +24,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', '.odt', '.ods', '.odp')): + if item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp', *SHORTCUT_TYPES)): return 'text/html; charset=utf-8' if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}: return mime @@ -76,14 +77,15 @@ 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', '.odt', '.ods', '.odp')): + if inline=='1' and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp', *SHORTCUT_TYPES)): try: - html = render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path) + html = (render_google_shortcut(path) if path.suffix.lower() in SHORTCUT_TYPES else + 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') response['Cache-Control'] = 'private, no-store' - response['Content-Security-Policy'] = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:" + response['Content-Security-Policy'] = ("sandbox allow-popups allow-popups-to-escape-sandbox; default-src 'none'; style-src 'unsafe-inline'" if path.suffix.lower() in SHORTCUT_TYPES else "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:") response['X-Content-Type-Options'] = 'nosniff' return response name=item.name diff --git a/backend/drive/views_shares.py b/backend/drive/views_shares.py index 5ce7a34..63c5dbc 100644 --- a/backend/drive/views_shares.py +++ b/backend/drive/views_shares.py @@ -19,6 +19,7 @@ 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 .google_shortcuts import SHORTCUT_TYPES, render_google_shortcut from .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS from .views_content import safe_preview_type, read_bytes @@ -178,14 +179,15 @@ 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', '.odt', '.ods', '.odp')): + if inline and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp', *SHORTCUT_TYPES)): try: - html = render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path) + html = (render_google_shortcut(path) if path.suffix.lower() in SHORTCUT_TYPES else + 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') response['Cache-Control'] = 'no-store' - response['Content-Security-Policy'] = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:" + response['Content-Security-Policy'] = ("sandbox allow-popups allow-popups-to-escape-sandbox; default-src 'none'; style-src 'unsafe-inline'" if path.suffix.lower() in SHORTCUT_TYPES else "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' diff --git a/frontend/src/components/drive/DriveExplorer.jsx b/frontend/src/components/drive/DriveExplorer.jsx index 7183fff..58ffb19 100644 --- a/frontend/src/components/drive/DriveExplorer.jsx +++ b/frontend/src/components/drive/DriveExplorer.jsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' import { ChevronRight, Clock3, FolderOpen, Grid2X2, List as ListIcon, Trash2, LoaderCircle, Search, Upload, @@ -105,7 +106,7 @@ export default function DriveExplorer({ controller, user }) { {error &&
setError('')} />
} {note &&
setNote('')} />
} -
+ {loading ? (
Chargement des fichiers… @@ -135,7 +136,7 @@ export default function DriveExplorer({ controller, user }) { ) : ( {} : openEntry} onActions={startAction} onContext={openContext} /> )} -
+
diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index cc4b669..12d7590 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -2,9 +2,10 @@ 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, 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 { Database, FolderPlus, FilePlus2, FileText, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react' +import { localFileGroups } from '@/config/creatable-files' +import { ScrollArea } from '@/components/ui/scroll-area' +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent } from '@/components/ui/dropdown-menu' import { sections } from '@/config/navigation' import { fmtSize } from '@/lib/drive-format' @@ -94,11 +95,7 @@ export default function DriveSidebar({ controller, user, onLogout }) { {group.types.map(type => newFile(type)}>{type.label} .{type.extension})} )} - - Google Drive · création externe - {googleFileTypes.map(type => window.open(type.url, '_blank', 'noopener,noreferrer')}> - {type.label} - )} + @@ -107,7 +104,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
-
+
{personal &&
+
{view === 'files' && activeSpace &&
diff --git a/frontend/src/components/drive/dialogs/PreviewDialog.jsx b/frontend/src/components/drive/dialogs/PreviewDialog.jsx index 4c7094c..09d2748 100644 --- a/frontend/src/components/drive/dialogs/PreviewDialog.jsx +++ b/frontend/src/components/drive/dialogs/PreviewDialog.jsx @@ -22,6 +22,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 {item.name} + if (/\.(gdoc|gsheet|gslides)$/.test(name)) return