feat(news): replace django-ckeditor (CKEditor 4) with django-prose-editor (#1269)#1344
Merged
jonfroehlich merged 1 commit intoJun 18, 2026
Merged
Conversation
CKEditor 4 (bundled by django-ckeditor) is EOL with unpatched XSS and was the
single most fragile dependency blocking the Django 6.1 LTS upgrade. Replace it
with django-prose-editor (ProseMirror, MIT) for the News editor — the only model
using a rich-text field.
- News.content -> ProseEditorField(extensions=..., sanitize=True). Content stays
raw HTML; a 51-post production audit showed migration is near-lossless (only
inline img `style` is dropped). Sanitized on save via an nh3 allowlist derived
from the enabled extensions. Field swap is TEXT->TEXT (no schema change).
- In-body image upload: prose-editor has no native upload, so add a staff-only
picker view (website/views/news_image_upload.py) wired to the Figure pickerUrl
via the CKEditor-4 filebrowser callback protocol. Reuses media/uploads/ +
get_ckeditor_image_filename and validate_image_upload (rejects SVG/HTML).
Collects alt text at upload time.
- Enable the HTML extension ("Edit HTML" source view); give the admin editor a
usable min-height (news_admin.css).
- Responsive-by-default images: prose-editor drops inline img dims on save, and
the idempotent normalize_news_image_styles command (wired into the entrypoint)
strips them from legacy rows so the existing .news-item-content img CSS governs
sizing. Eliminates the old manual "set width:100%, erase height" ritual.
- Remove ckeditor/ckeditor_uploader from INSTALLED_APPS, the CKEDITOR_* settings,
the ckeditor/ URL include, and the dependency.
- Add a small in-repo ckeditor_uploader shim so gitignored, per-environment
historical migrations (0001-0003) that `import ckeditor_uploader.fields` keep
loading after the package is removed (mirrors the image_cropping in-repo
module). Cleanup tracked in #1317.
- Add regression tests (sanitize fidelity, upload auth/callback/rejection, admin
widget rendering, normalizer idempotency). Bump version to 2.13.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1269. Replaces django-ckeditor (CKEditor 4 — EOL, unpatched XSS, the most fragile dependency blocking the Django 6.1 LTS upgrade) with django-prose-editor (ProseMirror-based, MIT) for the News editor — the only model using a rich-text field.
Why django-prose-editor
MIT-licensed with no editor-vendor licensing/branding regime (unlike CKEditor 5 / TinyMCE, which both re-introduce license-key churn); lightweight; the django-ckeditor maintainer himself migrated to it. Markdown editors were ruled out because
News.contentis stored as raw HTML (a Markdown switch would mean a lossy HTML→MD conversion of every post).Migration is near-lossless (validated)
Ran 51 real production posts through prose-editor's actual nh3 sanitizer (extension-derived allowlist). The only markup dropped across the entire corpus was inline
styleon<img>(mostlywidth:100%); all text, links, lists, blockquotes, headings, code, and images survive. No tables/iframes/embeds exist in any post. The field swap is TEXT→TEXT (no schema change); content is sanitized on save.What changed
News.content→ProseEditorField(extensions=…, sanitize=True).website/views/news_image_upload.py) is wired to the FigurepickerUrlvia the CKEditor-4 filebrowser callback protocol. Reusesmedia/uploads/+get_ckeditor_image_filename, validates withvalidate_image_upload(rejects SVG/HTML), and collects alt text at upload time.normalize_news_image_stylescommand (wired intodocker-entrypoint.sh) strips them from legacy rows so the existing.news-item-content imgCSS governs sizing. Eliminates the old manual "set width:100%, erase height in source" ritual.news_admin.css).ckeditor/ckeditor_uploaderfromINSTALLED_APPS, theCKEDITOR_*settings, theckeditor/URL include, and the dependency.Migration shim (temporary; cleanup tracked in #1317)
Gitignored, per-environment historical migrations (
0001–0003)import ckeditor_uploader.fieldsat load time, and the entrypoint re-imports all migrations on every start. Removing the package would crash startup everywhere, and we can't edit prod's migration files. A small in-repockeditor_uploader/shim (a stubRichTextUploadingField(TextField)) keeps that import resolving — mirroring the existingimage_croppingin-repo module. It can be deleted once the migrations-in-Git cleanup (#1317) lands; commented there.Testing
website/tests/test_news_prose_editor.py): sanitize fidelity, upload endpoint auth/callback/rejection, admin widget rendering, normalizer idempotency.makemigrations/migratecleanly (shim +AlterField), the normalizer runs, the admin renders the editor with working upload + source view.Screenshot of new editor
Deploy note
Intentionally no SemVer tag in this PR — merging to
masterdeploys to-testonly. Will tag for prod after auditing news pages on-test. Version bumped to 2.13.0.🤖 Generated with Claude Code