Re-do markdown handling for better user experience and security
authorMagnus Hagander <magnus@hagander.net>
Sun, 8 Nov 2020 16:03:04 +0000 (17:03 +0100)
committerMagnus Hagander <magnus@hagander.net>
Thu, 12 Nov 2020 17:52:04 +0000 (18:52 +0100)
* Get rid of the django_markwhat dependency, and implement our own
  classes to get more control. In passing also remove django-markdown,
  because we never used that.
* Instead of trying to clean markdown with regexps, use the bleach
  library (NEW DEPENDENCY) with special whitelisting of allowed tags
  based off standard markdown. This means that one can input links or
  formatting in HTML if one prefers, as long as it renders to the same
  subset of tags that markdown allows.
* Replace javascript based client side preview with an actual call to a
  preview URL that renders the exact result using the same function,
  since the use of showdown on the client was increasingly starting to
  differ from the server, and since that cannot be kept secure the same
  way. Rewrite the client side javascript to work better with the now
  longer interval between updates of the preview.

Long in planning, but never got around to it.

Suggestion to use bleach for escaping from David Fetter.

25 files changed:
media/css/markdown_preview.css [moved from media/css/showdown_preview.css with 100% similarity]
media/js/admin_pgweb.js
media/js/forms.js
media/js/markdown_preview.js [new file with mode: 0644]
media/js/showdown_preview.js [deleted file]
pgweb/account/urls.py
pgweb/account/views.py
pgweb/core/templatetags/pgmarkdown.py [new file with mode: 0644]
pgweb/settings.py
pgweb/util/helpers.py
pgweb/util/markup.py [new file with mode: 0644]
pgweb/util/moderation.py
requirements.txt
templates/admin/change_form_pgweb.html
templates/base/form.html
templates/downloads/productlist.html
templates/events/archive.html
templates/events/item.html
templates/events/rss_description.html
templates/featurematrix/featuredetail.html
templates/news/item.html
templates/news/mail/default.html
templates/news/mail/pgproject.html
templates/news/newsarchive.html
templates/news/rss_description.html

