Source code for duck.html.components.core.lively_utils.file_request

"""
File request: Lively WebSocket utilities module.
"""
import uuid
import asyncio

from dataclasses import dataclass
from typing import Dict, List, Optional, Callable

from duck.shortcuts import jsonify, resolve
from duck.settings.loaded import SettingsLoaded
from duck.http.response import JsonResponse
from duck.http.fileuploads import BaseFileUpload, FileUploadError, FileTypeNotAllowedError
from duck.html.components.core.websocket import LivelyWebSocketView
from duck.html.components.core.opcodes import EventOpCode
from duck.views import csrf_exempt


# Default seconds to wait for a client to start uploading
DEFAULT_FILE_TIMEOUT = 30

# Default total seconds to wait for file upload.
DEFAULT_TOTAL_FILE_TIMEOUT = 30 * 60

# Requested files
REQUESTED_FILES: Dict[str, "RequestedFileEntry"]  = {}

# Initiated File uploads
STARTED_FILE_UPLOADS: Dict[str, asyncio.Future] = {}


[docs] class FileUploadNotFoundError(FileUploadError): """ Raised when a requested file upload cannot be found. """
[docs] class FileNotSelectedError(FileUploadError): """ Raised when no selected file on file input. """
[docs] @dataclass(slots=True) class RequestedFileEntry: """ Requested file entry dataclass. """ file_id: str future: asyncio.Future allowed_mimes: Optional[List[str]] token_secret: str on_progress: Optional[Callable]
[docs] def clean_requested_file(file_id: str): """ Clean up a requested file entry after it resolves, times out, or errors. Args: file_id: Unique identifier of the requested file. """ REQUESTED_FILES.pop(file_id, None) STARTED_FILE_UPLOADS.pop(file_id, None)
[docs] def mark_file_upload_started(file_id: str, strict: bool = False) -> bool: """ Mark a pending file upload as started. Args: file_id: Unique identifier of the file upload. strict: Whether to raise `FileUploadNotFoundError` when the upload does not exist. Returns: True if the upload was successfully marked as started, otherwise False when `strict` is False. Raises: FileUploadNotFoundError: If the upload does not exist and `strict` is True. asyncio.InvalidStateError: If the upload has already been marked as started or otherwise has a completed future. """ future = STARTED_FILE_UPLOADS.get(file_id) if future is None: if strict: raise FileUploadNotFoundError(f"File upload '{file_id}' not found.") return False if future.done(): return True # Update future state future.set_result(True) # Return final state return True
[docs] def mark_file_upload_failed(file_id: str, reason: str = "", strict: bool = False) -> bool: """ Mark a pending file upload as failed. Args: file_id: Unique identifier of the file upload. reason: Optional reason describing why the upload failed. strict: Whether to raise `FileUploadNotFoundError` when the upload does not exist. Returns: True if the upload was successfully marked as failed, otherwise False when `strict` is False. Raises: FileUploadNotFoundError: If the upload does not exist and `strict` is True. asyncio.InvalidStateError: If the upload has already been marked as failed or otherwise has a completed future. """ future = STARTED_FILE_UPLOADS.get(file_id) if future is None: if strict: raise FileUploadNotFoundError(f"File upload '{file_id}' not found.") return False if future.done(): return True # Generate message message = reason or f"File upload '{file_id}' failed." # Set exception future.set_exception(FileUploadError(message)) # Return completion flag return True
[docs] def notify_file_upload_progress(file_id: str, percent: float, strict: bool = False) -> bool: """ Notify the server of a file upload's current progress. Args: file_id: Unique identifier of the file upload. percent: Upload progress as a percentage (0-100). strict: Whether to raise `FileUploadNotFoundError` when the upload does not exist. Returns: True if the progress was successfully notified, otherwise False when `strict` is False. Raises: FileUploadNotFoundError: If the upload does not exist and `strict` is True. """ entry = REQUESTED_FILES.get(file_id) if entry is None: if strict: raise FileUploadNotFoundError(f"File upload '{file_id}' was not found.") return False if entry.on_progress is not None: entry.on_progress(percent) # Return final flag return True
[docs] async def ws_request_file( form_id: str, name: str, ws: LivelyWebSocketView, *, allowed_mimes: Optional[List[str]] = None, on_progress: Optional[Callable[[int], None]] = None, timeout: float = DEFAULT_FILE_TIMEOUT, total_timeout = DEFAULT_TOTAL_FILE_TIMEOUT, ) -> BaseFileUpload: """ Request a file from the client inside a Lively event handler. Sends a command over the websocket telling the client to open a file picker and upload the selected file to the receiving view, then waits for that upload to complete. Args: form_id: The ID of the form to target. name: Name of the file to request (usually the name of the file input). ws: Active Lively websocket connection for the current client. allowed_mimes: Optional list of mimetypes to expect from client. on_progress: Optional callable to call on file upload progress. Defaults to None. timeout: Seconds to wait before giving up on the upload. total_timeout: Total seconds in overall for the whole file upload to finish. Returns: BaseFileUpload instance inheriting from `io.BytesIO`. Raises: TimeoutError: If the client does not upload a file in time. ValueError: If on_progress is not None and not a callable. """ from duck.http.middlewares.security.csrf import ( generate_csrf_secret, mask_cipher_secret, ) if on_progress and not callable(on_progress): raise ValueError("Argument on_progress must be a callable or None.") file_id = str(uuid.uuid4()) # Generate unique file ID event_loop = asyncio.get_event_loop() file_future = event_loop.create_future() file_started_future = event_loop.create_future() allowed_mimes = allowed_mimes or [] upload_url = resolve("receive_ws_file") auth_secret = generate_csrf_secret() auth_token = mask_cipher_secret(auth_secret) fire_on_progress = bool(on_progress) # Add file upload started future STARTED_FILE_UPLOADS[file_id] = file_started_future # Add future to the requested files REQUESTED_FILES[file_id] = RequestedFileEntry( file_id=file_id, future=file_future, allowed_mimes=allowed_mimes, auth_secret=auth_secret, on_progress=on_progress, ) # Tell client to open its file picker and upload to the receiving view await ws.send_data([EventOpCode.REQUEST_FILE, [form_id, file_id, name, upload_url, allowed_mimes, fire_on_progress, auth_token], ]) try: try: file_started = await asyncio.wait_for(file_started_future, timeout=timeout) except asyncio.TimeoutError as e: raise TimeoutError(f"File upload did not start in {timeout} seconds.") try: uploaded_file = await asyncio.wait_for(file_future, timeout=total_timeout) except asyncio.TimeoutError: raise TimeoutError(f"File upload slow, total file upload failed to complete in {total_timeout} seconds.") # Return the final uploaded file. return uploaded_file except FileTypeNotAllowedError: raise except FileUploadError as e: error = str(e) error_lower = error.lower() if "no file selected" in error_lower: raise NoFileSelectedError(error) if "file type" in error_lower and "allowed" in error_lower: raise FileTypeNotAllowedError(error) raise finally: # Clean data after if not done yet. clean_requested_file(file_id)
[docs] @csrf_exempt async def receive_ws_file(request) -> JsonResponse: """ View for receiving an uploaded file from the client. Saves the file using the configured upload handler and resolves the matching future so the waiting async_request_file call can continue. Args: request: Incoming Duck request carrying the multipart file upload. Returns: JSON response acknowledging receipt or describing the error. """ from duck.http.middlewares.security.csrf import unmask_cipher_token file_id: Optional[str] = request.POST.get("id") auth_token: Optional[str] = request.POST.get("token", "") uploaded_file: Optional[BaseFileUpload] = request.FILES.get(file_id) requested_file_entry: Optional[RequestedFileEntry] = REQUESTED_FILES.get(file_id, None) # Reject uploads with no matching pending request if not requested_file_entry or not uploaded_file: return jsonify({"error": "Invalid or expired file_id"}, status_code=400) # Verify auth token auth_secret = requested_file_entry.auth_secret try: declared_secret = unmask_cipher_token(auth_token) if declared_secret != auth_secret: raise ValueError("Invalid file token") except ValueError: return jsonify({"error": "Invalid or empty file token"}, status_code=400) # Get the future future = requested_file_entry.future if future.done(): return jsonify({"error": "Upload already completed"}, status_code=409) # Do some validations here. allowed_mimes = requested_file_entry.allowed_mimes try: # Verify allowed file upload data uploaded_file.verify(allowed_mimes=allowed_mimes) # Set future result future.set_result(uploaded_file) except Exception as e: future.set_exception(e) finally: clean_requested_file(file_id) return jsonify({"status": "received", "id": file_id})