mirror of
https://github.com/Hopiu/django-markdownx.git
synced 2026-04-25 07:24:43 +00:00
Separated exceptions, and added a JavaScript monitoring for SVG files.
This commit is contained in:
parent
f24628e237
commit
38649c964a
4 changed files with 254 additions and 78 deletions
50
markdownx/exceptions.py
Normal file
50
markdownx/exceptions.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.forms import ValidationError
|
||||
|
||||
|
||||
class MarkdownxImageUploadError(ValidationError):
|
||||
"""
|
||||
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def not_uploaded():
|
||||
"""
|
||||
No file is available to upload.
|
||||
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
return MarkdownxImageUploadError(_('No files have been uploaded.'))
|
||||
|
||||
@staticmethod
|
||||
def unsupported_format():
|
||||
"""
|
||||
The file is of a format not defined in `settings.py`
|
||||
or if default, in `markdownx/settings.py`.
|
||||
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
return MarkdownxImageUploadError(_('File type is not supported.'))
|
||||
|
||||
@staticmethod
|
||||
def invalid_size(current, expected):
|
||||
"""
|
||||
The file is larger in size that the maximum allow in `settings.py` (or the default).
|
||||
|
||||
:param current:
|
||||
:type current:
|
||||
:param expected:
|
||||
:type expected:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
from django.template.defaultfilters import filesizeformat
|
||||
|
||||
return MarkdownxImageUploadError(
|
||||
_('Please keep file size under {max}. Current file size {current}').format(
|
||||
max=filesizeformat(expected),
|
||||
current=filesizeformat(current)
|
||||
)
|
||||
)
|
||||
|
|
@ -7,59 +7,47 @@ from collections import namedtuple
|
|||
from django import forms
|
||||
from django.utils.six import BytesIO
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
from django.template.defaultfilters import filesizeformat
|
||||
|
||||
# Internal.
|
||||
from .utils import scale_and_crop
|
||||
from .utils import scale_and_crop, 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 AJAX.
|
||||
"""
|
||||
|
||||
image = forms.FileField()
|
||||
|
||||
# Separately defined as it needs to be processed a text file rather than image.
|
||||
# Separately defined as it needs to be
|
||||
# processed a text file rather than image.
|
||||
_SVG_TYPE = 'image/svg+xml'
|
||||
|
||||
_error_templates = {
|
||||
# No file is available to upload.
|
||||
'not_uploaded':
|
||||
forms.ValidationError(_('No files have been uploaded.')),
|
||||
|
||||
# The file is of a format not defined in "settings.py"
|
||||
# or if default, in "markdownx/settings.py".
|
||||
'unsupported_format':
|
||||
forms.ValidationError(_('File type is not supported.')),
|
||||
|
||||
# The file is larger in size that the maximum allow in "settings.py" (or the default).
|
||||
'invalid_size':
|
||||
lambda current: forms.ValidationError(
|
||||
_('Please keep file size under {max}. Current file size {current}').format(
|
||||
max=filesizeformat(MARKDOWNX_UPLOAD_MAX_SIZE),
|
||||
current=filesizeformat(current)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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`.
|
||||
subsequently save; otherwise, the SVG is saved in its existing ``charset``
|
||||
as an ``image/svg+xml``.
|
||||
|
||||
The dimension of image files (excluding SVG) are set using `PIL`.
|
||||
*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.
|
||||
: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)`.
|
||||
:return: An instance of saved image if ``commit is True``,
|
||||
else ``namedtuple(path, image)``.
|
||||
:rtype: bool, namedtuple
|
||||
"""
|
||||
image = self.files.get('image')
|
||||
|
|
@ -69,7 +57,10 @@ class ImageForm(forms.Form):
|
|||
image_size = getattr(image, '_size')
|
||||
|
||||
if content_type.lower() != self._SVG_TYPE:
|
||||
# Processing the raster graphic image:
|
||||
# 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()
|
||||
|
||||
|
|
@ -88,7 +79,18 @@ class ImageForm(forms.Form):
|
|||
|
||||
def _save(self, image, file_name, commit):
|
||||
"""
|
||||
Final saving process, called internally after the image had processed.
|
||||
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.
|
||||
|
|
@ -106,10 +108,22 @@ class ImageForm(forms.Form):
|
|||
@staticmethod
|
||||
def _process_raster(image, extension):
|
||||
"""
|
||||
Processing of raster graphic image.
|
||||
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
|
||||
"""
|
||||
# File needs to be uploaded and saved temporarily in
|
||||
# the memory for additional processing using PIL.
|
||||
thumb_io = BytesIO()
|
||||
preped_image = scale_and_crop(image, **MARKDOWNX_IMAGE_MAX_SIZE)
|
||||
preped_image.save(thumb_io, extension)
|
||||
|
|
@ -119,16 +133,19 @@ class ImageForm(forms.Form):
|
|||
@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.
|
||||
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`.
|
||||
: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)[1][1:] # [1] is the extension, [1:] discards the dot.
|
||||
extension=path.splitext(file_name)[extension][extension_dot_index:]
|
||||
)
|
||||
return file_name
|
||||
|
||||
|
|
@ -145,14 +162,28 @@ class ImageForm(forms.Form):
|
|||
# additional information on each error.
|
||||
# -----------------------------------------------
|
||||
if not upload:
|
||||
raise self._error_templates['not_uploaded']
|
||||
raise MarkdownxImageUploadError.not_uploaded()
|
||||
|
||||
content_type = upload.content_type
|
||||
file_size = getattr(upload, '_size')
|
||||
|
||||
if content_type not in MARKDOWNX_UPLOAD_CONTENT_TYPES:
|
||||
raise self._error_templates['unsupported_format']
|
||||
|
||||
raise MarkdownxImageUploadError.unsupported_format()
|
||||
|
||||
elif file_size > MARKDOWNX_UPLOAD_MAX_SIZE:
|
||||
raise self._error_templates['invalid_size'](file_size)
|
||||
|
||||
raise MarkdownxImageUploadError.invalid_size(
|
||||
current=file_size,
|
||||
expected=MARKDOWNX_UPLOAD_MAX_SIZE
|
||||
)
|
||||
|
||||
elif (content_type.lower() != self._SVG_TYPE
|
||||
and MARKDOWNX_SVG_JAVASCRIPT_PROTECTION
|
||||
and has_javascript(upload.read())):
|
||||
|
||||
raise MarkdownxImageUploadError(
|
||||
'Failed security monitoring: SVG file contains JavaScript.'
|
||||
)
|
||||
|
||||
return upload
|
||||
|
|
|
|||
|
|
@ -1,66 +1,76 @@
|
|||
# Django library.
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
||||
# Constants
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
__FIFTY_MEGABYTES = 50 * 1024 * 1024
|
||||
__VALID_CONTENT_TYPES = 'image/jpeg', 'image/png', 'image/svg+xml'
|
||||
FIFTY_MEGABYTES = 50 * 1024 * 1024
|
||||
VALID_CONTENT_TYPES = 'image/jpeg', 'image/png', 'image/svg+xml'
|
||||
NINETY_DPI = 90
|
||||
IM_WIDTH = 500
|
||||
IM_HEIGHT = 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _from_settings(var, default):
|
||||
def _mdx(var, default):
|
||||
"""
|
||||
Adds "MARXDOWX_" to the requested variable and retrieves its value
|
||||
from settings or returns the default.
|
||||
from settings or returns the default.
|
||||
|
||||
:param var: Variable to be retrieved.
|
||||
:type var: str
|
||||
:param default: Default value if the variable is not defined.
|
||||
:return: Value corresponding to 'var'.
|
||||
"""
|
||||
return getattr(settings, 'MARKDOWNX_' + var, default)
|
||||
try:
|
||||
return getattr(settings, 'MARKDOWNX_' + var, default)
|
||||
except ImproperlyConfigured:
|
||||
# To handle the auto-generation of documentations.
|
||||
return default
|
||||
|
||||
|
||||
# Markdownify
|
||||
# ------------
|
||||
MARKDOWNX_MARKDOWNIFY_FUNCTION = _from_settings('MARKDOWNIFY_FUNCTION', 'markdownx.utils.markdownify')
|
||||
# --------------------
|
||||
MARKDOWNX_MARKDOWNIFY_FUNCTION = _mdx('MARKDOWNIFY_FUNCTION', 'markdownx.utils.markdownify')
|
||||
|
||||
|
||||
# Markdown extensions
|
||||
# --------------------
|
||||
MARKDOWNX_MARKDOWN_EXTENSIONS = _from_settings('MARKDOWN_EXTENSIONS', list())
|
||||
MARKDOWNX_MARKDOWN_EXTENSIONS = _mdx('MARKDOWN_EXTENSIONS', list())
|
||||
|
||||
MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS = _from_settings('MARKDOWN_EXTENSION_CONFIGS', dict())
|
||||
MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS = _mdx('MARKDOWN_EXTENSION_CONFIGS', dict())
|
||||
|
||||
|
||||
# Markdown urls
|
||||
# --------------
|
||||
MARKDOWNX_URLS_PATH = _from_settings('URLS_PATH', '/markdownx/markdownify/')
|
||||
# --------------------
|
||||
MARKDOWNX_URLS_PATH = _mdx('URLS_PATH', '/markdownx/markdownify/')
|
||||
|
||||
MARKDOWNX_UPLOAD_URLS_PATH = _from_settings('UPLOAD_URLS_PATH', '/markdownx/upload/')
|
||||
MARKDOWNX_UPLOAD_URLS_PATH = _mdx('UPLOAD_URLS_PATH', '/markdownx/upload/')
|
||||
|
||||
|
||||
# Media path
|
||||
# -----------
|
||||
MARKDOWNX_MEDIA_PATH = _from_settings('MEDIA_PATH', 'markdownx/')
|
||||
# --------------------
|
||||
MARKDOWNX_MEDIA_PATH = _mdx('MEDIA_PATH', 'markdownx/')
|
||||
|
||||
|
||||
# Image
|
||||
# ------
|
||||
MARKDOWNX_UPLOAD_MAX_SIZE = _from_settings('UPLOAD_MAX_SIZE', __FIFTY_MEGABYTES)
|
||||
# --------------------
|
||||
MARKDOWNX_UPLOAD_MAX_SIZE = _mdx('UPLOAD_MAX_SIZE', FIFTY_MEGABYTES)
|
||||
|
||||
MARKDOWNX_UPLOAD_CONTENT_TYPES = _from_settings('UPLOAD_CONTENT_TYPES', __VALID_CONTENT_TYPES)
|
||||
MARKDOWNX_UPLOAD_CONTENT_TYPES = _mdx('UPLOAD_CONTENT_TYPES', VALID_CONTENT_TYPES)
|
||||
|
||||
MARKDOWNX_IMAGE_MAX_SIZE = _from_settings('IMAGE_MAX_SIZE', dict(size=(500, 500), quality=90))
|
||||
MARKDOWNX_IMAGE_MAX_SIZE = _mdx('IMAGE_MAX_SIZE', dict(size=(IM_WIDTH, IM_HEIGHT), quality=NINETY_DPI))
|
||||
|
||||
MARKDOWNX_SVG_JAVASCRIPT_PROTECTION = True
|
||||
|
||||
|
||||
# Editor
|
||||
# -------
|
||||
MARKDOWNX_EDITOR_RESIZABLE = _from_settings('EDITOR_RESIZABLE', True)
|
||||
# --------------------
|
||||
MARKDOWNX_EDITOR_RESIZABLE = _mdx('EDITOR_RESIZABLE', True)
|
||||
|
||||
|
||||
# ------------------------------------------------
|
||||
|
|
@ -68,11 +78,15 @@ MARKDOWNX_EDITOR_RESIZABLE = _from_settings('EDITOR_RESIZABLE', True)
|
|||
# ------------------------------------------------
|
||||
# This is not called using `_from_settings` as
|
||||
# it does not need "_MARKDOWNX" prefix.
|
||||
LANGUAGES = getattr(
|
||||
settings,
|
||||
'LANGUAGES',
|
||||
(
|
||||
('en', _('English')),
|
||||
('pl', _('Polish')),
|
||||
try:
|
||||
LANGUAGES = getattr(
|
||||
settings,
|
||||
'LANGUAGES',
|
||||
(
|
||||
('en', _('English')),
|
||||
('pl', _('Polish')),
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImproperlyConfigured:
|
||||
# To handle the auto-generation of documentations.
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import markdown
|
||||
from markdown import markdown
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
|
@ -6,13 +6,39 @@ from .settings import MARKDOWNX_MARKDOWN_EXTENSIONS, MARKDOWNX_MARKDOWN_EXTENSIO
|
|||
|
||||
|
||||
def markdownify(content):
|
||||
return markdown.markdown(
|
||||
content,
|
||||
extensions=MARKDOWNX_MARKDOWN_EXTENSIONS,
|
||||
extension_configs=MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS
|
||||
"""
|
||||
|
||||
:param content:
|
||||
:type content:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
md = markdown(
|
||||
text=content,
|
||||
extensions=MARKDOWNX_MARKDOWN_EXTENSIONS,
|
||||
extension_configs=MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS
|
||||
)
|
||||
return md
|
||||
|
||||
|
||||
def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
|
||||
"""
|
||||
|
||||
:param image:
|
||||
:type image:
|
||||
:param size:
|
||||
:type size:
|
||||
:param crop:
|
||||
:type crop:
|
||||
:param upscale:
|
||||
:type upscale:
|
||||
:param quality:
|
||||
:type quality:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
# ToDo: Possible IO/Runtime exceptions need to handled, and `finally` the file needs to be closed.
|
||||
|
||||
# Open image and store format/metadata.
|
||||
image.open()
|
||||
im = Image.open(image)
|
||||
|
|
@ -23,8 +49,8 @@ def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
|
|||
# Force PIL to load image data.
|
||||
im.load()
|
||||
|
||||
source_x, source_y = [float(v) for v in im.size]
|
||||
target_x, target_y = [float(v) for v in size]
|
||||
source_x, source_y = map(float, im.size)
|
||||
target_x, target_y = map(float, size)
|
||||
|
||||
if crop or not target_x or not target_y:
|
||||
scale = max(target_x / source_x, target_y / source_y)
|
||||
|
|
@ -38,7 +64,10 @@ def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
|
|||
target_y = source_y * scale
|
||||
|
||||
if scale < 1.0 or (scale > 1.0 and upscale):
|
||||
im = im.resize((int(source_x * scale), int(source_y * scale)), resample=Image.ANTIALIAS)
|
||||
im = im.resize(
|
||||
(int(source_x * scale), int(source_y * scale)),
|
||||
resample=Image.ANTIALIAS
|
||||
)
|
||||
|
||||
if crop:
|
||||
# Use integer values now.
|
||||
|
|
@ -46,12 +75,17 @@ def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
|
|||
# Difference between new image size and requested size.
|
||||
diff_x = int(source_x - min(source_x, target_x))
|
||||
diff_y = int(source_y - min(source_y, target_y))
|
||||
|
||||
if diff_x or diff_y:
|
||||
# Center cropping (default).
|
||||
halfdiff_x, halfdiff_y = diff_x // 2, diff_y // 2
|
||||
box = [halfdiff_x, halfdiff_y,
|
||||
min(source_x, int(target_x) + halfdiff_x),
|
||||
min(source_y, int(target_y) + halfdiff_y)]
|
||||
box = [
|
||||
halfdiff_x,
|
||||
halfdiff_y,
|
||||
min(source_x, int(target_x) + halfdiff_x),
|
||||
min(source_y, int(target_y) + halfdiff_y)
|
||||
]
|
||||
|
||||
# Finally, crop the image!
|
||||
im = im.crop(box)
|
||||
|
||||
|
|
@ -59,3 +93,50 @@ def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
|
|||
im.format, im.info = im_format, im_info
|
||||
image.close()
|
||||
return im
|
||||
|
||||
|
||||
def has_javascript(data):
|
||||
"""
|
||||
|
||||
:param data: Contents to be monitored for JavaScript injection.
|
||||
:type data: str
|
||||
:return: ``True`` if **data** contains JavaScript tag(s), otherwise ``False``.
|
||||
:rtype: bool
|
||||
"""
|
||||
from re import search, IGNORECASE, MULTILINE
|
||||
from xml.etree.ElementTree import fromstring
|
||||
|
||||
# ------------------------------------------------
|
||||
# Handles JavaScript nodes and stringified nodes.
|
||||
# ------------------------------------------------
|
||||
pattern = (
|
||||
r'(<\s*\bscript\b.*>.*)|'
|
||||
r'(.*\bif\b\s*\(.?={2,3}.*\))|'
|
||||
r'(.*\bfor\b\s*\(.*\))'
|
||||
)
|
||||
|
||||
found = search(
|
||||
pattern=pattern,
|
||||
string=data,
|
||||
flags=IGNORECASE | MULTILINE
|
||||
)
|
||||
|
||||
if found is not None:
|
||||
return True
|
||||
|
||||
# ------------------------------------------------
|
||||
# Handles JavaScript injection into attributes
|
||||
# for element creation.
|
||||
# ------------------------------------------------
|
||||
parsed_xml = (
|
||||
(attribute, value)
|
||||
for elm in fromstring(data).iter()
|
||||
for attribute, value in elm.attrib.items()
|
||||
)
|
||||
|
||||
for key, val in parsed_xml:
|
||||
if '"' in val or "'" in val:
|
||||
return True
|
||||
|
||||
# It is (hopefully) safe.
|
||||
return False
|
||||
|
|
|
|||
Loading…
Reference in a new issue