"""Read-only, case-scoped file browsing including linked mail attachments.""" from io import BytesIO from pathlib import Path from html import escape from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, Response from app.core.auth_dependencies import get_current_user from app.core.database import execute_query router=APIRouter(dependencies=[Depends(get_current_user)]) MAX_BYTES=25*1024*1024 def catalog(sag_id): if not execute_query('SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL',(sag_id,)):raise HTTPException(404,detail='Sagen findes ikke') files=execute_query("SELECT *, 'case' AS source FROM sag_files WHERE sag_id=%s",(sag_id,)) or [] mails=execute_query("SELECT a.id,a.email_id,a.filename,a.content_type,a.size_bytes,a.file_path, 'mail' AS source,e.subject AS mail_subject FROM email_attachments a JOIN email_messages e ON e.id=a.email_id JOIN sag_emails se ON se.email_id=e.id WHERE se.sag_id=%s AND e.deleted_at IS NULL",(sag_id,)) or [] return files+mails def read_file(sag_id,source,file_id): row=next((r for r in catalog(sag_id) if r['source']==source and r['id']==file_id),None) if not row:raise HTTPException(404,detail='Filen er ikke knyttet til sagen') if source=='case': from .router import _resolve_attachment_path path=_resolve_attachment_path(row['stored_name']) else:path=Path(row['file_path']) if row.get('file_path') else None if path and path.is_file(): if path.stat().st_size>MAX_BYTES:raise HTTPException(413,detail='Filen er for stor til forhåndsvisning (25 MB)') content=path.read_bytes() else: if source=='mail' and 'content_data' not in row: saved=execute_query('SELECT content_data FROM email_attachments WHERE id=%s',(file_id,)) row['content_data']=saved[0].get('content_data') if saved else None content=bytes(row.get('content_data') or b'') if not content:raise HTTPException(404,detail='Filens indhold mangler') if len(content)>MAX_BYTES:raise HTTPException(413,detail='Filen er for stor til forhåndsvisning (25 MB)') return row,content def inspect_file(name,content): kind=Path(name).suffix.lower() if kind=='.txt':return {'pages':1,'text':content.decode('utf-8',errors='replace')} if kind=='.pdf': import pdfplumber with pdfplumber.open(BytesIO(content)) as pdf: if len(pdf.pages)>300:raise HTTPException(413,detail='PDF har over 300 sider') return {'pages':len(pdf.pages),'text':'\n'.join(p.extract_text() or '' for p in pdf.pages)} return {'pages':1,'text':''} @router.get('/sag/{sag_id}/file-browser') def list_files(sag_id:int): return [{'id':r['id'],'source':r['source'],'filename':r['filename'],'type':Path(r['filename']).suffix.lower().lstrip('.') or 'andet','size':r.get('size_bytes') or 0,'email_id':r.get('email_id'),'mail_subject':r.get('mail_subject')} for r in catalog(sag_id)] @router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/text') def text(sag_id:int,source:str,file_id:int): row,content=read_file(sag_id,source,file_id) try:return inspect_file(row['filename'],content) except HTTPException:raise except Exception:raise HTTPException(422,detail='Tekst kunne ikke læses; filen kan være beskyttet eller beskadiget') @router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/download') def download(sag_id:int,source:str,file_id:int): row,content=read_file(sag_id,source,file_id) return Response(content,media_type='application/octet-stream',headers={'Content-Disposition':"attachment; filename*=UTF-8''"+quote(row['filename'],safe=''),'X-Content-Type-Options':'nosniff'}) @router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/page/{page}') def page_image(sag_id:int,source:str,file_id:int,page:int,thumbnail:bool=False): row,content=read_file(sag_id,source,file_id) if Path(row['filename']).suffix.lower()!='.pdf':raise HTTPException(400,detail='Sidevisning gælder PDF') import pypdfium2 as pdfium try: with pdfium.PdfDocument(content) as pdf: if page<1 or page>len(pdf):raise HTTPException(404,detail='Siden findes ikke') p=pdf[page-1] try: scale=min(1.4,(220 if thumbnail else 1600)/max(p.get_size())) bitmap=p.render(scale=scale) try: out=BytesIO();bitmap.to_pil().save(out,format='PNG');return Response(out.getvalue(),media_type='image/png') finally:bitmap.close() finally:p.close() except HTTPException:raise except Exception:raise HTTPException(422,detail='PDF kunne ikke vises') @router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/image') def image(sag_id:int,source:str,file_id:int,thumbnail:bool=False): row,content=read_file(sag_id,source,file_id) from PIL import Image, ImageOps, UnidentifiedImageError try: with Image.open(BytesIO(content)) as original: if original.width*original.height>40_000_000: raise HTTPException(413,detail='Billedet er for stort til visning') rendered=ImageOps.exif_transpose(original) rendered.thumbnail((220,160) if thumbnail else (2000,2000)) if rendered.mode not in ('RGB','RGBA'):rendered=rendered.convert('RGBA') out=BytesIO();rendered.save(out,format='PNG') return Response(out.getvalue(),media_type='image/png',headers={'X-Content-Type-Options':'nosniff'}) except HTTPException:raise except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError): raise HTTPException(422,detail='Billedet kunne ikke vises') @router.get('/sag/{sag_id}/file-browser/{source}/{file_id}/thumbnail') def thumbnail(sag_id:int,source:str,file_id:int): row=next((r for r in catalog(sag_id) if r['source']==source and r['id']==file_id),None) if not row:raise HTTPException(404,detail='Filen er ikke knyttet til sagen') kind=Path(row['filename']).suffix.lower().lstrip('.') try: if kind=='pdf':return page_image(sag_id,source,file_id,1,thumbnail=True) if kind in {'jpg','jpeg','png','gif','webp','bmp','tif','tiff','ico'}:return image(sag_id,source,file_id,thumbnail=True) except HTTPException:pass # Keep a visible file-type thumbnail for damaged files. label=escape((kind or 'FIL')[:10].upper()) svg=f'{label}' return Response(svg,media_type='image/svg+xml',headers={'X-Content-Type-Options':'nosniff','Content-Security-Policy':"default-src 'none'; style-src 'none'; sandbox"})