Merge branch 'master' into custom-user-model-fixes

Conflicts:
	wagtail/wagtailadmin/forms.py
This commit is contained in:
John-Scott Atlakson 2014-10-24 13:59:47 -04:00
commit e32827f143
10 changed files with 159 additions and 19 deletions

View file

@ -4,11 +4,16 @@ Changelog
0.8 (xx.xx.2014)
~~~~~~~~~~~~~~~~
* Added logging for page operations
* The save/publish/submit buttons on the page edit page now redirects the user back to the edit page instead of the explorer
* Signal handlers for ``wagtail.wagtailsearch`` and ``wagtail.contrib.wagtailfrontendcache`` are now automatically registered when using Django 1.7 or above. (Tim Heap)
* Fix: Replaced references of .username with .get_username() on users for better custom user model support (John-Scott Atlakson)
* Fix: Unpinned dependency versions for six and requests to help prevent dependency conflicts
* Fix: Fixed TypeError when getting embed HTML with oembed on Python 3 (John-Scott Atlakson)
* Fix: Made HTML whitelisting in rich text fields more robust at catching disallowed URL schemes such as "jav\tascript:" (Tim Heap)
* Fix: created_at timestamps on page revisions were not being preserved on page copy, causing revisions to get out of sequence
* Fix: When copying pages recursively, revisions of sub-pages were being copied regardless of the copy_revisions flag
* Fix: Updated the migration dependencies within the project template to ensure that Wagtail's own migrations consistently apply first.
0.7 (09.10.2014)
~~~~~~~~~~~~~~~~

View file

@ -15,6 +15,7 @@ What's new
Minor features
~~~~~~~~~~~~~~
* Page operations (creation, publishing, copying etc) are now logged via Python's ``logging`` framework; to configure this, add a logger entry for ``'wagtail'`` or ``'wagtail.core'`` to the ``LOGGING`` setup in your settings file.
* The save/publish/submit buttons on the page edit page now redirects the user back to the edit page instead of the explorer
* Signal handlers for ``wagtail.wagtailsearch`` and ``wagtail.contrib.wagtailfrontendcache`` are now automatically registered when using Django 1.7 or above.
@ -25,6 +26,11 @@ Bug fixes
* Replaced references of .username with .get_username() on users for better custom user model support
* Unpinned dependency versions for six and requests to help prevent dependency conflicts
* Fixed TypeError when getting embed HTML with oembed on Python 3
* Made HTML whitelisting in rich text fields more robust at catching disallowed URL schemes such as ``jav\tascript:``
* ``created_at`` timestamps on page revisions were not being preserved on page copy, causing revisions to get out of sequence
* When copying pages recursively, revisions of sub-pages were being copied regardless of the ``copy_revisions`` flag
* Updated the migration dependencies within the project template to ensure that Wagtail's own migrations consistently apply first
Upgrade considerations
======================

View file

@ -7,7 +7,7 @@ from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('wagtailcore', '0002_initial_data'),
('wagtailcore', '__latest__'),
]
operations = [

View file

@ -40,9 +40,12 @@ class EmailLinkChooserWithLinkTextForm(forms.Form):
class LoginForm(AuthenticationForm):
username = forms.CharField(
max_length=254,
widget=forms.TextInput(attrs={'tabindex': '1',}),
)
password = forms.CharField(
widget=forms.PasswordInput(attrs={'placeholder': ugettext_lazy("Enter password")}),
widget=forms.PasswordInput(attrs={'placeholder': ugettext_lazy("Enter password"),
'tabindex': '2',
}),
)
def __init__(self, request=None, *args, **kwargs):

View file

@ -55,7 +55,7 @@
</li>
{% endcomment %}
<li class="submit">
<input type="submit" value="{% trans 'Sign in' %}"/>
<input type="submit" value="{% trans 'Sign in' %}" tabindex="3"/>
</li>
</ul>
</form>
@ -68,4 +68,4 @@
$('form input[name=username]').focus();
})
</script>
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('wagtailcore', '0008_populate_latest_revision_created_at'),
]
operations = [
migrations.AlterField(
model_name='pagerevision',
name='created_at',
field=models.DateTimeField(),
),
]

