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// → full file (200) Range: bytes=0-99 → partial content (206) Range: bytes=- → tail from Range: bytes=- → last 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