diff --git a/wagtail/admin/compare.py b/wagtail/admin/compare.py index 3c64f158f9..dc7a416e62 100644 --- a/wagtail/admin/compare.py +++ b/wagtail/admin/compare.py @@ -10,6 +10,11 @@ from django.utils.translation import gettext_lazy as _ from wagtail.core import blocks +def text_from_html(val): + # Return the unescaped text content of an HTML string + return BeautifulSoup(force_str(val), 'html5lib').getText() + + class FieldComparison: is_field = True is_child_relation = False @@ -52,15 +57,18 @@ class TextFieldComparison(FieldComparison): class RichTextFieldComparison(TextFieldComparison): def htmldiff(self): return diff_text( - BeautifulSoup(force_str(self.val_a), 'html5lib').getText(), - BeautifulSoup(force_str(self.val_b), 'html5lib').getText() + text_from_html(self.val_a), + text_from_html(self.val_b) ).to_html() def get_comparison_class_for_block(block): if hasattr(block, 'get_comparison_class'): return block.get_comparison_class() - elif isinstance(block, blocks.CharBlock): + elif isinstance(block, (blocks.CharBlock, blocks.TextBlock)): + return CharBlockComparison + elif isinstance(block, blocks.RawHTMLBlock): + # Compare raw HTML blocks as if they were plain text, so that tags are shown explicitly return CharBlockComparison elif isinstance(block, blocks.RichTextBlock): return RichTextBlockComparison @@ -89,7 +97,19 @@ class BlockComparison: return self.val_a != self.val_b def htmlvalue(self, val): - return self.block.render_basic(val) + """ + Return an HTML representation of this block that is safe to be included + in comparison views + """ + return escape(text_from_html(self.block.render_basic(val))) + + def htmldiff(self): + html_val_a = self.block.render_basic(self.val_a) + html_val_b = self.block.render_basic(self.val_b) + return diff_text( + text_from_html(html_val_a), + text_from_html(html_val_b) + ).to_html() class CharBlockComparison(BlockComparison): @@ -99,13 +119,12 @@ class CharBlockComparison(BlockComparison): force_str(self.val_b) ).to_html() + def htmlvalue(self, val): + return escape(val) + class RichTextBlockComparison(BlockComparison): - def htmldiff(self): - return diff_text( - BeautifulSoup(force_str(self.val_a), 'html5lib').getText(), - BeautifulSoup(force_str(self.val_b), 'html5lib').getText() - ).to_html() + pass class StructBlockComparison(BlockComparison): @@ -219,8 +238,8 @@ class StreamFieldComparison(FieldComparison): else: # Fall back to diffing the HTML representation return diff_text( - BeautifulSoup(force_str(self.val_a), 'html5lib').getText(), - BeautifulSoup(force_str(self.val_b), 'html5lib').getText() + text_from_html(self.val_a), + text_from_html(self.val_b) ).to_html() diff --git a/wagtail/admin/tests/test_compare.py b/wagtail/admin/tests/test_compare.py index 262f4ba91d..2348ba11f4 100644 --- a/wagtail/admin/tests/test_compare.py +++ b/wagtail/admin/tests/test_compare.py @@ -247,36 +247,151 @@ class TestStreamFieldComparison(TestCase): self.assertIsInstance(comparison.htmldiff(), SafeString) self.assertTrue(comparison.has_changed()) - def test_htmldiff_escapes_value(self): + def test_htmldiff_escapes_value_on_change(self): field = StreamPage._meta.get_field('body') comparison = self.comparison_class( field, StreamPage(body=StreamValue(field.stream_block, [ - ('text', "Original content", '1'), + ('text', "I really like originalish content", '1'), ])), StreamPage(body=StreamValue(field.stream_block, [ - ('text', '', '1'), + ('text', 'I really like evil code ', '1'), ])), ) - self.assertEqual(comparison.htmldiff(), '
Original content<script type="text/javascript">doSomethingBad();</script>
') + self.assertEqual(comparison.htmldiff(), '
I <b>really</b> like original<i>ish</i> contentevil code <script type="text/javascript">doSomethingBad();</script>
') self.assertIsInstance(comparison.htmldiff(), SafeString) - def test_htmldiff_escapes_value_richtext(self): + def test_htmldiff_escapes_value_on_addition(self): field = StreamPage._meta.get_field('body') comparison = self.comparison_class( field, StreamPage(body=StreamValue(field.stream_block, [ - ('rich_text', "Original content", '1'), + ('text', "Original and unchanged content", '1'), ])), StreamPage(body=StreamValue(field.stream_block, [ - ('rich_text', '', '1'), + ('text', "Original and unchanged content", '1'), + ('text', '', '2'), ])), ) - self.assertEqual(comparison.htmldiff(), '
Original contentdoSomethingBad();
') + self.assertEqual(comparison.htmldiff(), '
Original <em>and unchanged</em> content
\n
<script type="text/javascript">doSomethingBad();</script>
') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_escapes_value_on_deletion(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('text', "Original and unchanged content", '1'), + ('text', '', '2'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('text', "Original and unchanged content", '1'), + ])), + ) + + self.assertEqual(comparison.htmldiff(), '
Original <em>and unchanged</em> content
\n
<script type="text/javascript">doSomethingBad();</script>
') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_richtext_strips_tags_on_change(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', "I really like Wagtail <3", '1'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', 'I really like evil code >_< ', '1'), + ])), + ) + + self.assertEqual(comparison.htmldiff(), '
I really like Wagtail <3evil code >_< doSomethingBad();
') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_richtext_strips_tags_on_addition(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', "Original and unchanged content", '1'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', "Original and unchanged content", '1'), + ('rich_text', 'I really like evil code >_< ', '2'), + ])), + ) + + self.assertEqual(comparison.htmldiff(), '
Original and unchanged content
\n
I really like evil code >_< doSomethingBad();
') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_richtext_strips_tags_on_deletion(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', "Original and unchanged content", '1'), + ('rich_text', 'I really like evil code >_< ', '2'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', "Original and unchanged content", '1'), + ])), + ) + + self.assertEqual(comparison.htmldiff(), '
Original and unchanged content
\n
I really like evil code >_< doSomethingBad();
') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_raw_html_escapes_value_on_change(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Originalish content", '1'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', '', '1'), + ])), + ) + self.assertEqual(comparison.htmldiff(), '
Original<i>ish</i> content<script type="text/javascript">doSomethingBad();</script>
') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_raw_html_escapes_value_on_addition(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Original and unchanged content", '1'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Original and unchanged content", '1'), + ('raw_html', '', '2'), + ])), + ) + self.assertEqual(comparison.htmldiff(), '
Original <em>and unchanged</em> content
\n
<script type="text/javascript">doSomethingBad();</script>
') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_raw_html_escapes_value_on_deletion(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Original and unchanged content", '1'), + ('raw_html', '', '2'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Original and unchanged content", '1'), + ])), + ) + self.assertEqual(comparison.htmldiff(), '
Original <em>and unchanged</em> content
\n
<script type="text/javascript">doSomethingBad();</script>
') self.assertIsInstance(comparison.htmldiff(), SafeString) def test_compare_structblock(self): diff --git a/wagtail/tests/testapp/migrations/0048_rawhtmlblock.py b/wagtail/tests/testapp/migrations/0048_rawhtmlblock.py new file mode 100644 index 0000000000..19c901dccf --- /dev/null +++ b/wagtail/tests/testapp/migrations/0048_rawhtmlblock.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.4 on 2020-04-06 09:46 + +from django.db import migrations +import wagtail.core.blocks +import wagtail.core.fields +import wagtail.tests.testapp.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tests', '0047_restaurant_tags'), + ] + + operations = [ + migrations.AlterField( + model_name='streampage', + name='body', + field=wagtail.core.fields.StreamField([('text', wagtail.core.blocks.CharBlock()), ('rich_text', wagtail.core.blocks.RichTextBlock()), ('image', wagtail.tests.testapp.models.ExtendedImageChooserBlock()), ('product', wagtail.core.blocks.StructBlock([('name', wagtail.core.blocks.CharBlock()), ('price', wagtail.core.blocks.CharBlock())])), ('raw_html', wagtail.core.blocks.RawHTMLBlock())]), + ), + ] diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py index 3938ed1627..586672d21b 100644 --- a/wagtail/tests/testapp/models.py +++ b/wagtail/tests/testapp/models.py @@ -29,7 +29,7 @@ from wagtail.contrib.forms.models import ( from wagtail.contrib.settings.models import BaseSetting, register_setting from wagtail.contrib.sitemaps import Sitemap from wagtail.contrib.table_block.blocks import TableBlock -from wagtail.core.blocks import CharBlock, RichTextBlock, StructBlock +from wagtail.core.blocks import CharBlock, RawHTMLBlock, RichTextBlock, StructBlock from wagtail.core.fields import RichTextField, StreamField from wagtail.core.models import Orderable, Page, PageManager, PageQuerySet from wagtail.documents.edit_handlers import DocumentChooserPanel @@ -972,6 +972,7 @@ class StreamPage(Page): ('name', CharBlock()), ('price', CharBlock()), ])), + ('raw_html', RawHTMLBlock()), ]) api_fields = ('body',)