- 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.
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:
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):
# 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):
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
(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
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)
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)
+# -*- coding: utf-8 -*-
+
import re
import bleach
import html5lib
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
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<intro>
+ @font-face\s*{ # font-face opening
+ (?:[^\}]+;)? # any parts before the src
+ \s*src\s*:\s* # src declaration
+ url\(['"])?data: # url opener
+ (?P<mimetype>application/font-(?P<ext>%s)) # mimetype
+ ;base64,
+ (?P[A-Za-z0-9+/=]+) # data-uri itself
+ (?P<outro>['"]?\)) # 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))
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)
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()
<h2>OK</h2>
&
\u201d
- <a>This stuff</a>
+ <a target='_blank' rel='nofollow'>This stuff</a>
<a href="http://google.com" target="_blank" rel="nofollow">That guy</a>
""")
-class TestSanitizer(TestCase):
+class TestSanitizeToEditable(TestCase):
def test_clean(self):
dirty = """
<script>unsafe</script>
</section>
"""
- self.assertHTMLEqual(sanitizer.sanitize_html(dirty), u"""
+ self.assertHTMLEqual(sanitizer.sanitize_html_to_editable(dirty), u"""
<h1>Something</h1>
<h2>OK</h2>
&
\u201d
- <a>This stuff</a>
+ <a target="_blank" rel="nofollow">This stuff</a>
<a href="http://google.com" target="_blank" rel="nofollow">That guy</a>
<h3>This should show up</h3>
""")
def test_data_uri(self):
# Strip out all data URIs.
html = '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==">'
- self.assertHTMLEqual(sanitizer.sanitize_html(html), "<img/>")
+ self.assertHTMLEqual(sanitizer.sanitize_html_to_editable(html), "<img/>")
# Strip out non-image data URI's
html = '<img src="data:application/pdf;base64,blergh">'
- self.assertHTMLEqual(sanitizer.sanitize_html(html), "<img/>")
+ self.assertHTMLEqual(sanitizer.sanitize_html_to_editable(html), "<img/>")
class TestDataUriToS3(TestCase):
- def test_data_uri(self):
+ def test_image_data_uri(self):
html = '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==">'
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 = '''<style>@font-face { src: url('data:application/font-woff;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='); }</style>'''
+
+ 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))
-
-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
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) {
}
});
- // 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("<h3 style='text-align: center'>" + percentage + "%</h3>");
- }
- },
- 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("<h3 style='text-align: center'>Sorry, your note could not be retrieved.</h3>");
- }
- });
- }
+ $("#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() {
});
{% endif %}
</script>
- <script type="text/javascript">
- // wysihtml5 doesn't init correctly in a hidden div. So we remove it every
- // time before showing it in the modal.
+ {% if note.is_editable %}
+ <script type="text/javascript">
+ // wysihtml5 doesn't init correctly in a hidden div. So we remove it every
+ // time before showing it in the modal.
- // 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]);
- });
- </script>
+ // 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]);
+ });
+ </script>
+ {% endif %}
{% endblock %}
{% block raw_content %}
<p>{{ field.help_text }}</p>
{% endwith %}
</div>
- <div class="small-12 columns">
- {% with note_edit_form.html as field %}
- {{ field.errors|safe }}
- <label for="{{ field.id_for_label }}">{{ field.label }}:</label>
- {{ field }}
- <p>{{ field.help_text }}</p>
- {% endwith %}
- </div>
+ {% if note.is_editable %}
+ <div class="small-12 columns">
+ {% with note_edit_form.html as field %}
+ {{ field.errors|safe }}
+ <label for="{{ field.id_for_label }}">{{ field.label }}:</label>
+ {{ field }}
+ <p>{{ field.help_text }}</p>
+ {% endwith %}
+ </div>
+ {% endif %}
<div class="small-12 columns text-center">
<button type="submit"><i class="fa fa-save"></i> Save</button>
</div>
<div id="note_container" class="">
{% if pdf_controls %}
<div id="zoom-buttons" class="row show-for-medium-up">
- <div id="outline-btn-wrapper" class="small-1 columns hide show-for-medium-up">
- <i id="outline-btn" class="zoom-button fa fa-bars fa-2x"></i>
- </div>
- <div class="small-4 columns">
- <span>Jump to page:
- <input id="scroll-to" type="text" style="width: 3em; display: inline" /></span>
- </div>
<div class="small-2 small-centered columns center">
<i id="minus-btn" class="zoom-button fa fa-search-minus fa-2x"></i>
<i id="plus-btn" class="zoom-button fa fa-search-plus fa-2x"></i>
<div class="small-12 small-centered columns medium-12 large-12 body_copy">
<div id="note-content-wrapper" class="note-text">
{% if note.has_markdown %}
- <div class='note-html'>
+ <div class='note-html' id='note-html'>
{{ note.notemarkdown.html|safe }}
</div>
- {% else %}
- <iframe style="border:none; width:100%; min-height: 1000px;"
- id="noteframe"></iframe>
- <noscript>
- {{ note.text }}
- </noscript>
{% endif %}
</div> <!-- .note-text -->
</div><!-- /body_copy -->
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