diff --git a/ckeditor_uploader/__init__.py b/ckeditor_uploader/__init__.py
new file mode 100644
index 00000000..3265191f
--- /dev/null
+++ b/ckeditor_uploader/__init__.py
@@ -0,0 +1,8 @@
+"""
+In-repo compatibility shim for the removed ``ckeditor_uploader`` package (#1269).
+
+django-ckeditor (CKEditor 4) was replaced by django-prose-editor. This package
+is NOT a Django app and is not in INSTALLED_APPS; it exists only so historical,
+gitignored migration files can still ``import ckeditor_uploader.fields``. See
+``ckeditor_uploader/fields.py`` for the full rationale.
+"""
diff --git a/ckeditor_uploader/fields.py b/ckeditor_uploader/fields.py
new file mode 100644
index 00000000..92b1a723
--- /dev/null
+++ b/ckeditor_uploader/fields.py
@@ -0,0 +1,52 @@
+"""
+Compatibility shim for ``ckeditor_uploader.fields`` (issue #1269).
+
+django-ckeditor (CKEditor 4) was removed in favor of django-prose-editor. Our
+``website/migrations/`` are gitignored and per-environment, so older ones
+(0001_initial, 0002, 0003) still ``import ckeditor_uploader.fields`` at load
+time to reconstruct historical model state. With the real package gone, that
+import would crash ``makemigrations``/``migrate`` on every container start — and
+we cannot edit migrations on the servers (push-only deploys, no shell access).
+
+This mirrors the in-repo ``image_cropping`` fork (see image_cropping/README.md
+and CLAUDE.md): keep the import path alive with a minimal stand-in so historical
+migrations load unchanged. ``News.content`` is now a
+``django_prose_editor.fields.ProseEditorField``; a generated ``AlterField``
+migration moves the column off this shim on first deploy. The DB column was
+always a plain ``TEXT`` column, so the stand-in is just a ``TextField`` that
+tolerates (and drops) CKEditor-only constructor kwargs and preserves the
+original ``deconstruct()`` import path so the historical migrations round-trip.
+
+Because the project root is first on ``sys.path``, this package shadows any
+leftover site-packages copy; behavior is identical whether or not the real
+django-ckeditor is still installed.
+"""
+
+from django.db import models
+
+# CKEditor-only kwargs that historical field definitions might carry. They have
+# no DB-schema meaning for a plain TextField, so we drop them on the way in.
+_CKEDITOR_ONLY_KWARGS = (
+ "config_name",
+ "extra_plugins",
+ "external_plugin_resources",
+)
+
+
+class RichTextUploadingField(models.TextField):
+ """Minimal stand-in for the removed CKEditor uploading field.
+
+ Behaves as a plain ``TextField`` but accepts and discards CKEditor-specific
+ kwargs, and reports the original dotted path from ``deconstruct()`` so that
+ gitignored historical migrations referencing
+ ``ckeditor_uploader.fields.RichTextUploadingField`` keep loading.
+ """
+
+ def __init__(self, *args, **kwargs):
+ for key in _CKEDITOR_ONLY_KWARGS:
+ kwargs.pop(key, None)
+ super().__init__(*args, **kwargs)
+
+ def deconstruct(self):
+ name, _path, args, kwargs = super().deconstruct()
+ return name, "ckeditor_uploader.fields.RichTextUploadingField", args, kwargs
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 3b324335..59b0b948 100755
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -90,6 +90,11 @@ echo "4.2 Running 'python manage.py generate_slugs_for_old_news_items' to genera
echo "******************************************"
python manage.py generate_slugs_for_old_news_items
+echo "****************** STEP 4.2b/5: docker-entrypoint.sh ************************"
+echo "4.2b Running 'python manage.py normalize_news_image_styles' to make legacy news images responsive (#1269)"
+echo "******************************************"
+python manage.py normalize_news_image_styles
+
echo "****************** STEP 4.3/5: docker-entrypoint.sh ************************"
echo "4.3 Running 'python manage.py auto_close_project_roles' to auto-close project roles"
echo "******************************************"
diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py
index 1d409608..abb25be2 100644
--- a/makeabilitylab/settings.py
+++ b/makeabilitylab/settings.py
@@ -86,8 +86,8 @@
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Makeability Lab Global Variables, including Makeability Lab version
-ML_WEBSITE_VERSION = "2.12.3" # Keep this updated with each release and also change the short description below
-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."
+ML_WEBSITE_VERSION = "2.13.0" # Keep this updated with each release and also change the short description below
+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)."
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
MAX_BANNERS = 7 # Maximum number of banners on a page
@@ -214,9 +214,8 @@
'image_cropping',
'easy_thumbnails', # for dynamically creating thumbnails: https://github.com/SmileyChris/easy-thumbnails
'sortedm2m', # Used for SortedManyToManyFields in admin interface: https://pypi.org/project/django-sortedm2m-filter-horizontal-widget/
- 'ckeditor', # Used for news page editing in admin interface: https://pypi.org/project/django-ckeditor/
- 'ckeditor_uploader',
-
+ 'django_prose_editor', # ProseMirror rich-text editor for the News admin (replaced django-ckeditor; issue #1269)
+
# This sortedm2m_filter_horizontal_widget widget was originally from:
# https://github.com/svleeuwen/sortedm2m-filter-horizontal-widget
# However, it was incompatible with Django 5.2.9, so we forked it and made some changes.
@@ -358,15 +357,12 @@
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
-# CKEditor - Rich Text Editor
-CKEDITOR_UPLOAD_PATH = "uploads/"
-CKEDITOR_FILENAME_GENERATOR = 'website.utils.fileutils.get_ckeditor_image_filename'
-CKEDITOR_IMAGE_BACKEND = 'pillow'
-CKEDITOR_CONFIGS = {
- 'default': {
- 'toolbar': 'full',
- },
-}
+# Rich text editing for the News admin is handled by django-prose-editor
+# (issue #1269). Configuration is per-field on website/models/news.py
+# (ProseEditorField extensions + sanitize), so no project-level settings are
+# needed here. Image uploads go through our own staff-only picker view
+# (website/views/news.py: news_image_upload), which still saves into
+# media/uploads/ via website.utils.fileutils.get_ckeditor_image_filename.
# Thumbnail processing
# LS: from https://github.com/jonasundderwolf/django-image-cropping
diff --git a/makeabilitylab/settings_test.py b/makeabilitylab/settings_test.py
index fede8304..6387fee9 100644
--- a/makeabilitylab/settings_test.py
+++ b/makeabilitylab/settings_test.py
@@ -14,7 +14,7 @@
directly from the current models during test-DB setup (run_syncdb), which is
both reproducible across environments and the durable fix for that flakiness.
-Only the *website* app is affected; third-party apps (admin, auth, ckeditor,
+Only the *website* app is affected; third-party apps (admin, auth,
sortedm2m, easy_thumbnails, image_cropping, ...) keep their shipped migrations.
"""
import os
diff --git a/makeabilitylab/urls.py b/makeabilitylab/urls.py
index fb505a91..e4ea6924 100644
--- a/makeabilitylab/urls.py
+++ b/makeabilitylab/urls.py
@@ -45,7 +45,6 @@
#Info on how to route root to website was found here http://stackoverflow.com/questions/7580220/django-urls-howto-map-root-to-app
re_path(r'', include('website.urls')),
# re_path(r'^admin/', admin.site.urls),
- re_path(r'^ckeditor/', include('ckeditor_uploader.urls')),
path("__debug__/", include("debug_toolbar.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/requirements.txt b/requirements.txt
index 40716b56..a9b6ac98 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -106,14 +106,14 @@ django-sortedm2m==4.0.0
# 1. Fork and update the widget package yourself
# 2. Stay on Django 4.2 LTS (supported until April 2026)
-# Django CKEditor - rich text editor for news/content
-# WARNING: This package (despite being version 6.7.3) bundles CKEditor 4, which
-# is the underlying JavaScript editor. CKEditor 4 has reached end-of-life and
-# has unfixed security issues. Consider migrating to one of these alternatives:
-# - django-ckeditor-5 (separate package that uses CKEditor 5 JS editor)
-# - django-prose-editor
-# See: https://pypi.org/project/django-ckeditor/
-django-ckeditor==6.7.3
+# django-prose-editor - ProseMirror-based rich text editor for the News admin
+# (issue #1269). Replaced django-ckeditor (CKEditor 4), which was EOL with
+# unpatched XSS and blocked the Django 6.1 LTS upgrade. MIT-licensed, no editor
+# licensing/branding regime. The [sanitize] extra pulls in nh3, which we use for
+# server-side HTML sanitization derived from the enabled editor extensions
+# (ProseEditorField(..., sanitize=True) on website/models/news.py).
+# See: https://pypi.org/project/django-prose-editor/
+django-prose-editor[sanitize]==0.26.0
# -----------------------------------------------------------------------------
diff --git a/website/admin/news_admin.py b/website/admin/news_admin.py
index 786b8034..28261046 100644
--- a/website/admin/news_admin.py
+++ b/website/admin/news_admin.py
@@ -36,6 +36,10 @@ def queryset(self, request, queryset):
@admin.register(News, site=ml_admin_site)
class NewsAdmin(ImageCroppingMixin, admin.ModelAdmin):
+ class Media:
+ # Gives the prose-editor content area a usable min-height (#1269).
+ css = {"all": ("website/css/news_admin.css",)}
+
# The list display lets us control what is shown in the default table at Home > Website > News
list_display = ('title', 'get_display_thumbnail', 'author', 'date', 'display_projects', 'display_people')
diff --git a/website/management/commands/normalize_news_image_styles.py b/website/management/commands/normalize_news_image_styles.py
new file mode 100644
index 00000000..3d973e56
--- /dev/null
+++ b/website/management/commands/normalize_news_image_styles.py
@@ -0,0 +1,95 @@
+"""
+One-shot, idempotent normalizer that strips inline image dimensions from News
+content (issue #1269).
+
+Under CKEditor 4, inserted images carried inline ``style="width:...;height:..."``
+(and sometimes ``width``/``height`` attributes). Those inline dimensions
+*override* the site's responsive rule (``.news-item-content img { max-width:
+100%; height: auto }`` in news-item.css), which is why each image historically
+had to be hand-edited in "source" to ``width:100%`` with its height erased.
+
+django-prose-editor drops ``style`` on ```` when it sanitizes on save, so
+*new* and *re-saved* posts are already clean. This command applies the same
+cleanup to *legacy* rows that haven't been re-saved, so every news image becomes
+responsive immediately rather than lazily. It only removes width/height (from
+both the ``style`` attribute and the ``width``/``height`` attributes) and leaves
+all other markup untouched.
+
+It is idempotent (a second run changes nothing) and writes via ``queryset
+.update()`` to avoid re-running model save/validation. It is wired into
+docker-entrypoint.sh alongside the other idempotent backfills, which is the only
+way to touch prod data (no shell/manage.py access on the servers).
+"""
+
+import re
+
+from django.core.management.base import BaseCommand
+
+from website.models import News
+
+# A whole
tag.
+_IMG_TAG_RE = re.compile(r"
]*>", re.IGNORECASE)
+# A width/height *attribute* on the tag: width="300", height='2', width=300.
+_DIM_ATTR_RE = re.compile(
+ r'\s+(?:width|height)\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)', re.IGNORECASE
+)
+# A width/height *declaration* inside a style value: "width: 100%;".
+_DIM_DECL_RE = re.compile(r"\s*(?:width|height)\s*:\s*[^;]*;?", re.IGNORECASE)
+# The style attribute and its quoted value.
+_STYLE_ATTR_RE = re.compile(
+ r'(\s+style\s*=\s*)(?:"([^"]*)"|\'([^\']*)\')', re.IGNORECASE
+)
+
+
+def _clean_style(match):
+ """Drop width/height declarations from one style="..." attribute."""
+ prefix = match.group(1)
+ double = match.group(2)
+ quote = '"' if double is not None else "'"
+ value = double if double is not None else match.group(3)
+ new_value = _DIM_DECL_RE.sub("", value).strip().strip(";").strip()
+ if not new_value:
+ return "" # nothing left -> drop the style attribute entirely
+ return f"{prefix}{quote}{new_value}{quote}"
+
+
+def _normalize_img_tag(tag):
+ tag = _DIM_ATTR_RE.sub("", tag)
+ tag = _STYLE_ATTR_RE.sub(_clean_style, tag)
+ return tag
+
+
+def normalize_image_dimensions(html):
+ """
+ Strip inline width/height (style declarations + width/height attributes)
+ from every ``
`` tag in ``html``. All other markup is preserved.
+
+ >>> normalize_image_dimensions('
')
+ '
'
+ """
+ if not html or " tags in News.content so images "
+ "rely on the responsive .news-item-content img CSS. Idempotent; safe to "
+ "run repeatedly (issue #1269)."
+ )
+
+ def handle(self, *args, **options):
+ changed = 0
+ for news in News.objects.all():
+ original = news.content or ""
+ cleaned = normalize_image_dimensions(original)
+ if cleaned != original:
+ News.objects.filter(pk=news.pk).update(content=cleaned)
+ changed += 1
+ self.stdout.write(f"News id {news.pk}: normalized image dimensions")
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"normalize_news_image_styles: updated {changed} news item(s)."
+ )
+ )
diff --git a/website/models/news.py b/website/models/news.py
index b25a2e7e..e82c452a 100644
--- a/website/models/news.py
+++ b/website/models/news.py
@@ -2,7 +2,8 @@
from django.dispatch import receiver
from django.db.models.signals import pre_delete, post_save, m2m_changed, post_delete
-from ckeditor_uploader.fields import RichTextUploadingField
+from django.urls import reverse_lazy
+from django_prose_editor.fields import ProseEditorField
from website.utils.fileutils import UniquePathAndRename
from website.utils.upload_validators import validate_image_upload
from image_cropping import ImageRatioField
@@ -42,7 +43,35 @@ def get_thumbnail_size_as_str():
date = models.DateField(default=date.today)
author = models.ForeignKey(Person, null=True, on_delete=models.SET_NULL, related_name='authored_news')
- content = RichTextUploadingField(config_name='default')
+ # Rich-text body, edited with django-prose-editor (issue #1269; replaced
+ # CKEditor 4). `sanitize=True` cleans the HTML on save using an nh3 allowlist
+ # derived from the enabled extensions below, so only markup the editor can
+ # actually produce is stored. The Figure extension's `pickerUrl` wires the
+ # "Browse…" image button to our staff-only upload view (see website/urls.py:
+ # news_image_upload); reverse_lazy avoids a URL-resolution-at-import cycle.
+ # Images are inserted without inline width/height so the responsive
+ # `.news-item-content img` CSS governs sizing (news-item.css).
+ content = ProseEditorField(
+ extensions={
+ "Bold": True, "Italic": True, "Underline": True, "Strike": True,
+ "Subscript": True, "Superscript": True, "Code": True,
+ "Heading": {"levels": [2, 3, 4]},
+ "BulletList": True, "OrderedList": True, "ListItem": True,
+ "Blockquote": True, "HorizontalRule": True,
+ "TextAlign": True, "TextStyle": True,
+ # "Edit HTML" source view for occasional manual tweaks. Adds nothing
+ # to the sanitize allowlist, so source edits are still cleaned on
+ # save (can't introduce disallowed tags like
+{% else %}
+
{{ error }}
{% endif %} + +{% endif %} +