From: Charlie DeTar Date: Sat, 14 Feb 2015 23:27:17 +0000 (-0700) Subject: Updates to sanitizer; all-in with inline HTML X-Git-Tag: release-20150325~22 X-Git-Url: https://git.librecmc.org/?a=commitdiff_plain;h=e0cbe924dc84bcfe861797b884323eaf92e57465;p=oweals%2Fkarmaworld.git Updates to sanitizer; all-in with inline HTML - Remove iframe and "static hosting" as a strategy for showing a note. Only show inline HTML. - Add a "format preserving" sanitizer that does XSS filtering and prep for inline HTML, but doesn't remove visual markup. - Remove javascript pertinent to PDF viewing. Handle zoom buttons using CSS transforms on the HTML container. - Add notion of "editability" for Notes. Notes will save with an "editable" sanitizer that strips to markdown-caliber HTML which the client side WYSIWYG can handle if the note is an editable type, and renders with a "format preserving" sanitizer that uses complex ugly junk as the HTML if it's not meant to be edited. - Add and improve tests for this stuff. --- diff --git a/karmaworld/apps/notes/forms.py b/karmaworld/apps/notes/forms.py index 4bf660b..a6a92f6 100644 --- a/karmaworld/apps/notes/forms.py +++ b/karmaworld/apps/notes/forms.py @@ -15,13 +15,12 @@ class NoteForm(ModelForm): def save(self, *args, **kwargs): # TODO: use transaction.atomic for this when we switch to Django 1.6+ - print self.cleaned_data instance = super(NoteForm, self).save(*args, **kwargs) instance.tags.set(*self.cleaned_data['tags']) if instance.is_hidden: instance.is_hidden = False instance.save() - if self.cleaned_data.get('html'): + if instance.is_editable() and self.cleaned_data.get('html'): try: note_markdown = instance.notemarkdown except NoteMarkdown.DoesNotExist: diff --git a/karmaworld/apps/notes/migrations/0020_markdown_to_html.py b/karmaworld/apps/notes/migrations/0020_markdown_to_html.py index 4efa2ac..0db844f 100644 --- a/karmaworld/apps/notes/migrations/0020_markdown_to_html.py +++ b/karmaworld/apps/notes/migrations/0020_markdown_to_html.py @@ -5,7 +5,7 @@ from south.v2 import DataMigration from django.db import models import markdown from notes.models import NoteMarkdown -from notes.sanitizer import sanitize_html +from notes import sanitizer class Migration(DataMigration): @@ -15,7 +15,7 @@ class Migration(DataMigration): # Use orm.ModelName to refer to models in this application, # and orm['appname.ModelName'] for models in other applications. for notemarkdown in orm['notes.NoteMarkdown'].objects.exclude(markdown=""): - notemarkdown.html = sanitize_html(markdown.markdown(notemarkdown.markdown)) + notemarkdown.html = sanitizer.sanitize_html_to_editable(markdown.markdown(notemarkdown.markdown)) notemarkdown.save() def backwards(self, orm): diff --git a/karmaworld/apps/notes/models.py b/karmaworld/apps/notes/models.py index 68db5bc..9bb0a1c 100644 --- a/karmaworld/apps/notes/models.py +++ b/karmaworld/apps/notes/models.py @@ -34,7 +34,7 @@ import django_filepicker from taggit.managers import TaggableManager import markdown -from karmaworld.apps.notes.sanitizer import sanitize_html +from karmaworld.apps.notes import sanitizer from karmaworld.apps.courses.models import Course from karmaworld.apps.licenses.models import License from karmaworld.apps.notes.search import SearchIndex @@ -80,6 +80,8 @@ class Document(models.Model): (ASSIGNMENT, 'Assignment'), (OTHER, 'Other'), ) + EDITABLE_CATEGORIES = (LECTURE_NOTES,) + category = models.CharField(max_length=50, choices=NOTE_CATEGORIES, blank=True, null=True) # license if different from default @@ -360,6 +362,9 @@ class Note(Document): def is_pdf(self): return self.mimetype in Note.PDF_MIMETYPES + def is_editable(self): + return self.category in Note.EDITABLE_CATEGORIES + class NoteMarkdown(models.Model): note = models.OneToOneField(Note, primary_key=True) @@ -369,7 +374,11 @@ class NoteMarkdown(models.Model): def save(self, *args, **kwargs): if self.markdown and not self.html: self.html = markdown.markdown(self.markdown) - self.html = sanitize_html(self.html) + if self.note.is_editable(): + self.html = sanitizer.sanitize_html_to_editable(self.html) + else: + self.html = sanitizer.sanitize_html_preserve_formatting(self.html) + super(NoteMarkdown, self).save(*args, **kwargs) auto_add_check_unique_together(Note) diff --git a/karmaworld/apps/notes/sanitizer.py b/karmaworld/apps/notes/sanitizer.py index 162d256..abc9fc3 100644 --- a/karmaworld/apps/notes/sanitizer.py +++ b/karmaworld/apps/notes/sanitizer.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + import re import bleach import html5lib @@ -18,7 +20,7 @@ def _canonical_link_predicate(tag): tag.has_attr('rel') and \ u'canonical' in tag['rel'] -class Sanitizer(BleachSanitizer): +class SuppressingSanitizer(BleachSanitizer): """ The default bleach clean method uses a sanitizer that handles disallowed tags either by escaping them. With the bad HTML @@ -35,57 +37,155 @@ class Sanitizer(BleachSanitizer): But we want to strip both the tag and contents for certain tags like script and style. This subclass does that. - - Also support data URI's for some mimetypes (image/png, image/gif, image/jpeg) """ - allowed_elements = bleach_whitelist.markdown_tags - allowed_attributes = bleach_whitelist.markdown_attrs suppressed_elements = ["script", "style"] - strip_disallowed_elements = True - strip_html_comments = True def __init__(self, *args, **kwargs): self.suppressing = None - super(Sanitizer, self).__init__(*args, **kwargs) + super(SuppressingSanitizer, self).__init__(*args, **kwargs) def sanitize_token(self, token): # Do sanitization on the parent. - result = super(Sanitizer, self).sanitize_token(token) + result = super(SuppressingSanitizer, self).sanitize_token(token) + if "name" not in token: + if self.suppressing: + return None + return result + + tag_name = token['name'] # Suppress elements like script and style entirely. - if token.get('name') and token['name'] in self.suppressed_elements: + if tag_name in self.suppressed_elements: if token['type'] == tokenTypes['StartTag']: - self.suppressing = token['name'] - elif token['type'] == tokenTypes['EndTag'] and token['name'] == self.suppressing: + self.suppressing = tag_name + elif token['type'] == tokenTypes['EndTag'] and tag_name == self.suppressing: self.suppressing = False + + elif tag_name in self.required_attributes: + if result and result.get('type') == tokenTypes['StartTag']: + attr_dict = dict(result.get('data', []) or []) + for attr, val in self.required_attributes[tag_name].iteritems(): + attr_dict[attr] = val + result['data'] = attr_dict.items() + if self.suppressing: - return {u'data': '', 'type': 2} + return None else: return result +class EditableSanitizer(SuppressingSanitizer): + """ + Sanitizer tokenizer optimized for producing HTML suitable for editing in + client-side WYSIWYG's. + """ + allowed_elements = bleach_whitelist.markdown_tags + allowed_attributes = { + "img": ["src", "width", "height"], + "a": ["href", "target", "rel"], + } + required_attributes = { + "a": { "rel": "nofollow", "target": "_blank" } + } + suppressed_elements = ["script", "style"] + strip_disallowed_elements = True + strip_html_comments = True + +class PreserveFormattingSanitizer(SuppressingSanitizer): + """ + Lax sanitizer tokenizer optimized for preserving the formatting in the + incoming HTML, while still removing XSS threats. + """ + allowed_elements = bleach_whitelist.generally_xss_safe + allowed_attributes = { + "*": ["class", "style"], + "img": ["src", "width", "height"], + "a": ["href", "target", "rel"], + } + required_attributes = { + "a": { "rel": "nofollow", "target": "_blank" } + } + allowed_css_properties = bleach_whitelist.all_styles + suppressed_elements = ["script"] + strip_disallowed_elements = True + strip_html_comments = True + class DataUriReplacer(HTMLTokenizer, HTMLSanitizerMixin): """ Convert any valid image data URI's to files, and upload them to s3. Replace the data URI with a link to the file in s3. """ - VALID_URI = "^data:image/(png|gif|jpeg);base64,[A-Za-z0-9+/=]+$" + VALID_IMAGE_URI = "^data:image/(png|gif|jpeg);base64,[A-Za-z0-9+/=]+$" + VALID_FONT_FACE_FORMATS = ["woff"] + + def __init__(self, *args, **kwargs): + self.in_style = False + self.style_content = [] + self.font_face_cache = {} + super(DataUriReplacer, self).__init__(*args, **kwargs) def sanitize_token(self, token): - if token.get('name') == u"img": + # Handle images + tag_name = token.get("name") + if tag_name == u"img": attrs = dict([(name, val) for name, val in token['data'][::-1]]) if 'src' in attrs: src = attrs['src'] - if re.match(self.VALID_URI, src): + if re.match(self.VALID_IMAGE_URI, src): url = self._upload_image(src) attrs['src'] = url token['data'] = [(k,v) for k,v in attrs.iteritems()] + + # Handle font-faces. + if self.in_style: + if 'data' in token and token['type'] == 1: + token['data'] = self._extract_and_upload_data_uri_fonts(token['data']) + + if tag_name == u"style": + if token['type'] == tokenTypes['StartTag']: + self.in_style = True + elif token['type'] == tokenTypes['EndTag']: + self.in_style = False + return token - def _upload_image(self, data_uri): - from django.core.files.storage import default_storage - from karmaworld.apps.notes.models import all_read_xml_acl - from django.conf import settings + def _extract_and_upload_data_uri_fonts(self, raw_css): + # Might be better to use a full-fledged CSS parser, but it has to be + # modern enough to support font-faces and data-uri's. + pattern = r"""(?P + @font-face\s*{ # font-face opening + (?:[^\}]+;)? # any parts before the src + \s*src\s*:\s* # src declaration + url\(['"])?data: # url opener + (?Papplication/font-(?P%s)) # mimetype + ;base64, + (?P[A-Za-z0-9+/=]+) # data-uri itself + (?P['"]?\)) # url closer + """ % "|".join(self.VALID_FONT_FACE_FORMATS) + return re.sub(pattern, + repl=self._upload_font_match, + string=raw_css, + flags=re.VERBOSE | re.DOTALL) + + def _upload_font_match(self, match): + url = "" + if match: + ext = match.group('ext') + mimetype = match.group('mimetype') + data_uri = match.group('data_uri') + if ext in self.VALID_FONT_FACE_FORMATS: + filepath = "fonts/{}.{}".format(uuid.uuid4(), ext) + sio = StringIO() + sio.write(base64.b64decode(data_uri)) + sio.seek(0) + value = sio.getvalue() + if value in self.font_face_cache: + url = self.font_face_cache[value] + else: + url = self._s3_upload(filepath, mimetype, sio.getvalue()) + self.font_face_cache[value] = url + return u"".join((match.group('intro'), url, match.group('outro'))) + def _upload_image(self, data_uri): mimetype, data = data_uri.split(";base64,") sio = StringIO() sio.write(base64.b64decode(data)) @@ -101,35 +201,47 @@ class DataUriReplacer(HTMLTokenizer, HTMLSanitizerMixin): image.save(image_data, format=fmt) filepath = "images/{}.{}".format(uuid.uuid4(), fmt) + return self._s3_upload(filepath, mimetype, image_data.getvalue()) + + def _s3_upload(self, filepath, mimetype, data): + from django.core.files.storage import default_storage + from karmaworld.apps.notes.models import all_read_xml_acl + from django.conf import settings + new_key = default_storage.bucket.new_key(filepath) - new_key.set_contents_from_string(image_data.getvalue(), {"Content-Type": mimetype}) + new_key.set_contents_from_string(data, {"Content-Type": mimetype}) new_key.set_xml_acl(all_read_xml_acl) parts = [settings.S3_URL, filepath] if parts[0].startswith("//"): - # Fully resolve the URL as https for happiness in all things. + # Fully resolve the URL as https for happiness in all things. ॐ parts.insert(0, "https:") return "".join(parts) + def __iter__(self): for token in HTMLTokenizer.__iter__(self): token = self.sanitize_token(token) if token: yield token -def sanitize_html(raw_html): +def _sanitize_html(raw_html, tokenizer): + parser = html5lib.HTMLParser(tokenizer=tokenizer) + clean = _render(parser.parseFragment(raw_html)) + return clean + +def sanitize_html_to_editable(raw_html): """ - Sanitize the given raw_html. + Sanitize the given raw_html, with the result in a format suitable for + editing in client-side HTML WYSIWYG editors. """ - # Strip tags to the few that we like - parser = html5lib.HTMLParser(tokenizer=Sanitizer) - clean = _render(parser.parseFragment(raw_html)) + return _sanitize_html(raw_html, EditableSanitizer) - # Set anchor tags' targets - clean = bleach.linkify(clean, callbacks=[ - bleach.callbacks.nofollow, - bleach.callbacks.target_blank - ], tokenizer=Sanitizer) - return clean +def sanitize_html_preserve_formatting(raw_html): + """ + Sanitize the given HTML, preserving as much of the original formatting as + possible. + """ + return _sanitize_html(raw_html, PreserveFormattingSanitizer) def data_uris_to_s3(raw_html): parser = html5lib.HTMLParser(tokenizer=DataUriReplacer) diff --git a/karmaworld/apps/notes/tests.py b/karmaworld/apps/notes/tests.py index e452420..7a52d15 100644 --- a/karmaworld/apps/notes/tests.py +++ b/karmaworld/apps/notes/tests.py @@ -36,6 +36,7 @@ class TestNotes(TestCase): self.note = Note() self.note.course = self.course self.note.name = u"Lecture notes concerning the use of therefore ∴" + self.note.category = Note.LECTURE_NOTES self.note.uploaded_at = self.now self.note.text = "This is the plaintext version of a note. It's pretty cool. Alpaca." self.note.save() @@ -97,11 +98,11 @@ class TestNotes(TestCase):

