import logging import sys import tempfile import traceback from asgiref.sync import sync_to_async from django.conf import settings from django.core import signals from django.core.exceptions import RequestAborted, RequestDataTooBig from django.core.handlers import base from django.http import ( FileResponse, HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseServerError, QueryDict, parse_cookie, ) from django.urls import set_script_prefix from django.utils.functional import cached_property logger = logging.getLogger('django.request') class ASGIRequest(HttpRequest): """ Custom request subclass that decodes from an ASGI-standard request dict and wraps request body handling. """ # Number of seconds until a Request gives up on trying to read a request # body and aborts. body_receive_timeout = 60 def __init__(self, scope, body_file): self.scope = scope self._post_parse_error = False self._read_started = False self.resolver_match = None self.script_name = self.scope.get('root_path', '') if self.script_name and scope['path'].startswith(self.script_name): # TODO: Better is-prefix checking, slash handling? self.path_info = scope['path'][len(self.script_name):] else: self.path_info = scope['path'] # The Django path is different from ASGI scope path args, it should # combine with script name. if self.script_name: self.path = '%s/%s' % ( self.script_name.rstrip('/'), self.path_info.replace('/', '', 1), ) else: self.path = scope['path'] # HTTP basics. self.method = self.scope['method'].upper() # Ensure query string is encoded correctly. query_string = self.scope.get('query_string', '') if isinstance(query_string, bytes): query_string = query_string.decode() self.META = { 'REQUEST_METHOD': self.method, 'QUERY_STRING': query_string, 'SCRIPT_NAME': self.script_name, 'PATH_INFO': self.path_info, # WSGI-expecting code will need these for a while 'wsgi.multithread': True, 'wsgi.multiprocess': True, } if self.scope.get('client'): self.META['REMOTE_ADDR'] = self.scope['client'][0] self.META['REMOTE_HOST'] = self.META['REMOTE_ADDR'] self.META['REMOTE_PORT'] = self.scope['client'][1] if self.scope.get('server'): self.META['SERVER_NAME'] = self.scope['server'][0] self.META['SERVER_PORT'] = str(self.scope['server'][1]) else: self.META['SERVER_NAME'] = 'unknown' self.META['SERVER_PORT'] = '0' # Headers go into META. for name, value in self.scope.get('headers', []): name = name.decode('latin1') if name == 'content-length': corrected_name = 'CONTENT_LENGTH' elif name == 'content-type': corrected_name = 'CONTENT_TYPE' else: corrected_name = 'HTTP_%s' % name.upper().replace('-', '_') # HTTP/2 say only ASCII chars are allowed in headers, but decode # latin1 just in case. value = value.decode('latin1') if corrected_name in self.META: value = self.META[corrected_name] + ',' + value self.META[corrected_name] = value # Pull out request encoding, if provided. self._set_content_type_params(self.META) # Directly assign the body file to be our stream. self._stream = body_file # Other bits. self.resolver_match = None @cached_property def GET(self): return QueryDict(self.META['QUERY_STRING']) def _get_scheme(self): return self.scope.get('scheme') or super()._get_scheme() def _get_post(self): if not hasattr(self, '_post'): self._load_post_and_files() return self._post def _set_post(self, post): self._post = post def _get_files(self): if not hasattr(self, '_files'): self._load_post_and_files() return self._files POST = property(_get_post, _set_post) FILES = property(_get_files) @cached_property def COOKIES(self): return parse_cookie(self.META.get('HTTP_COOKIE', '')) class ASGIHandler(base.BaseHandler): """Handler for ASGI requests.""" request_class = ASGIRequest # Size to chunk response bodies into for multiple response messages. chunk_size = 2 ** 16 def __init__(self): super().__init__() self.load_middleware(is_async=True) async def __call__(self, scope, receive, send): """ Async entrypoint - parses the request and hands off to get_response. """ # Serve only HTTP connections. # FIXME: Allow to override this. if scope['type'] != 'http': raise ValueError( 'Django can only handle ASGI/HTTP connections, not %s.' % scope['type'] ) # Receive the HTTP request body as a stream object. try: body_file = await self.read_body(receive) except RequestAborted: return # Request is complete and can be served. set_script_prefix(self.get_script_prefix(scope)) await sync_to_async(signals.request_started.send, thread_sensitive=True)(sender=self.__class__, scope=scope) # Get the request and check for basic issues. request, error_response = self.create_request(scope, body_file) if request is None: await self.send_response(error_response, send) return # Get the response, using the async mode of BaseHandler. response = await self.get_response_async(request) response._handler_class = self.__class__ # Increase chunk size on file responses (ASGI servers handles low-level # chunking). if isinstance(response, FileResponse): response.block_size = self.chunk_size # Send the response. await self.send_response(response, send) async def read_body(self, receive): """Reads a HTTP body from an ASGI connection.""" # Use the tempfile that auto rolls-over to a disk file as it fills up. body_file = tempfile.SpooledTemporaryFile(max_size=settings.FILE_UPLOAD_MAX_MEMORY_SIZE, mode='w+b') while True: message = await receive() if message['type'] == 'http.disconnect': # Early client disconnect. raise RequestAborted() # Add a body chunk from the message, if provided. if 'body' in message: body_file.write(message['body']) # Quit out if that's the end. if not message.get('more_body', False): break body_file.seek(0) return body_file def create_request(self, scope, body_file): """ Create the Request object and returns either (request, None) or (None, response) if there is an error response. """ try: return self.request_class(scope, body_file), None except UnicodeDecodeError: logger.warning( 'Bad Request (UnicodeDecodeError)', exc_info=sys.exc_info(), extra={'status_code': 400}, ) return None, HttpResponseBadRequest() except RequestDataTooBig: return None, HttpResponse('413 Payload too large', status=413) def handle_uncaught_exception(self, request, resolver, exc_info): """Last-chance handler for exceptions.""" # There's no WSGI server to catch the exception further up # if this fails, so translate it into a plain text response. try: return super().handle_uncaught_exception(request, resolver, exc_info) except Exception: return HttpResponseServerError( traceback.format_exc() if settings.DEBUG else 'Internal Server Error', content_type='text/plain', ) async def send_response(self, response, send): """Encode and send a response out over ASGI.""" # Collect cookies into headers. Have to preserve header case as there # are some non-RFC compliant clients that require e.g. Content-Type. response_headers = [] for header, value in response.items(): if isinstance(header, str): header = header.encode('ascii') if isinstance(value, str): value = value.encode('latin1') response_headers.append((bytes(header), bytes(value))) for c in response.cookies.values(): response_headers.append( (b'Set-Cookie', c.output(header='').encode('ascii').strip()) ) # Initial response message. await send({ 'type': 'http.response.start', 'status': response.status_code, 'headers': response_headers, }) # Streaming responses need to be pinned to their iterator. if response.streaming: # Access `__iter__` and not `streaming_content` directly in case # it has been overridden in a subclass. for part in response: for chunk, _ in self.chunk_bytes(part): await send({ 'type': 'http.response.body', 'body': chunk, # Ignore "more" as there may be more parts; instead, # use an empty final closing message with False. 'more_body': True, }) # Final closing message. await send({'type': 'http.response.body'}) # Other responses just need chunking. else: # Yield chunks of response. for chunk, last in self.chunk_bytes(response.content): await send({ 'type': 'http.response.body', 'body': chunk, 'more_body': not last, }) await sync_to_async(response.close, thread_sensitive=True)() @classmethod def chunk_bytes(cls, data): """ Chunks some data up so it can be sent in reasonable size messages. Yields (chunk, last_chunk) tuples. """ position = 0 if not data: yield data, True return while position < len(data): yield ( data[position:position + cls.chunk_size], (position + cls.chunk_size) >= len(data), ) position += cls.chunk_size def get_script_prefix(self, scope): """ Return the script prefix to use from either the scope or a setting. """ if settings.FORCE_SCRIPT_NAME: return settings.FORCE_SCRIPT_NAME return scope.get('root_path', '') or ''