django-markdownx/markdownx/forms.py

189 lines
6.7 KiB
Python
Executable file

from os import path, SEEK_END, SEEK_SET
from io import BytesIO
from uuid import uuid4
from collections import namedtuple
from django import forms
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import InMemoryUploadedFile
from .utils import scale_and_crop, xml_has_javascript
from .exceptions import MarkdownxImageUploadError
from .settings import (
MARKDOWNX_IMAGE_MAX_SIZE,
MARKDOWNX_MEDIA_PATH,
MARKDOWNX_UPLOAD_CONTENT_TYPES,
MARKDOWNX_UPLOAD_MAX_SIZE,
MARKDOWNX_SVG_JAVASCRIPT_PROTECTION
)
class ImageForm(forms.Form):
"""
Used for the handling of images uploaded using the editor through :guilabel:`AJAX`.
"""
image = forms.FileField()
# Separately defined as it needs to be
# processed a text file rather than image.
_SVG_TYPE = 'image/svg+xml'
def save(self, commit=True):
"""
Saves the uploaded image in the designated location.
If image type is not SVG, a byteIO of image content_type is created and
subsequently save; otherwise, the SVG is saved in its existing ``charset``
as an ``image/svg+xml``.
*Note*: The dimension of image files (excluding SVG) are set using ``PIL``.
:param commit: If ``True``, the file is saved to the disk;
otherwise, it is held in the memory.
:type commit: bool
:return: An instance of saved image if ``commit is True``,
else ``namedtuple(path, image)``.
:rtype: bool, namedtuple
"""
image = self.files.get('image')
content_type = image.content_type
file_name = image.name
image_extension = content_type.split('/')[-1].upper()
image_size = image.size
if content_type.lower() != self._SVG_TYPE:
# Processing the raster graphic image.
# Note that vector graphics in SVG format
# do not require additional processing and
# may be stored as uploaded.
image = self._process_raster(image, image_extension)
image_size = image.tell()
image.seek(0, SEEK_SET)
# Processed file (or the actual file in the case of SVG) is now
# saved in the memory as a Django object.
uploaded_image = InMemoryUploadedFile(
file=image,
field_name=None,
name=file_name,
content_type=content_type,
size=image_size,
charset=None
)
if (content_type.lower() == self._SVG_TYPE
and MARKDOWNX_SVG_JAVASCRIPT_PROTECTION
and xml_has_javascript(uploaded_image.read())):
raise MarkdownxImageUploadError(
'Failed security monitoring: SVG file contains JavaScript.'
)
return self._save(uploaded_image, file_name, commit)
def _save(self, image, file_name, commit):
"""
Final saving process, called internally after processing tasks are complete.
:param image: Prepared image
:type image: django.core.files.uploadedfile.InMemoryUploadedFile
:param file_name: Name of the file using which the image is to be saved.
:type file_name: str
:param commit: If ``True``, the image is saved onto the disk.
:type commit: bool
:return: URL of the uploaded image ``commit=True``, otherwise a namedtuple
of ``(path, image)`` where ``path`` is the absolute path generated
for saving the file, and ``image`` is the prepared image.
:rtype: str, namedtuple
"""
# Defining a universally unique name for the file
# to be saved on the disk.
unique_file_name = self.get_unique_file_name(file_name)
full_path = path.join(MARKDOWNX_MEDIA_PATH, unique_file_name)
if commit:
default_storage.save(full_path, image)
return default_storage.url(full_path)
# If `commit is False`, return the path and in-memory image.
image_data = namedtuple('image_data', ['path', 'image'])
return image_data(path=full_path, image=image)
@staticmethod
def _process_raster(image, extension):
"""
Processing of raster graphic image using Python Imaging Library (PIL).
This is where raster graphics are processed to the specifications
as defined in ``settings.py``.
*Note*: The file needs to be uploaded and saved temporarily in the
memory to enable processing tasks using Python Imaging Library (PIL)
to take place and subsequently retained until written onto the disk.
:param image: Non-SVG image as processed by Django.
:type image: django.forms.BaseForm.file
:param extension: Image extension (e.g.: png, jpg, gif)
:type extension: str
:return: The image object ready to be written into a file.
:rtype: BytesIO
"""
thumb_io = BytesIO()
preped_image = scale_and_crop(image, **MARKDOWNX_IMAGE_MAX_SIZE)
preped_image.save(thumb_io, extension)
thumb_io.seek(0, SEEK_END)
return thumb_io
@staticmethod
def get_unique_file_name(file_name):
"""
Generates a universally unique ID using Python ``UUID`` and attaches the
extension of file name to it.
:param file_name: Name of the uploaded file, including the extension.
:type file_name: str
:return: Universally unique ID, ending with the extension extracted
from ``file_name``.
:rtype: str
"""
extension = 1
extension_dot_index = 1
file_name = "{unique_name}.{extension}".format(
unique_name=uuid4(),
extension=path.splitext(file_name)[extension][extension_dot_index:]
)
return file_name
def clean(self):
"""
Checks the upload against allowed extensions and maximum size.
:return: Upload
"""
upload = self.cleaned_data.get('image')
# -----------------------------------------------
# See comments in `self._error_templates` for
# additional information on each error.
# -----------------------------------------------
if not upload:
raise MarkdownxImageUploadError.not_uploaded()
content_type = upload.content_type
file_size = upload.size
if content_type not in MARKDOWNX_UPLOAD_CONTENT_TYPES:
raise MarkdownxImageUploadError.unsupported_format()
elif file_size > MARKDOWNX_UPLOAD_MAX_SIZE:
raise MarkdownxImageUploadError.invalid_size(
current=file_size,
expected=MARKDOWNX_UPLOAD_MAX_SIZE
)
return upload