OK

& \u201d - This stuff + This stuff That guy """) -class TestSanitizer(TestCase): +class TestSanitizeToEditable(TestCase): def test_clean(self): dirty = """ @@ -117,12 +118,12 @@ class TestSanitizer(TestCase): """ - self.assertHTMLEqual(sanitizer.sanitize_html(dirty), u""" + self.assertHTMLEqual(sanitizer.sanitize_html_to_editable(dirty), u"""

Something

OK

& \u201d - This stuff + This stuff That guy

This should show up

""") @@ -135,18 +136,34 @@ class TestSanitizer(TestCase): def test_data_uri(self): # Strip out all data URIs. html = '' - self.assertHTMLEqual(sanitizer.sanitize_html(html), "") + self.assertHTMLEqual(sanitizer.sanitize_html_to_editable(html), "") # Strip out non-image data URI's html = '' - self.assertHTMLEqual(sanitizer.sanitize_html(html), "") + self.assertHTMLEqual(sanitizer.sanitize_html_to_editable(html), "") class TestDataUriToS3(TestCase): - def test_data_uri(self): + def test_image_data_uri(self): html = '' s3ified = sanitizer.data_uris_to_s3(html) soup = BeautifulSoup(s3ified) - print s3ified regex = r'^https?://.*$' self.assertTrue(bool(re.match(regex, soup.img['src'])), "{} does not match {}".format(s3ified, regex)) + + resanitize = sanitizer.data_uris_to_s3(s3ified) + self.assertHTMLEqual(s3ified, resanitize) + + def test_font_face_data_uri(self): + # Note: this data-uri is not a valid font (it's the red dot). + html = '''''' + + s3ified = sanitizer.data_uris_to_s3(html) + self.assertFalse(re.search(r"url\('data:application", s3ified), + "data URL not removed: {}".format(s3ified)) + self.assertTrue(re.search(r"url\('https?://[^\)]+\)", s3ified), + "URL not inserted: {}".format(s3ified)) + + # Ensure that cleaning is idempotent. + self.assertHTMLEqual(s3ified, + sanitizer.sanitize_html_preserve_formatting(s3ified)) diff --git a/karmaworld/assets/js/note-detail.js b/karmaworld/assets/js/note-detail.js index d2e1413..0ea09a2 100644 --- a/karmaworld/assets/js/note-detail.js +++ b/karmaworld/assets/js/note-detail.js @@ -1,96 +1,3 @@ - -function rescalePdf(viewer) { - var scaleBase = 750; - var outlineWidth = 250; - var frameWidth = parseInt($('#note_container')[0].clientWidth); - var pdfWidth = frameWidth; - - if ($(viewer.sidebar).hasClass('opened')){ - pdfWidth = pdfWidth - 250; - } - - var newPdfScale = pdfWidth / scaleBase; - viewer.rescale(newPdfScale); -} - -function setupPdfViewer(noteframe, pdfViewer) { - - $('#plus-btn').click(function (){ - pdfViewer.rescale(1.20, true, [0,0]); - }); - - $('#minus-btn').click(function (){ - pdfViewer.rescale(0.80, true, [0,0]); - }); - - // detect if the PDF viewer wants to show an outline - // at all - if ($(pdfViewer.sidebar).hasClass('opened')) { - var body = $(document.body); - // if the screen is less than 64em wide, hide the outline - if (parseInt($(body.width()).toEm({scope: body})) < 64) { - $(pdfViewer.sidebar).removeClass('opened'); - } - } - - $('#outline-btn').click(function() { - $(pdfViewer.sidebar).toggleClass('opened'); - // rescale the PDF to fit the available space - rescalePdf(pdfViewer); - }); - - $('#scroll-to').change(function() { - page = parseInt($(this).val()); - pdfViewer.scroll_to(page, [0,0]); - }); - - // rescale the PDF to fit the available space - rescalePdf(pdfViewer); -} - -function writeNoteFrame(contents) { - var dstFrame = document.getElementById('noteframe'); - if (dstFrame) { - var dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document; - dstDoc.write(contents); - dstDoc.close(); - } -} - -function setupAnnotator(noteElement, readOnly) { - noteElement.annotator({readOnly: readOnly}); - noteElement.annotator('addPlugin', 'Store', { - prefix: '/ajax/annotations', - loadFromSearch: { - 'uri': note_id - }, - annotationData: { - 'uri': note_id - } - }); -} - -function injectRemoteScript(url, noteframe, onload) { - var injectScript = noteframe.document.createElement("script"); - injectScript.src = url; - injectScript.onload = onload; - noteframe.document.head.appendChild(injectScript); -} - -function injectScript(scriptText, noteframe) { - var injectScript = noteframe.document.createElement("script"); - injectScript.innerHTML = scriptText; - noteframe.document.body.appendChild(injectScript); -} - -function injectRemoteCSS(url, noteframe) { - var injectCSS = noteframe.document.createElement("link"); - injectCSS.href = url; - injectCSS.type = 'text/css'; - injectCSS.rel = 'stylesheet'; - noteframe.document.head.appendChild(injectCSS); -} - function tabHandler(event) { // check for: // key pressed was TAB @@ -133,6 +40,32 @@ function addForm(event) { keywordInput.focus(); } +/** + * Scale the html given by the selector by the factor. + * @param {string|jQuery|dom} container - Selector to scale + * @param {Number} factorDelta - Amount to change the current scaling factor by + * (e.g. 1.1, 0.9, 2.0). + */ +function scaleHTML(container, factorDelta) { + var el = $(container); + var currentFactor = parseFloat(el.attr("data-scale-factor") || 1); + var factor = currentFactor * factorDelta; + + var parent = el.parent() + var origHeight = parent.height() / currentFactor; + var destHeight = origHeight * factor; + // Set the new parent height + parent.height(destHeight); + + el.attr("data-scale-factor", factor); + var matrix = "matrix(" + factor + ", 0, 0, " + factor + ", " + + "0," + ((destHeight - origHeight) * 0.5) + ")"; + el.css("-webkit-transform", matrix); + el.css("-moz-transform", matrix); + el.css("-ms-transform", matrix); + el.css("transform", matrix); +} + function initNoteContentPage() { $("#thank-button").click(function(event) { @@ -216,69 +149,16 @@ function initNoteContentPage() { } }); - // Embed the converted markdown if it is on the page, else default to the iframe - if ($('#note-markdown').length > 0) { - var note_markdown = $('#note-markdown'); - note_markdown.html(marked(note_markdown.data('markdown'))); - setupAnnotator(note_markdown, !user_authenticated); - } else { - $.ajax(note_contents_url, { - type: 'GET', - xhrFields: { - onprogress: function (progress) { - var percentage = Math.floor((progress.loaded / progress.total) * 100); - writeNoteFrame("

