"""
|
HTTP views dedicated to LocalFiles storage download operations.
|
"""
|
import logging
|
import mimetypes
|
import os
|
import posixpath
|
from pathlib import Path
|
from typing import Optional
|
|
from django.conf import settings
|
from django.db.models import CharField, F, Value
|
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, HttpResponseNotFound, HttpResponseNotModified
|
from django.utils._os import safe_join
|
from drf_spectacular.utils import extend_schema
|
from io_storages.localfiles.models import LocalFilesImportStorage
|
from ranged_fileresponse import RangedFileResponse
|
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.permissions import IsAuthenticated
|
|
logger = logging.getLogger(__name__)
|
|
|
"""
|
Utility helpers for LocalFiles storage operations.
|
"""
|
|
|
def _if_none_match_satisfied(header_value: Optional[str], etag: str) -> bool:
|
"""Return True if the client's cached representation matches the current file."""
|
if not header_value:
|
return False
|
etag_candidates = [candidate.strip() for candidate in header_value.split(',') if candidate.strip()]
|
return '*' in etag_candidates or etag in etag_candidates
|
|
|
def build_localfile_response(
|
request: HttpRequest,
|
full_path: str,
|
if_none_match_header: Optional[str],
|
) -> HttpResponse:
|
"""
|
Stream the requested file and attach a weak ETag so browsers can cache it.
|
|
We also honor `If-None-Match` headers to short-circuit the response with 304
|
when the client's cached representation is still valid.
|
"""
|
try:
|
file_handle = open(full_path, mode='rb')
|
except OSError as exc:
|
logger.error('Error opening file %s: %s', full_path, exc)
|
return HttpResponseNotFound(f'Error opening file {full_path}')
|
|
# Weak ETag keeps the implementation simple while still invalidating on file edits.
|
stat_result = os.fstat(file_handle.fileno())
|
mtime_ns = getattr(stat_result, 'st_mtime_ns', int(stat_result.st_mtime * 1_000_000_000))
|
etag = f'W/"{mtime_ns:x}-{stat_result.st_size:x}"'
|
|
# Check if the client's cached representation matches the current file
|
if _if_none_match_satisfied(if_none_match_header, etag):
|
file_handle.close()
|
not_modified = HttpResponseNotModified()
|
not_modified['ETag'] = etag
|
return not_modified
|
|
# Detect mime type and encoding
|
content_type, _ = mimetypes.guess_type(str(full_path))
|
content_type = content_type or 'application/octet-stream'
|
|
response = RangedFileResponse(request, file_handle, content_type=content_type)
|
response['ETag'] = etag # Enables client-side caching for unchanged files.
|
return response
|
|
|
"""
|
Main view for serving files residing under LocalFilesImportStorage roots with ETag support
|
"""
|
|
|
@extend_schema(exclude=True)
|
@api_view(['GET'])
|
@permission_classes([IsAuthenticated])
|
def localfiles_data(request):
|
"""Serve files residing under LocalFilesImportStorage roots with ETag support."""
|
path = request.GET.get('d')
|
if settings.LOCAL_FILES_SERVING_ENABLED is False:
|
return HttpResponseForbidden(
|
"Serving local files can be dangerous, so it's disabled by default. "
|
'You can enable it with LOCAL_FILES_SERVING_ENABLED environment variable, '
|
'please check docs: https://labelstud.io/guide/storage.html#Local-storage'
|
)
|
|
local_serving_document_root = settings.LOCAL_FILES_DOCUMENT_ROOT
|
if path and request.user.is_authenticated:
|
# Normalize the incoming relative path so we don't depend on trailing slashes
|
path = posixpath.normpath(path).lstrip('/')
|
full_path = Path(safe_join(local_serving_document_root, path))
|
user_has_permissions = False
|
|
# Storage paths are normalized on save/migration, so prefix matches using the
|
# directory that contains the requested file stay consistent across OSes.
|
full_path_dir = os.path.normpath(os.path.dirname(str(full_path)))
|
localfiles_storage = LocalFilesImportStorage.objects.annotate(
|
_full_path=Value(full_path_dir, output_field=CharField())
|
).filter(_full_path__startswith=F('path'))
|
if localfiles_storage.exists():
|
user_has_permissions = any(storage.project.has_permission(request.user) for storage in localfiles_storage)
|
|
# Check user permissions for this file and if it exists
|
if user_has_permissions and os.path.exists(full_path):
|
# Detect mime type and encoding
|
return build_localfile_response(
|
request=request,
|
full_path=str(full_path),
|
if_none_match_header=request.META.get('HTTP_IF_NONE_MATCH'),
|
)
|
else:
|
return HttpResponseNotFound()
|
|
return HttpResponseForbidden()
|