#!/usr/bin/env python2.7
#
# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Rewrites blob download headers in the response with full blob contents."""


import logging

from google.appengine.api import apiproxy_stub_map
from google.appengine.api import datastore
from google.appengine.api import datastore_errors
from google.appengine.api.files import file_service_stub
from google.appengine.ext import blobstore


# The MIME type from apps to tell Blobstore to select the mime type.
_AUTO_MIME_TYPE = 'application/vnd.google.appengine.auto'


def _get_blob_storage():
  """Gets the BlobStorage instance from the API proxy stub map.

  Returns:
    The BlobStorage instance as registered with blobstore API in stub map.
  """
  return apiproxy_stub_map.apiproxy.GetStub('blobstore').storage


def _parse_range_header(range_header):
  """Parse HTTP Range header.

  Args:
    range_header: A str representing the value of a range header as retrived
      from Range or X-AppEngine-BlobRange.

  Returns:
    Tuple (start, end):
      start: Start index of blob to retrieve.  May be negative index.
      end: None or end index.  End index is exclusive.
    (None, None) if there is a parse error.
  """
  if not range_header:
    return None, None
  try:
    # ValueError if <1 split.
    range_type, ranges = range_header.split('=', 1)
    if range_type != 'bytes':
      return None, None
    ranges = ranges.lstrip()
    if ',' in ranges:
      return None, None
    end = None
    if ranges.startswith('-'):
      start = int(ranges)
      if start == 0:
        return None, None
    else:
      split_range = ranges.split('-', 1)
      start = int(split_range[0])
      if len(split_range) == 2 and split_range[1].strip():
        end = int(split_range[1]) + 1
        if start > end:
          return None, None
    return start, end
  except ValueError:
    return None, None


def _get_google_storage_file_metadata(blob_key):
  """Retreive metadata about a GS blob from the blob_key.

  Args:
    blob_key: The BlobKey of the blob.

  Returns:
    Tuple (size, content_type, open_key):
      size: The size of the blob.
      content_type: The content type of the blob.
      open_key: The key used as an argument to BlobStorage to open the blob
        for reading.
    (None, None, None) if the blob metadata was not found.
  """
  try:
    gs_info = datastore.Get(
        datastore.Key.from_path(file_service_stub.GS_INFO_KIND,
                                blob_key,
                                namespace=''))
    return gs_info['size'], gs_info['content_type'], gs_info['storage_key']
  except datastore_errors.EntityNotFoundError:
    return None, None, None


def _get_blobstore_metadata(blob_key):
  """Retreive metadata about a blobstore blob from the blob_key.

  Args:
    blob_key: The BlobKey of the blob.

  Returns:
    Tuple (size, content_type, open_key):
      size: The size of the blob.
      content_type: The content type of the blob.
      open_key: The key used as an argument to BlobStorage to open the blob
        for reading.
    (None, None, None) if the blob metadata was not found.
  """
  try:
    blob_info = datastore.Get(
        datastore.Key.from_path(blobstore.BLOB_INFO_KIND,
                                blob_key,
                                namespace=''))
    return blob_info['size'], blob_info['content_type'], blob_key
  except datastore_errors.EntityNotFoundError:
    return None, None, None


def _get_blob_metadata(blob_key):
  """Retrieve the metadata about a blob from the blob_key.

  Args:
    blob_key: The BlobKey of the blob.

  Returns:
    Tuple (size, content_type, open_key):
      size: The size of the blob.
      content_type: The content type of the blob.
      open_key: The key used as an argument to BlobStorage to open the blob
        for reading.
    (None, None, None) if the blob metadata was not found.
  """
  size, content_type, open_key = _get_google_storage_file_metadata(blob_key)
  if size is None:
    size, content_type, open_key = _get_blobstore_metadata(blob_key)
  return size, content_type, open_key


def blobstore_download_rewriter(state):
  """Rewrite a response with blobstore download bodies.

  Checks for the X-AppEngine-BlobKey header in the response.  If found, it will
  discard the body of the request and replace it with the blob content
  indicated.

  If a valid blob is not found, it will send a 404 to the client.

  If the application itself provides a content-type header, it will override
  the content-type stored in the action blob.

  If blobstore.BLOB_RANGE_HEADER header is provided, blob will be partially
  served.  If Range is present, and not blobstore.BLOB_RANGE_HEADER, will use
  Range instead.

  Args:
    state: A request_rewriter.RewriterState to modify.
  """
  blob_key = state.headers.get(blobstore.BLOB_KEY_HEADER)
  if not blob_key:
    # Pass the download through unchanged.
    return

  def set_range_request_not_satisfiable(blob_size):
    """Short circuit response and return 416 error.

    Changes state into the error response.

    Args:
      blob_size: The size of the blob.
    """
    state.status = '416 Requested Range Not Satisfiable'
    state.headers['Content-Length'] = '0'
    state.headers['Content-Range'] = '*/%d' % blob_size
    del state.headers['Content-Type']

  # Rewrite the response to include the blob contents.
  state.body = []
  del state.headers[blobstore.BLOB_KEY_HEADER]

  blob_size, blob_content_type, blob_open_key = _get_blob_metadata(blob_key)
  if isinstance(blob_content_type, unicode):
    blob_content_type = blob_content_type.encode('ascii')

  range_header = state.headers.get(blobstore.BLOB_RANGE_HEADER)
  if range_header is not None:
    del state.headers[blobstore.BLOB_RANGE_HEADER]
  else:
    range_header = state.environ.get('HTTP_RANGE')

  status_code = state.status_code

  # If we found a blob, serve it.
  # It is an error if the response code returned by the user is not 200.
  if (blob_size is not None and blob_content_type is not None and
      status_code == 200):
    content_length = blob_size
    start = 0
    end = content_length

    if range_header:
      start, end = _parse_range_header(range_header)
      if start is None:
        set_range_request_not_satisfiable(blob_size)
        return
      else:
        if start < 0:
          start = max(blob_size + start, 0)  # Start is negative.
        elif start >= blob_size:
          set_range_request_not_satisfiable(blob_size)
          return
        if end is not None:
          end = min(end, blob_size)
        else:
          end = blob_size
        content_length = min(end, blob_size) - start
        end = start + content_length
        state.status = '206 Partial Content'
        state.headers['Content-Range'] = 'bytes %d-%d/%d' % (start, end - 1,
                                                             blob_size)

    blob_stream = _get_blob_storage().OpenBlob(blob_open_key)
    blob_stream.seek(start)
    state.body = [blob_stream.read(content_length)]
    state.headers['Content-Length'] = str(content_length)

    content_type = state.headers.get('Content-Type')
    if not content_type or content_type == _AUTO_MIME_TYPE:
      state.headers['Content-Type'] = blob_content_type
    # Allow responses beyond the maximum dynamic response size
    state.allow_large_response = True

  else:
    # Missing blobs should have been handled by application.
    if status_code != 200:
      logging.error('Blob-serving response with status %d, expected 200.',
                    status_code)
    else:
      logging.error('Could not find blob with key %s.', blob_key)

    state.status = '500 Internal Server Error'

    state.headers['Content-Length'] = '0'
    del state.headers['Content-Type']