" + percentage + "%

"); - } - }, - success: function(data, textStatus, jqXHR) { - writeNoteFrame(data); - - // run setupAnnotator in frame context - var parentFrame = document.getElementById('noteframe'); - if (!parentFrame) { - return; - } - var noteframe = parentFrame.contentWindow; - - injectRemoteCSS(annotator_css_url, noteframe); - injectScript("csrf_token = '" + csrf_token + "';", noteframe); - - injectRemoteScript("https://code.jquery.com/jquery-2.1.0.min.js", noteframe, - function() { - injectRemoteScript(setup_ajax_url, noteframe); - injectRemoteScript(annotator_js_url, noteframe, - function() { - var js = "$(function() { \ - var document_selector = $('body'); \ - if ($('#page-container').length > 0) { \ - document_selector = $('#page-container'); \ - } \ - document_selector.annotator({readOnly: " + !user_authenticated + "}); \ - document_selector.annotator('addPlugin', 'Store', { \ - prefix: '/ajax/annotations', \ - loadFromSearch: { \ - 'uri': " + note_id + " \ - }, \ - annotationData: { \ - 'uri': " + note_id + " \ - } \ - }); })"; - injectScript(js, noteframe); - - if (pdfControls == true) { - var pdfViewer = noteframe.pdf2htmlEX.defaultViewer; - $(noteframe.document).ready(function() { - setupPdfViewer(noteframe, pdfViewer); - }); - } - }); - }); - }, - error: function(data, textStatus, jqXHR) { - writeNoteFrame("

