"""Short-lived, permission-bound preview/download URLs and HTTP byte ranges.""" import mimetypes import re from django.contrib.auth import get_user_model from django.core.signing import TimestampSigner, BadSignature, SignatureExpired from django.http import FileResponse, Http404, StreamingHttpResponse, HttpResponse from django.views.decorators.clickjacking import xframe_options_sameorigin from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response 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'} AUDIO_TYPES = {'audio/mpeg','audio/ogg','audio/wav','audio/mp4','audio/webm','audio/flac','audio/aac','audio/x-wav'} TEXT_TYPES = {'text/plain','text/csv','text/markdown','text/x-python','text/javascript','text/css', 'application/json','application/xml','text/xml'} 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')): return 'text/html; charset=utf-8' if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}: return mime if mime in TEXT_TYPES or mime in {'text/html','image/svg+xml'} or mime.startswith('text/'): return 'text/plain; charset=utf-8' # Never render user HTML/scripts inline. return None @api_view(['POST']) def content_url(request,item_id): item=get_item(item_id) if item.is_folder or not readable(item,request.user):return error('Fichier inaccessible.',403) if not request.data.get('inline',False) and 'download' not in item_rights(request.user,item): return error('Téléchargement non autorisé.',403) inline=bool(request.data.get('inline',False)) if inline and not safe_preview_type(item):return error('Aperçu non disponible pour ce type de fichier.',415) # Encode the authorized identity so access is checked again on every request. token=TimestampSigner(salt='drive-content').sign(f'{item.pk}:{int(inline)}:{request.user.pk}') return Response({'url':f'/api/drive/items/{item.pk}/content/?token={token}','expires_in':7200}) def read_bytes(path, start, end): with path.open('rb') as source: source.seek(start) remaining=end-start+1 while remaining>0: chunk=source.read(min(1024*1024,remaining)) if not chunk:break remaining-=len(chunk) yield chunk @api_view(['GET','HEAD']) @permission_classes([AllowAny]) @xframe_options_sameorigin def content(request,item_id): token=request.query_params.get('token','') try: value=TimestampSigner(salt='drive-content').unsign(token,max_age=7200) expected,inline,user_pk=value.rsplit(':',2) if expected!=str(item_id) or inline not in ('0','1'):raise BadSignature() user=get_user_model().objects.get(pk=int(user_pk),is_active=True) except (BadSignature,SignatureExpired,ValueError,TypeError,get_user_model().DoesNotExist): raise Http404 item=get_item(item_id) if item.is_folder or not readable(item,user) or (inline=='0' and 'download' not in item_rights(user,item)):raise Http404 media=safe_preview_type(item) if inline=='1' and not media:raise Http404 path=storage_path(item) 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')): try: 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') response['Cache-Control'] = 'private, no-store' response['Content-Security-Policy'] = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:" response['X-Content-Type-Options'] = 'nosniff' return response name=item.name byte_range=request.headers.get('Range') # Native video/audio controls commonly request Range: bytes=0-. if byte_range: match=re.fullmatch(r'bytes=(\d*)-(\d*)',byte_range.strip()) if not match or (not match[1] and not match[2]): response=HttpResponse(status=416) response['Content-Range']=f'bytes */{size}' return response if match[1]: start=int(match[1]);end=int(match[2]) if match[2] else size-1 else: suffix=int(match[2]);start=max(0,size-suffix);end=size-1 end=min(end,size-1) if start>=size or end