diff --git a/.secrets.baseline b/.secrets.baseline index b04a7e4..6000923 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -124,27 +124,26 @@ }, { "path": "detect_secrets.filters.heuristic.is_templated_secret" + }, + { + "path": "detect_secrets.filters.regex.should_exclude_file", + "pattern": [ + "static/blog/tiptap\\.js$", + "static/css/tailwind\\.min\\.css$", + "package-lock\\.json$", + "\\.venv/", + "node_modules/" + ] } ], "results": { - ".github/workflows/tests.yml": [ - { - "type": "Secret Keyword", - "filename": ".github/workflows/tests.yml", - "hashed_secret": "3c90011fe8d955a8938382f77cafa2016b03413e", - "is_verified": false, - "line_number": 34, - "is_secret": false - } - ], "account/api/serializers.py": [ { "type": "Secret Keyword", "filename": "account/api/serializers.py", "hashed_secret": "ba82d7311b12e96451206a9202f2300add40126a", "is_verified": false, - "line_number": 38, - "is_secret": false + "line_number": 38 } ], "account/tests.py": [ @@ -153,56 +152,49 @@ "filename": "account/tests.py", "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", "is_verified": false, - "line_number": 74, - "is_secret": false + "line_number": 74 }, { "type": "Secret Keyword", "filename": "account/tests.py", "hashed_secret": "1816851d10187b57a93235b52495e615629f906d", "is_verified": false, - "line_number": 103, - "is_secret": false + "line_number": 103 }, { "type": "Secret Keyword", "filename": "account/tests.py", "hashed_secret": "0fa86f7cd4925d1bc1f299fefdaeb3cede77592f", "is_verified": false, - "line_number": 146, - "is_secret": false + "line_number": 146 }, { "type": "Secret Keyword", "filename": "account/tests.py", "hashed_secret": "d8ecf7db8fc9ec9c31bc5c9ae2929cc599c75f8d", "is_verified": false, - "line_number": 176, - "is_secret": false + "line_number": 176 }, { "type": "Secret Keyword", "filename": "account/tests.py", "hashed_secret": "0d8b28805975effded2c628b96d75cd3b47bbdcf", "is_verified": false, - "line_number": 257, - "is_secret": false + "line_number": 257 }, { "type": "Secret Keyword", "filename": "account/tests.py", "hashed_secret": "1151a8c33d6ae2daf21ad0d466488e707c1c7f8d", "is_verified": false, - "line_number": 281, - "is_secret": false + "line_number": 281 }, { "type": "Secret Keyword", "filename": "account/tests.py", "hashed_secret": "bdb8465ce041d94a0e490564f2162dcc87d4a46a", "is_verified": false, - "line_number": 289, - "is_secret": false + "line_number": 289 } ], "blog/tests.py": [ @@ -211,8 +203,7 @@ "filename": "blog/tests.py", "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", "is_verified": false, - "line_number": 13, - "is_secret": false + "line_number": 13 } ], "blog/tests_api.py": [ @@ -221,8 +212,7 @@ "filename": "blog/tests_api.py", "hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f", "is_verified": false, - "line_number": 32, - "is_secret": false + "line_number": 32 } ], "settings.ini.example": [ @@ -231,10 +221,9 @@ "filename": "settings.ini.example", "hashed_secret": "29b8dca3de5ff27bcf8bd3b622adf9970f29381c", "is_verified": false, - "line_number": 2, - "is_secret": false + "line_number": 2 } ] }, - "generated_at": "2026-05-14T13:07:32Z" + "generated_at": "2026-05-14T23:21:05Z" } diff --git a/blog/forms.py b/blog/forms.py index a4899fc..3e8a35f 100644 --- a/blog/forms.py +++ b/blog/forms.py @@ -1,6 +1,7 @@ from django import forms from blog.models import BlogPost, Comment +from blog.widgets import TiptapWidget class CommentForm(forms.ModelForm): @@ -20,6 +21,7 @@ class Meta: fields = ['title', 'body', 'image', 'category', 'tags', 'status'] widgets = { 'tags': forms.CheckboxSelectMultiple(), + 'body': TiptapWidget(), } @@ -30,10 +32,10 @@ class Meta: fields = ['title', 'body', 'image', 'category', 'tags', 'status'] widgets = { 'tags': forms.CheckboxSelectMultiple(), + 'body': TiptapWidget(), } def save(self, commit=True): - # Keep existing image if no new one uploaded if not self.cleaned_data.get('image'): self.cleaned_data['image'] = self.instance.image return super().save(commit=commit) diff --git a/blog/migrations/0009_alter_blogpost_body.py b/blog/migrations/0009_alter_blogpost_body.py index 7b54825..c1dba8d 100644 --- a/blog/migrations/0009_alter_blogpost_body.py +++ b/blog/migrations/0009_alter_blogpost_body.py @@ -1,7 +1,6 @@ # Generated by Django 5.2.12 on 2026-04-02 19:45 -import django_ckeditor_5.fields -from django.db import migrations +from django.db import migrations, models class Migration(migrations.Migration): @@ -14,6 +13,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='blogpost', name='body', - field=django_ckeditor_5.fields.CKEditor5Field(blank=True, max_length=20000), + field=models.TextField(blank=True, max_length=20000), ), ] diff --git a/blog/migrations/0014_alter_blogpost_body.py b/blog/migrations/0014_alter_blogpost_body.py new file mode 100644 index 0000000..009fa79 --- /dev/null +++ b/blog/migrations/0014_alter_blogpost_body.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-14 18:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0013_rebackfill_body_plain_unescape'), + ] + + operations = [ + migrations.AlterField( + model_name='blogpost', + name='body', + field=models.TextField(blank=True, max_length=20000), + ), + ] diff --git a/blog/models.py b/blog/models.py index 5379655..679a411 100644 --- a/blog/models.py +++ b/blog/models.py @@ -8,7 +8,6 @@ from django.dispatch import receiver from django.utils.html import strip_tags from django.utils.text import slugify -from django_ckeditor_5.fields import CKEditor5Field def upload_location(instance, filename): @@ -49,7 +48,7 @@ class BlogPost(models.Model): ] title = models.CharField(max_length=50, null=False, blank=True) - body = CKEditor5Field(max_length=20000, blank=True, config_name='default') + body = models.TextField(max_length=20000, blank=True) # Plain-text mirror of body, populated in pre_save. Search uses this so HTML # tag names (div, class, style) don't leak as matches against the rich field. body_plain = models.TextField(blank=True, editable=False) diff --git a/blog/templates/blog/widgets/tiptap.html b/blog/templates/blog/widgets/tiptap.html new file mode 100644 index 0000000..1cd3a56 --- /dev/null +++ b/blog/templates/blog/widgets/tiptap.html @@ -0,0 +1,19 @@ +
+ + +
+ +
+
+
diff --git a/blog/tests_tiptap.py b/blog/tests_tiptap.py new file mode 100644 index 0000000..d899ba6 --- /dev/null +++ b/blog/tests_tiptap.py @@ -0,0 +1,104 @@ +from django.test import TestCase +from django.urls import reverse + +from account.models import Account +from blog.forms import CreateBlogPostForm +from blog.models import BlogPost, Category +from blog.widgets import TiptapWidget + + +class TiptapWidgetRenderTests(TestCase): + def test_renders_hidden_textarea(self): + html = TiptapWidget().render('body', '

hi

') + self.assertIn('Hi') + self.assertIn('<h2>Hi</h2>', html) + + def test_initial_value_html_escaped_to_prevent_textarea_breakout(self): + html = TiptapWidget().render('body', '') + self.assertNotIn('', + author=self.user, category=self.category, status='published', + ) + r = self.client.get(reverse('blog:detail', args=[post.slug])) + self.assertEqual(r.status_code, 200) + # The sanitiser must strip the ', r.content) diff --git a/blog/widgets.py b/blog/widgets.py new file mode 100644 index 0000000..54f40ab --- /dev/null +++ b/blog/widgets.py @@ -0,0 +1,21 @@ +from django import forms + + +class TiptapWidget(forms.Textarea): + """Renders a hidden + +
+ +
+
+ +``` + +- [ ] **Step 2: Commit** + +Run: +```bash +git add blog/templates/blog/widgets/tiptap.html +git commit -m "Add Tiptap widget template" +``` + +--- + +## Task 8: TiptapWidget class + Python tests + +**Files:** +- Create: `blog/widgets.py` +- Create: `blog/tests_tiptap.py` + +The project uses **tabs** for Python indentation per `pyproject.toml`. All Python code in this task uses tabs. + +- [ ] **Step 1: Write failing tests** + +`blog/tests_tiptap.py`: +```python +from django.test import TestCase +from django.urls import reverse + +from account.models import Account +from blog.forms import CreateBlogPostForm +from blog.models import BlogPost, Category +from blog.widgets import TiptapWidget + + +class TiptapWidgetRenderTests(TestCase): + def test_renders_hidden_textarea(self): + html = TiptapWidget().render('body', '

hi

') + self.assertIn('Hi') + self.assertIn('

Hi

', html) + + def test_media_includes_tiptap_assets(self): + widget = TiptapWidget() + self.assertIn('blog/tiptap.js', str(widget.media)) + self.assertIn('blog/tiptap.css', str(widget.media)) + + +class TiptapFormRoundtripTests(TestCase): + def setUp(self): + self.user = Account.objects.create_user( + email='t@nyasablog.com', username='t', password='p', + ) + self.user.email_verified = True + self.user.save() + self.category, _ = Category.objects.get_or_create( + name='Culture', slug='culture', defaults={'description': 'x'}, + ) + + def test_form_uses_tiptap_widget_for_body(self): + form = CreateBlogPostForm() + self.assertIsInstance(form.fields['body'].widget, TiptapWidget) + + def test_form_roundtrip_preserves_html(self): + form = CreateBlogPostForm({ + 'title': 'T', + 'body': '

X

Y

', + 'category': self.category.id, + 'status': 'draft', + }) + self.assertTrue(form.is_valid(), form.errors) + post = form.save(commit=False) + post.author = self.user + post.save() + form.save_m2m() + post.refresh_from_db() + self.assertEqual(post.body, '

X

Y

') + + +class SanitiserRegressionTests(TestCase): + """The editor swap MUST NOT regress the server-side sanitiser.""" + + def setUp(self): + self.user = Account.objects.create_user( + email='s@nyasablog.com', username='s', password='p', + ) + self.user.email_verified = True + self.user.save() + self.category, _ = Category.objects.get_or_create( + name='Tech', slug='tech', defaults={'description': 'x'}, + ) + + def test_detail_view_strips_style_attribute(self): + post = BlogPost.objects.create( + title='S', slug='s-style', + body='

X

', + author=self.user, category=self.category, status='published', + ) + r = self.client.get(reverse('detail', kwargs={'slug': post.slug})) + self.assertEqual(r.status_code, 200) + self.assertNotIn(b'style=', r.content) + self.assertIn(b'

X

', r.content) + + def test_detail_view_strips_script(self): + post = BlogPost.objects.create( + title='S', slug='s-script', + body='

safe

', + author=self.user, category=self.category, status='published', + ) + r = self.client.get(reverse('detail', kwargs={'slug': post.slug})) + self.assertEqual(r.status_code, 200) + self.assertNotIn(b'') + r = client.get(post.get_absolute_url()) + assert b"

X

" in r.content + assert b"