Skip to content

Commit 5b60bd3

Browse files
authored
Merge pull request #1344 from makeabilitylab/1269-replace-ckeditor-with-django-prose-editor
feat(news): replace django-ckeditor (CKEditor 4) with django-prose-editor (#1269)
2 parents 0b86bda + bef6709 commit 5b60bd3

17 files changed

Lines changed: 572 additions & 26 deletions

File tree

ckeditor_uploader/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""
2+
In-repo compatibility shim for the removed ``ckeditor_uploader`` package (#1269).
3+
4+
django-ckeditor (CKEditor 4) was replaced by django-prose-editor. This package
5+
is NOT a Django app and is not in INSTALLED_APPS; it exists only so historical,
6+
gitignored migration files can still ``import ckeditor_uploader.fields``. See
7+
``ckeditor_uploader/fields.py`` for the full rationale.
8+
"""

ckeditor_uploader/fields.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""
2+
Compatibility shim for ``ckeditor_uploader.fields`` (issue #1269).
3+
4+
django-ckeditor (CKEditor 4) was removed in favor of django-prose-editor. Our
5+
``website/migrations/`` are gitignored and per-environment, so older ones
6+
(0001_initial, 0002, 0003) still ``import ckeditor_uploader.fields`` at load
7+
time to reconstruct historical model state. With the real package gone, that
8+
import would crash ``makemigrations``/``migrate`` on every container start — and
9+
we cannot edit migrations on the servers (push-only deploys, no shell access).
10+
11+
This mirrors the in-repo ``image_cropping`` fork (see image_cropping/README.md
12+
and CLAUDE.md): keep the import path alive with a minimal stand-in so historical
13+
migrations load unchanged. ``News.content`` is now a
14+
``django_prose_editor.fields.ProseEditorField``; a generated ``AlterField``
15+
migration moves the column off this shim on first deploy. The DB column was
16+
always a plain ``TEXT`` column, so the stand-in is just a ``TextField`` that
17+
tolerates (and drops) CKEditor-only constructor kwargs and preserves the
18+
original ``deconstruct()`` import path so the historical migrations round-trip.
19+
20+
Because the project root is first on ``sys.path``, this package shadows any
21+
leftover site-packages copy; behavior is identical whether or not the real
22+
django-ckeditor is still installed.
23+
"""
24+
25+
from django.db import models
26+
27+
# CKEditor-only kwargs that historical field definitions might carry. They have
28+
# no DB-schema meaning for a plain TextField, so we drop them on the way in.
29+
_CKEDITOR_ONLY_KWARGS = (
30+
"config_name",
31+
"extra_plugins",
32+
"external_plugin_resources",
33+
)
34+
35+
36+
class RichTextUploadingField(models.TextField):
37+
"""Minimal stand-in for the removed CKEditor uploading field.
38+
39+
Behaves as a plain ``TextField`` but accepts and discards CKEditor-specific
40+
kwargs, and reports the original dotted path from ``deconstruct()`` so that
41+
gitignored historical migrations referencing
42+
``ckeditor_uploader.fields.RichTextUploadingField`` keep loading.
43+
"""
44+
45+
def __init__(self, *args, **kwargs):
46+
for key in _CKEDITOR_ONLY_KWARGS:
47+
kwargs.pop(key, None)
48+
super().__init__(*args, **kwargs)
49+
50+
def deconstruct(self):
51+
name, _path, args, kwargs = super().deconstruct()
52+
return name, "ckeditor_uploader.fields.RichTextUploadingField", args, kwargs

docker-entrypoint.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ echo "4.2 Running 'python manage.py generate_slugs_for_old_news_items' to genera
9090
echo "******************************************"
9191
python manage.py generate_slugs_for_old_news_items
9292

93+
echo "****************** STEP 4.2b/5: docker-entrypoint.sh ************************"
94+
echo "4.2b Running 'python manage.py normalize_news_image_styles' to make legacy news images responsive (#1269)"
95+
echo "******************************************"
96+
python manage.py normalize_news_image_styles
97+
9398
echo "****************** STEP 4.3/5: docker-entrypoint.sh ************************"
9499
echo "4.3 Running 'python manage.py auto_close_project_roles' to auto-close project roles"
95100
echo "******************************************"

makeabilitylab/settings.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@
8686
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
8787

8888
# Makeability Lab Global Variables, including Makeability Lab version
89-
ML_WEBSITE_VERSION = "2.12.3" # Keep this updated with each release and also change the short description below
90-
ML_WEBSITE_VERSION_DESCRIPTION = "Patch: SEO/infra cleanup. Removes the in-app site_scheme context-processor workaround now that SECURE_PROXY_SSL_HEADER is trusted on TEST/PROD — canonical/OG/sitemap URLs derive https straight from request.scheme behind UW CSE's TLS proxy (#1329/#1236, verified on the sitemap #1338). Wires the Pa11y accessibility sweep into CI and excludes django-debug-toolbar from the scan (#1278 item 6). No schema change; no user-facing behavior change."
89+
ML_WEBSITE_VERSION = "2.13.0" # Keep this updated with each release and also change the short description below
90+
ML_WEBSITE_VERSION_DESCRIPTION = "Feature: replace django-ckeditor (CKEditor 4, EOL with unpatched XSS) with django-prose-editor for the News editor (#1269), unblocking the Django 6.1 LTS upgrade. Content stays raw HTML and migrates near-losslessly; sanitized on save via an nh3 allowlist. In-body image upload moves to a staff-only picker view (reuses media/uploads/ + validate_image_upload); adds an 'Edit HTML' source view. News images are now responsive by default — a one-shot normalize_news_image_styles command strips legacy inline width/height. A small in-repo ckeditor_uploader shim keeps gitignored historical migrations importable (cleanup tracked in #1317). Field swap is TEXT->TEXT (no schema change)."
9191
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
9292
MAX_BANNERS = 7 # Maximum number of banners on a page
9393

@@ -214,9 +214,8 @@
214214
'image_cropping',
215215
'easy_thumbnails', # for dynamically creating thumbnails: https://github.com/SmileyChris/easy-thumbnails
216216
'sortedm2m', # Used for SortedManyToManyFields in admin interface: https://pypi.org/project/django-sortedm2m-filter-horizontal-widget/
217-
'ckeditor', # Used for news page editing in admin interface: https://pypi.org/project/django-ckeditor/
218-
'ckeditor_uploader',
219-
217+
'django_prose_editor', # ProseMirror rich-text editor for the News admin (replaced django-ckeditor; issue #1269)
218+
220219
# This sortedm2m_filter_horizontal_widget widget was originally from:
221220
# https://github.com/svleeuwen/sortedm2m-filter-horizontal-widget
222221
# However, it was incompatible with Django 5.2.9, so we forked it and made some changes.
@@ -358,15 +357,12 @@
358357
STATIC_URL = '/static/'
359358
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
360359

361-
# CKEditor - Rich Text Editor
362-
CKEDITOR_UPLOAD_PATH = "uploads/"
363-
CKEDITOR_FILENAME_GENERATOR = 'website.utils.fileutils.get_ckeditor_image_filename'
364-
CKEDITOR_IMAGE_BACKEND = 'pillow'
365-
CKEDITOR_CONFIGS = {
366-
'default': {
367-
'toolbar': 'full',
368-
},
369-
}
360+
# Rich text editing for the News admin is handled by django-prose-editor
361+
# (issue #1269). Configuration is per-field on website/models/news.py
362+
# (ProseEditorField extensions + sanitize), so no project-level settings are
363+
# needed here. Image uploads go through our own staff-only picker view
364+
# (website/views/news.py: news_image_upload), which still saves into
365+
# media/uploads/ via website.utils.fileutils.get_ckeditor_image_filename.
370366

371367
# Thumbnail processing
372368
# LS: from https://github.com/jonasundderwolf/django-image-cropping

makeabilitylab/settings_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
directly from the current models during test-DB setup (run_syncdb), which is
1515
both reproducible across environments and the durable fix for that flakiness.
1616
17-
Only the *website* app is affected; third-party apps (admin, auth, ckeditor,
17+
Only the *website* app is affected; third-party apps (admin, auth,
1818
sortedm2m, easy_thumbnails, image_cropping, ...) keep their shipped migrations.
1919
"""
2020
import os

makeabilitylab/urls.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
#Info on how to route root to website was found here http://stackoverflow.com/questions/7580220/django-urls-howto-map-root-to-app
4646
re_path(r'', include('website.urls')),
4747
# re_path(r'^admin/', admin.site.urls),
48-
re_path(r'^ckeditor/', include('ckeditor_uploader.urls')),
4948
path("__debug__/", include("debug_toolbar.urls")),
5049
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
5150

requirements.txt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,14 @@ django-sortedm2m==4.0.0
106106
# 1. Fork and update the widget package yourself
107107
# 2. Stay on Django 4.2 LTS (supported until April 2026)
108108

109-
# Django CKEditor - rich text editor for news/content
110-
# WARNING: This package (despite being version 6.7.3) bundles CKEditor 4, which
111-
# is the underlying JavaScript editor. CKEditor 4 has reached end-of-life and
112-
# has unfixed security issues. Consider migrating to one of these alternatives:
113-
# - django-ckeditor-5 (separate package that uses CKEditor 5 JS editor)
114-
# - django-prose-editor
115-
# See: https://pypi.org/project/django-ckeditor/
116-
django-ckeditor==6.7.3
109+
# django-prose-editor - ProseMirror-based rich text editor for the News admin
110+
# (issue #1269). Replaced django-ckeditor (CKEditor 4), which was EOL with
111+
# unpatched XSS and blocked the Django 6.1 LTS upgrade. MIT-licensed, no editor
112+
# licensing/branding regime. The [sanitize] extra pulls in nh3, which we use for
113+
# server-side HTML sanitization derived from the enabled editor extensions
114+
# (ProseEditorField(..., sanitize=True) on website/models/news.py).
115+
# See: https://pypi.org/project/django-prose-editor/
116+
django-prose-editor[sanitize]==0.26.0
117117

118118

119119
# -----------------------------------------------------------------------------

website/admin/news_admin.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ def queryset(self, request, queryset):
3636
@admin.register(News, site=ml_admin_site)
3737
class NewsAdmin(ImageCroppingMixin, admin.ModelAdmin):
3838

39+
class Media:
40+
# Gives the prose-editor content area a usable min-height (#1269).
41+
css = {"all": ("website/css/news_admin.css",)}
42+
3943
# The list display lets us control what is shown in the default table at Home > Website > News
4044
list_display = ('title', 'get_display_thumbnail', 'author', 'date', 'display_projects', 'display_people')
4145

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""
2+
One-shot, idempotent normalizer that strips inline image dimensions from News
3+
content (issue #1269).
4+
5+
Under CKEditor 4, inserted images carried inline ``style="width:...;height:..."``
6+
(and sometimes ``width``/``height`` attributes). Those inline dimensions
7+
*override* the site's responsive rule (``.news-item-content img { max-width:
8+
100%; height: auto }`` in news-item.css), which is why each image historically
9+
had to be hand-edited in "source" to ``width:100%`` with its height erased.
10+
11+
django-prose-editor drops ``style`` on ``<img>`` when it sanitizes on save, so
12+
*new* and *re-saved* posts are already clean. This command applies the same
13+
cleanup to *legacy* rows that haven't been re-saved, so every news image becomes
14+
responsive immediately rather than lazily. It only removes width/height (from
15+
both the ``style`` attribute and the ``width``/``height`` attributes) and leaves
16+
all other markup untouched.
17+
18+
It is idempotent (a second run changes nothing) and writes via ``queryset
19+
.update()`` to avoid re-running model save/validation. It is wired into
20+
docker-entrypoint.sh alongside the other idempotent backfills, which is the only
21+
way to touch prod data (no shell/manage.py access on the servers).
22+
"""
23+
24+
import re
25+
26+
from django.core.management.base import BaseCommand
27+
28+
from website.models import News
29+
30+
# A whole <img ...> tag.
31+
_IMG_TAG_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
32+
# A width/height *attribute* on the tag: width="300", height='2', width=300.
33+
_DIM_ATTR_RE = re.compile(
34+
r'\s+(?:width|height)\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)', re.IGNORECASE
35+
)
36+
# A width/height *declaration* inside a style value: "width: 100%;".
37+
_DIM_DECL_RE = re.compile(r"\s*(?:width|height)\s*:\s*[^;]*;?", re.IGNORECASE)
38+
# The style attribute and its quoted value.
39+
_STYLE_ATTR_RE = re.compile(
40+
r'(\s+style\s*=\s*)(?:"([^"]*)"|\'([^\']*)\')', re.IGNORECASE
41+
)
42+
43+
44+
def _clean_style(match):
45+
"""Drop width/height declarations from one style="..." attribute."""
46+
prefix = match.group(1)
47+
double = match.group(2)
48+
quote = '"' if double is not None else "'"
49+
value = double if double is not None else match.group(3)
50+
new_value = _DIM_DECL_RE.sub("", value).strip().strip(";").strip()
51+
if not new_value:
52+
return "" # nothing left -> drop the style attribute entirely
53+
return f"{prefix}{quote}{new_value}{quote}"
54+
55+
56+
def _normalize_img_tag(tag):
57+
tag = _DIM_ATTR_RE.sub("", tag)
58+
tag = _STYLE_ATTR_RE.sub(_clean_style, tag)
59+
return tag
60+
61+
62+
def normalize_image_dimensions(html):
63+
"""
64+
Strip inline width/height (style declarations + width/height attributes)
65+
from every ``<img>`` tag in ``html``. All other markup is preserved.
66+
67+
>>> normalize_image_dimensions('<img src="a.jpg" style="width:100%">')
68+
'<img src="a.jpg">'
69+
"""
70+
if not html or "<img" not in html.lower():
71+
return html
72+
return _IMG_TAG_RE.sub(lambda m: _normalize_img_tag(m.group(0)), html)
73+
74+
75+
class Command(BaseCommand):
76+
help = (
77+
"Strip inline width/height from <img> tags in News.content so images "
78+
"rely on the responsive .news-item-content img CSS. Idempotent; safe to "
79+
"run repeatedly (issue #1269)."
80+
)
81+
82+
def handle(self, *args, **options):
83+
changed = 0
84+
for news in News.objects.all():
85+
original = news.content or ""
86+
cleaned = normalize_image_dimensions(original)
87+
if cleaned != original:
88+
News.objects.filter(pk=news.pk).update(content=cleaned)
89+
changed += 1
90+
self.stdout.write(f"News id {news.pk}: normalized image dimensions")
91+
self.stdout.write(
92+
self.style.SUCCESS(
93+
f"normalize_news_image_styles: updated {changed} news item(s)."
94+
)
95+
)

website/models/news.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from django.dispatch import receiver
33
from django.db.models.signals import pre_delete, post_save, m2m_changed, post_delete
44

5-
from ckeditor_uploader.fields import RichTextUploadingField
5+
from django.urls import reverse_lazy
6+
from django_prose_editor.fields import ProseEditorField
67
from website.utils.fileutils import UniquePathAndRename
78
from website.utils.upload_validators import validate_image_upload
89
from image_cropping import ImageRatioField
@@ -42,7 +43,35 @@ def get_thumbnail_size_as_str():
4243
date = models.DateField(default=date.today)
4344
author = models.ForeignKey(Person, null=True, on_delete=models.SET_NULL, related_name='authored_news')
4445

45-
content = RichTextUploadingField(config_name='default')
46+
# Rich-text body, edited with django-prose-editor (issue #1269; replaced
47+
# CKEditor 4). `sanitize=True` cleans the HTML on save using an nh3 allowlist
48+
# derived from the enabled extensions below, so only markup the editor can
49+
# actually produce is stored. The Figure extension's `pickerUrl` wires the
50+
# "Browse…" image button to our staff-only upload view (see website/urls.py:
51+
# news_image_upload); reverse_lazy avoids a URL-resolution-at-import cycle.
52+
# Images are inserted without inline width/height so the responsive
53+
# `.news-item-content img` CSS governs sizing (news-item.css).
54+
content = ProseEditorField(
55+
extensions={
56+
"Bold": True, "Italic": True, "Underline": True, "Strike": True,
57+
"Subscript": True, "Superscript": True, "Code": True,
58+
"Heading": {"levels": [2, 3, 4]},
59+
"BulletList": True, "OrderedList": True, "ListItem": True,
60+
"Blockquote": True, "HorizontalRule": True,
61+
"TextAlign": True, "TextStyle": True,
62+
# "Edit HTML" source view for occasional manual tweaks. Adds nothing
63+
# to the sanitize allowlist, so source edits are still cleaned on
64+
# save (can't introduce disallowed tags like <script>).
65+
"HTML": True,
66+
"Link": {"enableTarget": True},
67+
# Figure wraps images in <figure>/<figcaption>; Image covers bare
68+
# <img> (all our legacy images) and Caption enables the captions.
69+
"Figure": {"pickerUrl": reverse_lazy("website:news_image_upload")},
70+
"Image": True, "Caption": True,
71+
# Document/Paragraph/Text/HardBreak/History/Menu are implied defaults
72+
},
73+
sanitize=True,
74+
)
4675

4776
# Following the scheme of above thumbnails in other models
4877
image = models.ImageField(blank=True, upload_to=UniquePathAndRename("news", True), max_length=255, validators=[validate_image_upload])

0 commit comments

Comments
 (0)