Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ckeditor_uploader/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
52 changes: 52 additions & 0 deletions ckeditor_uploader/fields.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "******************************************"
Expand Down
24 changes: 10 additions & 14 deletions makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion makeabilitylab/settings_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion makeabilitylab/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 8 additions & 8 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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


# -----------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions website/admin/news_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
95 changes: 95 additions & 0 deletions website/management/commands/normalize_news_image_styles.py
Original file line number Diff line number Diff line change
@@ -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 ``<img>`` 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 <img ...> tag.
_IMG_TAG_RE = re.compile(r"<img\b[^>]*>", 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 ``<img>`` tag in ``html``. All other markup is preserved.

>>> normalize_image_dimensions('<img src="a.jpg" style="width:100%">')
'<img src="a.jpg">'
"""
if not html or "<img" not in html.lower():
return html
return _IMG_TAG_RE.sub(lambda m: _normalize_img_tag(m.group(0)), html)


class Command(BaseCommand):
help = (
"Strip inline width/height from <img> 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)."
)
)
33 changes: 31 additions & 2 deletions website/models/news.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <script>).
"HTML": True,
"Link": {"enableTarget": True},
# Figure wraps images in <figure>/<figcaption>; Image covers bare
# <img> (all our legacy images) and Caption enables the captions.
"Figure": {"pickerUrl": reverse_lazy("website:news_image_upload")},
"Image": True, "Caption": True,
# Document/Paragraph/Text/HardBreak/History/Menu are implied defaults
},
sanitize=True,
)

# Following the scheme of above thumbnails in other models
image = models.ImageField(blank=True, upload_to=UniquePathAndRename("news", True), max_length=255, validators=[validate_image_upload])
Expand Down
14 changes: 14 additions & 0 deletions website/static/website/css/news-item.css
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,20 @@
border-radius: var(--border-radius-sm);
}

/* Images inserted via django-prose-editor's Figure extension are wrapped in
<figure> with an optional <figcaption> (issue #1269). The img rule above
keeps them responsive; these style the wrapper and caption. */
.news-item-content figure {
margin: var(--space-4) 0;
}

.news-item-content figcaption {
margin-top: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
text-align: center;
}

.news-item-content h2 {
margin-top: var(--space-6);
margin-bottom: var(--space-3);
Expand Down
10 changes: 10 additions & 0 deletions website/static/website/css/news_admin.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Admin-only tweaks for the News editor (issue #1269).
*
* django-prose-editor ships no min-height, so the empty editable area
* collapses to ~one line on the "Add news item" page. Give it a comfortable
* authoring height; it still grows with content.
*/
.prose-editor .ProseMirror {
min-height: 22rem;
}
Loading
Loading