This commit is contained in:
Louis Mazin 2026-10-14 19:24:07 +02:00
parent 3fea8a85c8
commit f0fbefa642
13 changed files with 171 additions and 40 deletions

View File

@ -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 ('''<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f6fa;color:#262d3d;font:15px/1.5 system-ui,sans-serif;padding:24px}
main{width:min(100%,520px);text-align:center;background:#fff;border:1px solid #e1e5f0;border-radius:18px;padding:36px 24px;box-shadow:0 8px 25px #1321420d}
h1{font-size:22px}p{color:#657082}a{display:inline-block;margin-top:14px;background:#465a9a;color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600}</style></head><body><main>
<h1>''' + escape(label) + '''</h1><p>Ce fichier est un raccourci Google. Son contenu reste hébergé sur Google Drive.</p>
<a href="''' + escape(url, quote=True) + '''" target="_blank" rel="noopener noreferrer">Ouvrir sur Google</a>
<p>L'accès dépend des autorisations du document Google.</p></main></body></html>''')

View File

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

View File

@ -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

View File

@ -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'

View File

@ -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 && <div className="mb-3"><Banner text={error} close={() => setError('')} /></div>}
{note && <div className="mb-3"><Banner text={note} close={() => setNote('')} /></div>}
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card shadow-sm">
<ScrollArea className="min-h-0 flex-1 rounded-2xl border bg-card shadow-sm">
{loading ? (
<div className="flex h-full min-h-72 items-center justify-center gap-3 text-sm text-muted-foreground">
<LoaderCircle className="size-5 animate-spin" /> Chargement des fichiers…
@ -135,7 +136,7 @@ export default function DriveExplorer({ controller, user }) {
) : (
<FileGrid items={display} onOpen={isTrash ? () => {} : openEntry} onActions={startAction} onContext={openContext} />
)}
</div>
</ScrollArea>
<AlertDialog open={confirmEmptyTrash && isTrash} onOpenChange={setConfirmEmptyTrash}>
<AlertDialogContent>

View File

@ -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 => <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/>
@ -107,7 +104,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
</DropdownMenu>
</div>
<div className="min-h-0 overflow-y-auto px-3 pb-4">
<ScrollArea className="min-h-0 flex-1"><div className="px-3 pb-4">
{personal && <button type="button" onClick={()=>switchSpace(personal.id)}
className={`mb-4 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold ${activeSpaceId===personal.id ? 'bg-accent text-primary' : 'text-muted-foreground hover:bg-muted'}`}>
<FolderLock className="size-[19px] shrink-0"/><span className="truncate">Mes fichiers</span>
@ -139,7 +136,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
)
})}
</nav>
</div>
</div></ScrollArea>
<div className="mt-auto space-y-3 p-4">
{view === 'files' && activeSpace && <div className="rounded-2xl border bg-muted/45 p-4">

View File

@ -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 <img src={url} onError={onError} alt={item.name} className={`${mediaHeight} max-w-full object-contain`} />
if (/\.(gdoc|gsheet|gslides)$/.test(name)) return <iframe title={item.name} src={url} onError={onError} sandbox="allow-popups allow-popups-to-escape-sandbox" className={`${frameHeight} min-w-0 w-full flex-1 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>

View File

@ -36,14 +36,14 @@ function ScrollBar({
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
"flex touch-none rounded-full bg-muted/40 p-px transition-colors select-none hover:bg-muted/60 data-horizontal:h-2.5 data-horizontal:flex-col data-vertical:h-full data-vertical:w-2.5",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
className="relative flex-1 rounded-full bg-muted-foreground/35 transition-colors hover:bg-primary/55"
/>
</ScrollAreaPrimitive.Scrollbar>
)

View File

@ -24,11 +24,3 @@ export const localFileGroups = [
{ 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

@ -47,3 +47,18 @@
.gooddrive-preview-dialog[data-preview-fullscreen="true"] {
transition: width 120ms ease, height 120ms ease;
}
/* Scrollbars consistent with the shadcn theme, including native overflow panels. */
:root { scrollbar-color: color-mix(in oklab, var(--muted-foreground) 42%, transparent) transparent; scrollbar-width: thin; }
*::-webkit-scrollbar { width: 9px; height: 9px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: color-mix(in oklab, var(--muted-foreground) 35%, transparent);
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklab, var(--primary) 62%, transparent);
background-clip: padding-box;
}

View File

@ -1,12 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
import { Users, LoaderCircle, Trash2, KeyRound, Menu } from 'lucide-react'
import { Users, LoaderCircle, Trash2, KeyRound, Menu, Search, Moon, Sun, RefreshCcw } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
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({onOpenSidebar}){
export default function AdminUsersPage({onOpenSidebar, dark, setDark}){
const [query,setQuery] = useState('')
const [record,setRecord] = useState({users:[]})
const [busy,setBusy] = useState(true)
@ -63,13 +64,32 @@ export default function AdminUsersPage({onOpenSidebar}){
catch(e){setDeleteError(message(e))}
finally{setDeleting(false)}
}
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">
<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>
return <>
<header className="flex h-[76px] shrink-0 items-center justify-between gap-3 border-b bg-card/70 px-4 sm:px-7">
<div className="flex min-w-0 items-center gap-3">
<Button type="button" variant="ghost" size="icon" className="shrink-0 md:hidden" aria-label="Ouvrir le menu" onClick={onOpenSidebar}><Menu className="size-5"/></Button>
<h2 className="truncate text-base font-semibold sm:text-lg">Utilisateurs</h2>
</div>
<div className="flex items-center gap-2">
<div className="relative hidden w-64 lg:block">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input aria-label="Rechercher un utilisateur" placeholder="Rechercher un utilisateur…" className="h-9 pl-9" value={query} onChange={e=>setQuery(e.target.value)}/>
</div>
<Button type="button" variant="ghost" size="icon" title="Changer le thème" aria-label="Changer le thème" onClick={()=>setDark(value=>!value)}>{dark?<Sun/>:<Moon/>}</Button>
<Button type="button" variant="ghost" size="icon" title="Actualiser" aria-label="Actualiser les utilisateurs" onClick={refresh}><RefreshCcw/></Button>
</div>
</header>
<ScrollArea className="min-h-0 flex-1">
<section className="px-4 pb-4 pt-6 sm:px-7 sm:pb-7">
<div className="mb-4 lg:hidden">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input aria-label="Rechercher un utilisateur" placeholder="Rechercher un utilisateur…" className="pl-9" value={query} onChange={e=>setQuery(e.target.value)}/>
</div>
</div>
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div><h1 className="text-2xl font-semibold tracking-tight">Utilisateurs</h1><p className="mt-1 text-sm text-muted-foreground">{busy?'Chargement…':`${record.users.length} utilisateur${record.users.length>1?'s':''}`}</p></div>
</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">
<table className="w-full min-w-[650px] text-left text-sm"><thead className="bg-muted/50 text-muted-foreground"><tr><th className="p-3">Utilisateur</th><th className="p-3">Identifiant</th><th className="p-3">E-mail</th><th className="p-3">Espace attribué</th><th className="p-3">Statut</th><th className="p-3 text-right">Actions</th></tr></thead>
@ -85,7 +105,8 @@ export default function AdminUsersPage({onOpenSidebar}){
{!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>}
</div>}
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
</div>
</section>
</ScrollArea>
<Dialog open={!!accessUser} onOpenChange={open=>{if(!open&&!accessBusy)setAccessUser(null)}}>
<DialogContent className="sm:max-w-xl">
<DialogHeader><DialogTitle>Accès de @{accessUser?.username}</DialogTitle>
@ -116,7 +137,7 @@ export default function AdminUsersPage({onOpenSidebar}){
</form>
</DialogContent>
</Dialog>
</div>
</>
}
function AccessPermissionsRow({access,busy,save,revoke}) {

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 onOpenSidebar={() => setSidebar(true)} /> : <>
{controller.view === 'users' && user.is_admin ? <AdminUsersPage onOpenSidebar={() => setSidebar(true)} dark={controller.dark} setDark={controller.setDark} /> : <>
<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(/\.(gdoc|gsheet|gslides)$/.test(n))return 'google'
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'
@ -20,6 +21,7 @@ function Viewer({item,token,fullscreen}) {
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==='google')return <iframe title={item.name} src={src} sandbox="allow-popups allow-popups-to-escape-sandbox" className={`${frameHeight} min-w-0 w-full flex-1 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`}/>