104 lines
3.5 KiB
Python
104 lines
3.5 KiB
Python
import posixpath
|
|
from pathlib import Path
|
|
|
|
from django.conf import settings
|
|
from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse
|
|
from django.shortcuts import get_object_or_404
|
|
|
|
from .models import FileIndex
|
|
|
|
|
|
def _open_indexed_file(file_index):
|
|
"""Return an open binary file handle for the indexed file (404-safe)."""
|
|
stored = file_index.stored_path
|
|
if not stored or posixpath.isabs(stored) or '..' in stored.split('/'):
|
|
raise Http404
|
|
file_path = (Path(settings.MEDIA_ROOT) / stored).resolve()
|
|
if not file_path.is_relative_to(Path(settings.MEDIA_ROOT).resolve()):
|
|
raise Http404
|
|
if not file_path.is_file():
|
|
raise Http404
|
|
return open(file_path, 'rb')
|
|
|
|
|
|
def _unsatisfiable(content_length):
|
|
resp = HttpResponse(status=416)
|
|
resp['Content-Range'] = f'bytes */{content_length}'
|
|
return resp
|
|
|
|
|
|
def file_request(request, file_id):
|
|
"""Serve an indexed file behind the gate, honouring HTTP byte ranges.
|
|
|
|
GET /api/files/<uuid>/ → full file (200)
|
|
Range: bytes=0-99 → partial content (206)
|
|
Range: bytes=<start>- → tail from <start>
|
|
Range: bytes=-<suffix> → last <suffix> bytes
|
|
?download=1 → Content-Disposition: attachment
|
|
"""
|
|
file_index = get_object_or_404(FileIndex, uuid=file_id)
|
|
fh = _open_indexed_file(file_index)
|
|
|
|
content_type = file_index.content_type or 'application/octet-stream'
|
|
content_length = file_index.size or 0
|
|
disposition = ''
|
|
if request.GET.get('download') == '1':
|
|
filename = file_index.original_filename or posixpath.basename(file_index.stored_path)
|
|
disposition = f'attachment; filename="{filename}"'
|
|
|
|
range_header = request.META.get('HTTP_RANGE', '').strip()
|
|
if not range_header or not range_header.lower().startswith('bytes='):
|
|
response = FileResponse(fh, content_type=content_type)
|
|
response['Accept-Ranges'] = 'bytes'
|
|
response['Content-Length'] = str(content_length)
|
|
if disposition:
|
|
response['Content-Disposition'] = disposition
|
|
return response
|
|
|
|
try:
|
|
start, _, end = range_header[6:].partition('-')
|
|
start = int(start) if start else None
|
|
end = int(end) if end else None
|
|
except ValueError:
|
|
return _unsatisfiable(content_length)
|
|
|
|
if start is None and end is None:
|
|
return _unsatisfiable(content_length)
|
|
|
|
if start is None:
|
|
suffix = end
|
|
if suffix <= 0:
|
|
return _unsatisfiable(content_length)
|
|
start = max(content_length - suffix, 0)
|
|
end = content_length - 1
|
|
elif end is None:
|
|
end = content_length - 1
|
|
|
|
if start < 0 or start >= content_length or end < start:
|
|
return _unsatisfiable(content_length)
|
|
|
|
end = min(end, content_length - 1)
|
|
length = end - start + 1
|
|
fh.seek(start)
|
|
|
|
def chunk_generator():
|
|
try:
|
|
remaining = length
|
|
while remaining > 0:
|
|
data = fh.read(min(8192, remaining))
|
|
if not data:
|
|
break
|
|
remaining -= len(data)
|
|
yield data
|
|
finally:
|
|
fh.close()
|
|
|
|
part = StreamingHttpResponse(chunk_generator(), content_type=content_type)
|
|
part['Content-Range'] = f'bytes {start}-{end}/{content_length}'
|
|
part['Accept-Ranges'] = 'bytes'
|
|
part['Content-Length'] = str(length)
|
|
part.status_code = 206
|
|
if disposition:
|
|
part['Content-Disposition'] = disposition
|
|
return part
|