View file

@ -1,7 +1,7 @@
import logging
import warnings
import six
from six import string_types
from six import StringIO
from six.moves.urllib.parse import urlparse
@ -9,7 +9,7 @@ from modelcluster.models import ClusterableModel, get_all_child_relations
from django.db import models, connection, transaction
from django.db.models import Q
from django.db.models.signals import pre_delete
from django.db.models.signals import pre_delete, post_delete
from django.dispatch.dispatcher import receiver
from django.http import Http404
from django.core.cache import cache
@ -37,6 +37,9 @@ from wagtail.wagtailsearch import index
from wagtail.wagtailsearch.backends import get_search_backend
logger = logging.getLogger('wagtail.core')
class SiteManager(models.Manager):
def get_by_natural_key(self, hostname, port):
return self.get(hostname=hostname, port=port)
@ -316,8 +319,9 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed
@transaction.atomic # ensure that changes are only committed when we have updated all descendant URL paths, to preserve consistency
def save(self, *args, **kwargs):
update_descendant_url_paths = False
is_new = self.id is None
if self.id is None:
if is_new:
# we are creating a record. If we're doing things properly, this should happen
# through a treebeard method like add_child, in which case the 'path' field
# has been set and so we can safely call get_parent
@ -341,6 +345,11 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed
if Site.objects.filter(root_page=self).exists():
cache.delete('wagtail_site_root_paths')
# Log
if is_new:
cls = type(self)
logger.info("Page created: \"%s\" id=%d content_type=%s.%s path=%s", self.title, self.id, cls._meta.app_label, cls.__name__, self.url_path)
return result
def _update_descendant_url_paths(self, old_url_path, new_url_path):
@ -412,6 +421,7 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed
raise Http404
def save_revision(self, user=None, submitted_for_moderation=False, approved_go_live_at=None):
# Create revision
revision = self.revisions.create(
content_json=self.to_json(),
user=user,
@ -422,6 +432,12 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed
self.latest_revision_created_at = revision.created_at
self.save(update_fields=['latest_revision_created_at'])
# Log
logger.info("Page edited: \"%s\" id=%d revision_id=%d", self.title, self.id, revision.id)
if submitted_for_moderation:
logger.info("Page submitted for moderation: \"%s\" id=%d revision_id=%d", self.title, self.id, revision.id)
return revision
def get_latest_revision(self):
@ -448,6 +464,8 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed
page_unpublished.send(sender=self.specific_class, instance=self.specific)
logger.info("Page unpublished: \"%s\" id=%d", self.title, self.id)
self.revisions.update(approved_go_live_at=None)
def get_context(self, request, *args, **kwargs):
@ -650,6 +668,9 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed
new_self.save()
new_self._update_descendant_url_paths(old_url_path, new_url_path)
# Log
logger.info("Page moved: \"%s\" id=%d path=%s", self.title, self.id, new_url_path)
def copy(self, recursive=False, to=None, update_attrs=None, copy_revisions=True):
# Make a copy
page_copy = Page.objects.get(id=self.id).specific
@ -689,10 +710,13 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed
revision.page = page_copy
revision.save()
# Log
logger.info("Page copied: \"%s\" id=%d from=%d", page_copy.title, page_copy.id, self.id)
# Copy child pages
if recursive:
for child_page in self.get_children():
child_page.specific.copy(recursive=True, to=page_copy)
child_page.specific.copy(recursive=True, to=page_copy, copy_revisions=copy_revisions)
return page_copy
@ -794,12 +818,10 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, index.Indexed
return ['/']
def get_sitemap_urls(self):
latest_revision = self.get_latest_revision()
return [
{
'location': self.full_url,
'lastmod': latest_revision.created_at if latest_revision else None
'lastmod': self.latest_revision_created_at
}
]
@ -899,6 +921,11 @@ def unpublish_page_before_delete(sender, instance, **kwargs):
instance.unpublish(commit=False)
@receiver(post_delete, sender=Page)
def log_page_deletion(sender, instance, **kwargs):
logger.info("Page deleted: \"%s\" id=%d", instance.title, instance.id)
class Orderable(models.Model):
sort_order = models.IntegerField(null=True, blank=True, editable=False)
sort_order_field = 'sort_order'
@ -917,7 +944,7 @@ class SubmittedRevisionsManager(models.Manager):
class PageRevision(models.Model):
page = models.ForeignKey('Page', related_name='revisions')
submitted_for_moderation = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
created_at = models.DateTimeField()
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True)
content_json = models.TextField()
approved_go_live_at = models.DateTimeField(null=True, blank=True)
@ -926,6 +953,12 @@ class PageRevision(models.Model):
submitted_revisions = SubmittedRevisionsManager()
def save(self, *args, **kwargs):
# Set default value for created_at to now
# We cannot use auto_now_add as that will override
# any value that is set before saving
if self.created_at is None:
self.created_at = timezone.now()
super(PageRevision, self).save(*args, **kwargs)
if self.submitted_for_moderation:
# ensure that all other revisions of this page have the 'submitted for moderation' flag unset
@ -955,10 +988,12 @@ class PageRevision(models.Model):
def approve_moderation(self):
if self.submitted_for_moderation:
logger.info("Page moderation approved: \"%s\" id=%d revision_id=%d", self.page.title, self.page.id, self.id)
self.publish()
def reject_moderation(self):
if self.submitted_for_moderation:
logger.info("Page moderation rejected: \"%s\" id=%d revision_id=%d", self.page.title, self.page.id, self.id)
self.submitted_for_moderation = False
self.save(update_fields=['submitted_for_moderation'])
@ -995,8 +1030,12 @@ class PageRevision(models.Model):
if page.live:
page_published.send(sender=page.specific_class, instance=page.specific)
logger.info("Page published: \"%s\" id=%d revision_id=%d", page.title, page.id, self.id)
elif page.go_live_at:
logger.info("Page scheduled for publish: \"%s\" id=%d revision_id=%d go_live_at=%s", page.title, page.id, self.id, page.go_live_at.isoformat())
def __str__(self):
return '"' + unicode(self.page) + '" at ' + unicode(self.created_at)
return '"' + six.text_type(self.page) + '" at ' + six.text_type(self.created_at)
PAGE_PERMISSION_TYPE_CHOICES = [

View file

@ -443,6 +443,23 @@ class TestCopyPage(TestCase):
# Check that the new revision is not submitted for moderation
self.assertFalse(new_christmas_event.revisions.first().submitted_for_moderation)
def test_copy_page_copies_revisions_and_doesnt_change_created_at(self):
christmas_event = EventPage.objects.get(url_path='/home/events/christmas/')
christmas_event.save_revision(submitted_for_moderation=True)
# Set the created_at of the revision to a time in the past
revision = christmas_event.get_latest_revision()
revision.created_at = datetime.datetime(2014, 1, 1)
revision.save()
# Copy it
new_christmas_event = christmas_event.copy(update_attrs={'title': "New christmas event", 'slug': 'new-christmas-event'})
# Check that the created_at time is the same
christmas_event_created_at = christmas_event.get_latest_revision().created_at
new_christmas_event_created_at = new_christmas_event.get_latest_revision().created_at
self.assertEqual(christmas_event_created_at, new_christmas_event_created_at)
def test_copy_page_copies_revisions_and_doesnt_schedule(self):
christmas_event = EventPage.objects.get(url_path='/home/events/christmas/')
christmas_event.save_revision(approved_go_live_at=datetime.datetime(2014, 9, 16, 9, 12, 00, tzinfo=pytz.utc))
@ -456,6 +473,19 @@ class TestCopyPage(TestCase):
# Check that the new revision is not scheduled
self.assertEqual(new_christmas_event.revisions.first().approved_go_live_at, None)
def test_copy_page_doesnt_copy_revisions_if_told_not_to_do_so(self):
christmas_event = EventPage.objects.get(url_path='/home/events/christmas/')
christmas_event.save_revision()
# Copy it
new_christmas_event = christmas_event.copy(update_attrs={'title': "New christmas event", 'slug': 'new-christmas-event'}, copy_revisions=False)
# Check that the revisions weren't copied
self.assertEqual(new_christmas_event.revisions.count(), 0, "Revisions were copied")
# Check that the revisions weren't removed from old page
self.assertEqual(christmas_event.revisions.count(), 1, "Revisions were removed from the original page")
def test_copy_page_copies_child_objects_with_nonspecific_class(self):
# Get chrismas page as Page instead of EventPage
christmas_event = Page.objects.get(url_path='/home/events/christmas/')
@ -519,6 +549,23 @@ class TestCopyPage(TestCase):
# Check that the revisions weren't removed from old page
self.assertEqual(old_christmas_event.specific.revisions.count(), 1, "Revisions were removed from the original page")
def test_copy_page_copies_recursively_but_doesnt_copy_revisions_if_told_not_to_do_so(self):
events_index = EventIndex.objects.get(url_path='/home/events/')
old_christmas_event = events_index.get_children().filter(slug='christmas').first()
old_christmas_event.save_revision()
# Copy it
new_events_index = events_index.copy(recursive=True, update_attrs={'title': "New events index", 'slug': 'new-events-index'}, copy_revisions=False)
# Get christmas event
new_christmas_event = new_events_index.get_children().filter(slug='christmas').first()
# Check that the revisions weren't copied
self.assertEqual(new_christmas_event.specific.revisions.count(), 0, "Revisions were copied")
# Check that the revisions weren't removed from old page
self.assertEqual(old_christmas_event.specific.revisions.count(), 1, "Revisions were removed from the original page")
class TestSubpageTypeBusinessRules(TestCase):
def test_allowed_subpage_types(self):

View file

@ -17,6 +17,13 @@ class TestCheckUrl(TestCase):
def test_disallowed_url_scheme(self):
self.assertFalse(bool(check_url("invalid://url")))
def test_crafty_disallowed_url_scheme(self):
"""
Some URL parsers do not parse 'jav\tascript:' as a valid scheme.
Browsers, however, do. The checker needs to catch these crafty schemes
"""
self.assertFalse(bool(check_url("jav\tascript:alert('XSS')")))
class TestAttributeRule(TestCase):
def setUp(self):

View file

@ -2,19 +2,33 @@
A generic HTML whitelisting engine, designed to accommodate subclassing to override
specific rules.
"""
from six.moves.urllib.parse import urlparse
import re
from bs4 import BeautifulSoup, NavigableString, Tag
ALLOWED_URL_SCHEMES = ['', 'http', 'https', 'ftp', 'mailto', 'tel']
ALLOWED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'tel']
PROTOCOL_RE = re.compile("^[a-z0-9][-+.a-z0-9]*:")
def check_url(url_string):
# TODO: more paranoid checks (urlparse doesn't catch
# "jav\tascript:alert('XSS')")
url = urlparse(url_string)
return (url_string if url.scheme in ALLOWED_URL_SCHEMES else None)
# Remove control characters and other disallowed characters
# Browsers sometimes ignore these, so that 'jav\tascript:alert("XSS")'
# is treated as a valid javascript: link
unescaped = url_string.lower()
unescaped = unescaped.replace("&lt;", "<")
unescaped = unescaped.replace("&gt;", ">")
unescaped = unescaped.replace("&amp;", "&")
unescaped = re.sub("[`\000-\040\177-\240\s]+", '', unescaped)
unescaped = unescaped.replace("\ufffd", "")
if PROTOCOL_RE.match(unescaped):
protocol = unescaped.split(':', 1)[0]
if protocol not in ALLOWED_URL_SCHEMES:
return None
return url_string
def attribute_rule(allowed_attrs):