Sorry, your note could not be retrieved.

"); - } - }); - } + $("#note-html").annotator({ + readOnly: false + }).annotator('addPlugin', 'Store', { + prefix: '/ajax/annotations', + loadFromSearch: { 'uri': note_id }, + annotationData: { 'uri': note_id } + }); + + $("#plus-btn").click(function() { scaleHTML("#note-html", 1.1); }); + $("#minus-btn").click(function() { scaleHTML("#note-html", 1 / 1.1); }); } function initNoteKeywordsPage() { diff --git a/karmaworld/templates/notes/note_base.html b/karmaworld/templates/notes/note_base.html index 766c44d..d700d50 100644 --- a/karmaworld/templates/notes/note_base.html +++ b/karmaworld/templates/notes/note_base.html @@ -57,31 +57,33 @@ }); {% endif %} - + // This event will be renamed "open.fndtn.reveal" in future foundation: + // http://foundation.zurb.com/docs/components/reveal.html#event-bindings + $(document).on("open", "#note-edit-dialog", function(event) { + var scope = $(event.currentTarget); + // Remove iframe. + $(".wysihtml5-sandbox", scope).remove(); + // Unbind toolbar events by cloning it. + var toolbar = $(".wysihtml5-toolbar", scope); + var toolbarClone = toolbar.clone(); + toolbar.replaceWith(toolbarClone); + // Unbind textarea events by cloning it. + var textarea = $("[role='wysihtml5-rich-text']", scope); + var textareaClone = textarea.clone(); + var val = textarea.val(); // textarea value isn't copied with clone. + textarea.replaceWith(textareaClone); + textareaClone.val(val); + textareaClone.show(); + // Reinitialize + initWysihtml5(textareaClone[0]); + }); + + {% endif %} {% endblock %} {% block raw_content %} @@ -259,14 +261,16 @@

{{ field.help_text }}

{% endwith %} -
- {% with note_edit_form.html as field %} - {{ field.errors|safe }} - - {{ field }} -

{{ field.help_text }}

- {% endwith %} -
+ {% if note.is_editable %} +
+ {% with note_edit_form.html as field %} + {{ field.errors|safe }} + + {{ field }} +

{{ field.help_text }}

+ {% endwith %} +
+ {% endif %}
diff --git a/karmaworld/templates/notes/note_detail.html b/karmaworld/templates/notes/note_detail.html index fb8ca9f..3facd16 100644 --- a/karmaworld/templates/notes/note_detail.html +++ b/karmaworld/templates/notes/note_detail.html @@ -1,13 +1,6 @@
{% if pdf_controls %}
-
- -
-
- Jump to page: - -
@@ -19,15 +12,9 @@
{% if note.has_markdown %} -
+
{{ note.notemarkdown.html|safe }}
- {% else %} - - {% endif %}
diff --git a/requirements.txt b/requirements.txt index f758b14..88000ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,5 +37,5 @@ amqplib==1.0.2 django-sslify>=0.2 newrelic==2.40.0.34 bleach==1.4 -bleach-whitelist==0.0.4 +bleach-whitelist==0.0.7 Markdown==2.5.2