index ca9db30cafee45445a5422085991e753618d5fa8..da823d2e863c6ad69c470e2c0977dbbba077144f 100644 (file)
@@ -3,7 +3,7 @@ window.onload = function() {
     let tael = document.getElementsByTagName('textarea');
     for (let i = 0; i < tael.length; i++) {
         if (tael[i].className.indexOf('markdown_preview') >= 0) {
-            attach_showdown_preview(tael[i].id, 1);
+            attach_markdown_preview(tael[i].id, 1);
         }
     }
 
index f747ea120c98258c142cd9e3ecbf86aa67a10fe9..1c694e09d92abe22972d17e89cf41a43571f09e2 100644 (file)
@@ -1,6 +1,6 @@
 $(document).ready(function(){
     $('textarea.markdown-content').each(function(idx, e) {
-        attach_showdown_preview(e.id, 0);
+        attach_markdown_preview(e.id, 0);
     });
 
     $('input.toggle-checkbox').each(function(idx, e) {
diff --git a/media/js/markdown_preview.js b/media/js/markdown_preview.js
new file mode 100644 (file)
index 0000000..54a3186
--- /dev/null
@@ -0,0 +1,90 @@
+// Functions to generate markdown previews for form fields
+
+function attach_markdown_preview(objid, admin) {
+    obj = document.getElementById(objid);
+
+    if (!obj) {
+        alert('Could not locate object ' + objid + ' in DOM');
+        return;
+    }
+
+    newdiv = document.createElement('div');
+    newdiv.className = 'markdownpreview col-lg-12';
+
+    if (admin) {
+        obj.style.cssFloat = 'left';
+        obj.style.marginRight = '10px';
+        obj.style.width = newdiv.style.width = "400px";
+        obj.style.height = newdiv.style.height = "200px";
+        newdiv.className = newdiv.className + ' adminmarkdownpreview';
+    }
+
+    obj.preview_div = newdiv;
+
+    obj.parentNode.insertBefore(newdiv, obj.nextSibling);
+
+    obj.infospan_html_base = admin ? '' : 'This field supports <a href="https://daringfireball.net/projects/markdown/basics" target="_blank" rel="noopener">markdown</a>. See below for a preview.';
+
+    obj.infospan = document.createElement('span');
+    obj.infospan.innerHTML = obj.infospan_html_base;
+    obj.parentNode.insertBefore(obj.infospan, newdiv);
+
+    /* First force one update to happen right away */
+    _do_update_markdown(obj);
+
+    obj.addEventListener('keyup', function(e) {
+        update_markdown(this);
+    });
+}
+
+var __update_queue = {};
+var __interval_setup = false;
+function update_markdown(obj) {
+    if (!__interval_setup) {
+        __interval_setup = true;
+
+        /* Global interval ticker running the update queue */
+        setInterval(function() {
+            /* This is where we actually update things */
+            for (var id in __update_queue) {
+                /* First remove it from the queue, so we can absorb another request while we run */
+                delete __update_queue[id];
+                obj = document.getElementById(id);
+
+                _do_update_markdown(obj);
+            }
+        }, 2000); /* Maximum update interval is 2 seconds */
+    }
+
+    if (obj.value == obj.preview_div.value)
+        return;
+
+    /* Just flag that this needs to be done, and the ticker will pick it up */
+    __update_queue[obj.id] = true;
+}
+
+
+function _do_update_markdown(obj) {
+    if (obj.value == '') {
+        /* Short-circuit the empty field case */
+        obj.infospan.innerHTML = '';
+        return;
+    }
+
+    fetch('/account/mdpreview/', {
+        method: 'POST',
+        body: obj.value,
+        headers: {
+            'x-preview': 'md',
+        },
+        credentials: 'same-origin', /* for older browsers */
+    }).then(function(response) {
+        if (response.ok) {
+            return response.text().then(function(text) {
+                obj.preview_div.innerHTML = text;
+            });
+        } else {
+            console.warn('md preview failed');
+        }
+    });
+}
diff --git a/media/js/showdown_preview.js b/media/js/showdown_preview.js
deleted file mode 100644 (file)
index 91b08f0..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-// Functions to generate showdown previews for
-// the django admin interface
-
-var converter = null;
-
-function attach_showdown_preview(objid, admin) {
-    if (!converter) {
-        converter = new Showdown.converter();
-    }
-    obj = document.getElementById(objid);
-
-    if (!obj) {
-        alert('Could not locate object ' + objid + ' in DOM');
-        return;
-    }
-
-    newdiv = document.createElement('div');
-    newdiv.className = 'markdownpreview col-lg-12';
-
-    if (admin) {
-        obj.style.cssFloat = 'left';
-        obj.style.marginRight = '10px';
-        obj.style.width = newdiv.style.width = "400px";
-        obj.style.height = newdiv.style.height = "200px";
-        newdiv.className = newdiv.className + ' adminmarkdownpreview';
-    }
-
-    obj.preview_div = newdiv;
-
-    obj.parentNode.insertBefore(newdiv, obj.nextSibling);
-
-    obj.infospan_html_base = admin ? '' : 'This field supports <a href="https://daringfireball.net/projects/markdown/basics" target="_blank" rel="noopener">markdown</a>. See below for a preview.';
-
-    obj.infospan = document.createElement('span');
-    obj.infospan.innerHTML = obj.infospan_html_base;
-    obj.parentNode.insertBefore(obj.infospan, newdiv);
-
-    update_markdown(obj, newdiv);
-
-    window.onkeyup = function() {
-        /* Using a timer make sure we only update max 4 times / second */
-        if (obj.current_timeout) {
-            clearTimeout(obj.current_timeout);
-        }
-        obj.current_timeout = setTimeout(function() {
-            e = document.getElementsByTagName('textarea');
-            for (i= 0; i < e.length; i++) {
-                if (e[i].preview_div) {
-                    update_markdown(e[i], e[i].preview_div);
-                }
-            }
-        }, 250);
-    };
-}
-
-/*
- * Use regexp to do trivial HTML cleaning. The actual cleaning will happen
- * serverside later, so it doesn't matter that the regexps are far from
- * perfect - it should just be enough to alert the user that he/she is
- * using invalid markup.
- */
-var _update_markdown_reopen = new RegExp("<([^\s/][^>]*)>", "g");
-var _update_markdown_reclose = new RegExp("</([^>]+)>", "g");
-function update_markdown(src, dest) {
-    if (src.value != src.lastvalue) {
-        src.lastvalue = src.value;
-        if (_update_markdown_reclose.test(src.value) || _update_markdown_reopen.test(src.value)) {
-            dest.innerHTML = converter.makeHtml(src.value.replace(_update_markdown_reopen, '[HTML REMOVED]').replace(_update_markdown_reclose,'[HTML REMOVED2]'));
-            if (!src.last_had_html) {
-                src.last_had_html = true;
-                src.infospan.innerHTML = src.infospan_html_base + '<br/><span style="color: red;">You seem to be using HTML in your input - this will be filtered. Please use markdown instead!</span>';
-            }
-        }
-        else {
-            dest.innerHTML = converter.makeHtml(src.value);
-            if (src.last_had_html) {
-                src.last_had_html = false;
-                src.infospan.innerHTML = src.infospan_html_base;
-            }
-        }
-    }
-}
index 25a01cad61aafac454c0ac5bf707863c4ffb0e38..50eaebc077fe08314edb6c1e1d5434544d0fae86 100644 (file)
@@ -28,6 +28,9 @@ urlpatterns = [
     url(r'^(?P<objtype>news|events|products|organisations|services)/(?P<item>\d+|new)/$', pgweb.account.views.submitted_item_form),
     url(r'^organisations/confirm/([0-9a-f]+)/$', pgweb.account.views.confirm_org_email),
 
+    # Markdown preview (silly to have in /account/, but that's where all the markdown forms are so meh)
+    url(r'^mdpreview/', pgweb.account.views.markdown_preview),
+
     # Organisation information
     url(r'^orglist/$', pgweb.account.views.orglist),
 
index 3717da106cd9b26201a1725abc3f54bea7a3112b..addd06edcd94f823219757be1b7e3560b8532ce0 100644 (file)
@@ -5,6 +5,7 @@ from django.http import HttpResponseRedirect, Http404, HttpResponse
 from django.core.exceptions import PermissionDenied
 from django.shortcuts import get_object_or_404
 from pgweb.util.decorators import login_required, script_sources, frame_sources, content_sources
+from django.views.decorators.csrf import csrf_exempt
 from django.utils.encoding import force_bytes
 from django.utils.http import urlsafe_base64_encode
 from django.contrib.auth.tokens import default_token_generator
@@ -26,6 +27,7 @@ from pgweb.util.contexts import render_pgweb
 from pgweb.util.misc import send_template_mail, generate_random_token, get_client_ip
 from pgweb.util.helpers import HttpSimpleResponse, simple_form
 from pgweb.util.moderation import ModerationState
+from pgweb.util.markup import pgmarkdown
 
 from pgweb.news.models import NewsArticle
 from pgweb.events.models import Event
@@ -375,6 +377,18 @@ def submitted_item_submitwithdraw(request, objtype, item, what):
         return _submitted_item_withdraw(request, objtype, model, obj)
 
 
+@login_required
+@csrf_exempt
+def markdown_preview(request):
+    if request.method != 'POST':
+        return HttpResponse("POST only please", status=405)
+
+    if request.headers.get('x-preview', None) != 'md':
+        raise Http404()
+
+    return HttpResponse(pgmarkdown(request.body.decode('utf8', 'ignore')))
+
+
 def login(request):
     return authviews.LoginView.as_view(template_name='account/login.html',
                                        authentication_form=PgwebAuthenticationForm,
diff --git a/pgweb/core/templatetags/pgmarkdown.py b/pgweb/core/templatetags/pgmarkdown.py
new file mode 100644 (file)
index 0000000..5172536
--- /dev/null
@@ -0,0 +1,29 @@
+# Filter wrapping the python markdown library into a django template filter
+from django import template
+from django.utils.encoding import force_text
+from django.utils.safestring import mark_safe
+
+from pgweb.util.markup import pgmarkdown
+
+register = template.Library()
+
+
+@register.filter(is_safe=True)
+def markdown(value, args=''):
+    allow_images = False
+    allow_relative_links = False
+
+    if args:
+        for a in args.split(','):
+            if a == 'allowimages':
+                allow_images = True
+            elif a == 'allowrelativelinks':
+                allow_relative_links = True
+            else:
+                raise ValueError("Invalid argument to markdown: {}".format(a))
+
+    return mark_safe(pgmarkdown(
+        force_text(value),
+        allow_images=allow_images,
+        allow_relative_links=allow_relative_links,
+    ))
index 7c1bce768a9d9c1c53f3b48612305b227c775989..6c1cb24ed7678a9eae39c3739510472ed25b4dcb 100644 (file)
@@ -97,7 +97,6 @@ INSTALLED_APPS = [
     'django.contrib.messages',
     'django.contrib.sessions',
     'django.contrib.admin',
-    'django_markwhat',
     'django.contrib.staticfiles',
     'pgweb.core.apps.CoreAppConfig',
     'pgweb.mailqueue',
index d34d7e08717fe3e3b9e558391ba94f182b06b586..fb1dc342ace85ff48b356504a6aeecab92dd0f9f 100644 (file)
@@ -1,6 +1,5 @@
 from django.shortcuts import render, get_object_or_404
 from django.core.exceptions import PermissionDenied
-from django.core.validators import ValidationError
 from django.http import HttpResponseRedirect, Http404
 from django.template.loader import get_template
 import django.utils.xmlutils
@@ -10,29 +9,14 @@ from pgweb.util.contexts import render_pgweb
 from pgweb.util.moderation import ModerationState
 
 import io
-import re
 import difflib
-import markdown
 
 from pgweb.mailqueue.util import send_simple_mail
-
-
-_re_img = re.compile('<img ', re.I)
-_re_html_open = re.compile(r'<([^\s/][^>]*)>')
+from pgweb.util.markup import pgmarkdown
 
 
 def MarkdownValidator(val):
-    if _re_html_open.search(val):
-        raise ValidationError('Embedding HTML in markdown is not allowed')
-
-    out = markdown.markdown(val)
-
-    # We find images with a regexp, because it works... For now, nothing more advanced
-    # is needed.
-    if _re_img.search(out):
-        raise ValidationError('Image references are not allowed in this field')
-
-    return val
+    return pgmarkdown(val)
 
 
 def simple_form(instancetype, itemid, request, formclass, formtemplate='base/form.html', redirect='/account/', navsection='account', fixedfields=None, createifempty=False, extracontext={}):
diff --git a/pgweb/util/markup.py b/pgweb/util/markup.py
new file mode 100644 (file)
index 0000000..9a4e10e
--- /dev/null
@@ -0,0 +1,54 @@
+import markdown
+from bleach.sanitizer import Cleaner
+from bleach.html5lib_shim import Filter
+
+
+# Tags and attributes generated by markdown (anything that's not
+# generated by markdown is clearly manually added html)
+# This list is from the bleach-allowlist module, but adding a dependency
+# on it just to get two arrays seems silly.
+
+_markdown_tags = [
+    "h1", "h2", "h3", "h4", "h5", "h6",
+    "b", "i", "strong", "em", "tt",
+    "p", "br",
+    "span", "div", "blockquote", "code", "pre", "hr",
+    "ul", "ol", "li", "dd", "dt",
+    # "img",     # img is optional in our markdown validation
+    "a",
+    "sub", "sup",
+]
+
+_markdown_attrs = {
+    "*": ["id"],
+    "img": ["src", "alt", "title"],
+    "a": ["href", "alt", "title"],
+}
+
+
+# Prevent relative links, by simply removing any href tag that does not have
+# a : in it.
+class RelativeLinkFilter(Filter):
+    def __iter__(self):
+        for token in Filter.__iter__(self):
+            if token['type'] in ['StartTag', 'EmptyTag'] and token['data']:
+                if (None, 'href') in token['data']:
+                    # This means a href attribute with no namespace
+                    if ':' not in token['data'][(None, 'href')]:
+                        # Relative link!
+                        del token['data'][(None, 'href')]
+            yield token
+
+
+def pgmarkdown(value, allow_images=False, allow_relative_links=False):
+    tags = _markdown_tags
+    filters = []
+
+    if allow_images:
+        tags.append('img')
+    if not allow_relative_links:
+        filters.append(RelativeLinkFilter)
+
+    cleaner = Cleaner(tags=tags, attributes=_markdown_attrs, filters=filters)
+
+    return cleaner.clean(markdown.markdown(value))
index 61bc3d25a99e0765c90ea8238280fab89dcda449..45db593f67bba0c2476649de5ea23910886af9b2 100644 (file)
@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
 
 import datetime
 
-import markdown
+from pgweb.util.markup import pgmarkdown
 
 
 class ModerateModel(models.Model):
@@ -29,7 +29,7 @@ class ModerateModel(models.Model):
         if k in getattr(self, 'rendered_preview_fields', []):
             yield self.render_preview_field(k, val)
         elif k in getattr(self, 'markdown_fields', []):
-            yield markdown.markdown(val)
+            yield pgmarkdown(val)
         else:
             yield None
 
index d2fa331fa6be1e2c2011e80b3696e0c0ed664b26..3dfa624b955c1988e50dee4932ad774b38704009 100644 (file)
@@ -1,11 +1,11 @@
 Django>=2.2,<2.3
-django-markdown==0.8.4
 psycopg2==2.8.5
 pycryptodomex>=3.4.7,<3.5
-django_markwhat==1.6.2
+Markdown==3.0.1
 requests-oauthlib==1.0.0
 cvss==2.1
 pytidylib==0.3.2
 pycodestyle==2.4.0
 pynliner==0.8.0
 Babel==2.6.0
+bleach==3.1.4
index 39c52c1961133d26510fabe1a65b9ee5bdb5ab9a..ccbc005079eb2be63e1766c560090459dbf5a5eb 100644 (file)
@@ -2,9 +2,8 @@
 
 {% block extrahead %}
 {{ block.super }}
-<link rel="stylesheet" type="text/css" href="/media/css/showdown_preview.css?{{gitrev}}" />
-<script type="text/javascript" src="/media/showdown/showdown.js?{{gitrev}}"></script>
-<script type="text/javascript" src="/media/js/showdown_preview.js?{{gitrev}}"></script>
+<link rel="stylesheet" type="text/css" href="/media/css/markdown_preview.css?{{gitrev}}" />
+<script type="text/javascript" src="/media/js/markdown_preview.js?{{gitrev}}"></script>
 <script type="text/javascript" src="/media/js/admin_pgweb.js?{{gitrev}}"></script>
 {%endblock%}
 
index 0b6d983ffc4341f3c562da6f105f6d4eb029a82e..18156b203dd24963b9cb74334ca13b0756ad9bad 100644 (file)
 
 {% block extrahead %}
 {{ block.super }}
-<link rel="stylesheet" type="text/css" href="/media/css/showdown_preview.css?{{gitrev}}" />
+<link rel="stylesheet" type="text/css" href="/media/css/markdown_preview.css?{{gitrev}}" />
 {%endblock%}
 
 {%block extrascript%}
-<script type="text/javascript" src="/media/showdown/showdown.js?{{gitrev}}"></script>
-<script type="text/javascript" src="/media/js/showdown_preview.js?{{gitrev}}"></script>
+<script type="text/javascript" src="/media/js/markdown_preview.js?{{gitrev}}"></script>
 <script type="text/javascript" src="/media/js/forms.js?{{gitrev}}"></script>
 {%if recaptcha%}
 <script type="text/javascript" src="https://www.google.com/recaptcha/api.js?hl=en" async defer></script>
index abf05e59e22cc964161a9164669f3f87ae798adb..a6ace9e9ad71215f7350416581c86d0ca87f6a85 100644 (file)
@@ -1,5 +1,5 @@
 {%extends "base/page.html"%}
-{%load markup%}
+{%load pgmarkdown%}
 {%block title%}Software Catalogue - {{category.catname}}{%endblock%}
 {%block contents%}
 
@@ -22,7 +22,7 @@
     </thead>
     <tbody>
       <tr>
-       <td>{{product.description|markdown:"safe"}}</td>
+        <td>{{product.description|markdown}}</td>
         <td>{{product.licencetype}}</td>
         <td>{{product.price}}</td>
         <td><a href="{{product.org.url}}" target="_blank" rel="noopener">{{product.org.name}}</a></td>
index 72a646d3fd90ce397ed6355a306ecfcb90fc841d..c8d4e1807e5cd01e38e99433ea27ce4de7ced90a 100644 (file)
@@ -1,5 +1,5 @@
 {%extends "base/page.html"%}
-{%load markup%}
+{%load pgmarkdown%}
 {%block title%}{{title}}{%endblock%}
 {%block contents%}
 
@@ -25,7 +25,7 @@ whatsoever.</em>
 <div>Location: <strong>{{event.locationstring}}</strong></div>
 {%if event.language%}<div>Language: <strong>{{event.language}}</strong></div>{%endif%}
 <div class="newseventwrap">
-{{event.summary|markdown:"safe"|striptags}}
+{{event.summary|markdown|striptags}}
 </div>
 {%endfor%}
 
index 3b9f51584108bc38e2208e017f30c01b7bb0b412..81eb42d031269d5c9d6ba5efd6592c809d9dd87a 100644 (file)
@@ -1,12 +1,12 @@
 {%extends "base/page.html"%}
-{%load markup%}
+{%load pgmarkdown%}
 {%block title%}{{obj.title}}{%endblock%}
 {%block contents%}
 <h1>{{obj.title}}</h1>
 <div class="eventdate">Date: <strong>{{obj.displaydate|safe}}</strong></div>
 <div>Location: {{obj.locationstring}}</div>
 {%if obj.language%}<div>Language: {{obj.language}}</div>{%endif%}
-{{obj.details|markdown:"safe"}}
+{{obj.details|markdown}}
 {%if obj.has_organisation%}
 <p>Posted by {{obj.org}}{%if obj.org.email%} ({{obj.org.email}}){%endif%}.</p>
 {%else%}
index 0d58f2cf863fe8381429cbea172ddc7f325bf2d7..4d4fc58444e578db34716d280f45fa082e6ec5bc 100644 (file)
@@ -1,2 +1,2 @@
-{%load markup%}
-{{obj.summary|markdown:"safe"}}
+{%load pgmarkdown%}
+{{obj.summary|markdown}}
index e5edf0cfffa1afb99db03a5080ffc49212641df1..5bd10e46740d72390e538a1849808962336c5c32 100644 (file)
@@ -1,5 +1,5 @@
 {%extends "base/page.html"%}
-{% load markup %}
+{% load pgmarkdown %}
 {%block title%}Feature Description{%endblock%}
 {%block contents%}
 <h1>Feature Description</h1>
@@ -8,7 +8,7 @@
 {% if feature.featuredescription_is_url %}
   For more information, please visit : <a href="{{ feature.featurelink }}">{{ feature.featurelink }}</a>
 {% else %}
-  {{ feature.featuredescription|markdown:"safe" }}
+  {{ feature.featuredescription|markdown }}
 {% endif %}
 </p>
 {%endblock%}
index f5930fd2ca0bde6bb1ad88f4d0a9247c78eee2aa..c7a831947c50791fafa91e531162b6061a4a7a92 100644 (file)
@@ -1,5 +1,5 @@
 {%extends "base/page.html"%}
-{%load markup%}
+{%load pgmarkdown%}
 {%block title%}{{obj.title}}{%endblock%}
 {%block contents%}
 <h1>{{obj.title}}</h1>
@@ -9,7 +9,7 @@
 {%endfor%}
 </div>
 
-{{obj.content|markdown:"safe"}}
+{{obj.content|markdown}}
 {%if obj.is_migrated%}
 <p><em>This post has been migrated from a previous version of the PostgreSQL
 website. We apologise for any formatting issues caused by the migration.</em></p>
index 52c5fa42e0bf56ff04dca9e6b6ef1eb21e1d2f80..1c918b43f06344dd54c60d4fd13c282fa92b183a 100644 (file)
@@ -1,5 +1,5 @@
 {%extends "news/mail/base.html"%}
-{%load markup%}
+{%load pgmarkdown%}
 {%block title%}{{news.title}}{%endblock%}
 
 {%block content%}
index afeda5ebdbaaf95dd6086da6c5bc594368801990..55ddc2a054ca746edcf0057ff4253b9ccdd3e6e0 100644 (file)
@@ -1,5 +1,5 @@
 {%extends "news/mail/base.html"%}
-{%load markup%}
+{%load pgmarkdown%}
 {%block title%}{{news.title}}{%endblock%}
 
 {%block content%}
index 1b03c2109aa729dbaff1b2790a1d8143d4b006c8..fa2c3b62473a2c4803174c1f0253d212dd959005 100644 (file)
@@ -1,5 +1,5 @@
 {%extends "base/page.html"%}
-{%load markup%}
+{%load pgmarkdown%}
 {%block title%}News Archive{%if tag%} - {{tag.name}}{%endif%}{%endblock%}
 {%block contents%}
 <h1><a href="/about/newsarchive/">News Archive</a>{%if tag%} - {{tag.name}}{%endif%} <i class="far fa-newspaper"></i></h1>
@@ -13,7 +13,7 @@
 <div class="newsdate">Posted on <strong>{{obj.displaydate}}</strong>{% if obj.org.name != '_migrated' %} by {{ obj.org.name }}{% endif %}
 {%for t in obj.tags.all%}<span class="badge badge-pill badge-secondary"><i class="fa fa-tag"></i> {{t}}</span> {%endfor%}
 </div>
-{{obj.content|markdown:"safe"|striptags|truncatewords:20}}
+{{obj.content|markdown|striptags|truncatewords:20}}
 <p><a href="/about/news/{{obj.title|slugify}}-{{obj.id}}/">Read more...</a></p>
 {%endfor%}
 
index e4ac29475540b522eaf922a902116235feb85d60..e04e1d6da8ba4ce23d5193000b82dd83ab90c626 100644 (file)
@@ -1,2 +1,2 @@
-{%load markup%}
-{{obj.content|markdown:"safe"}}
+{%load pgmarkdown%}
+{{obj.content|markdown}}