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 %} +

Upload an image

+ {% if error %}{% endif %} +
+ {% csrf_token %} + + + +

JPEG, PNG, GIF, or WebP. The image displays responsively + (full width of the article column) — no need to set a width.

+ + +

Describe the image's content/purpose. Leave blank only if + the image is purely decorative.

+ +
+{% endif %} + + diff --git a/website/tests/test_news_prose_editor.py b/website/tests/test_news_prose_editor.py new file mode 100644 index 00000000..93024125 --- /dev/null +++ b/website/tests/test_news_prose_editor.py @@ -0,0 +1,178 @@ +""" +Regression tests for the django-ckeditor -> django-prose-editor migration (#1269): + +- `News.content` (ProseEditorField, sanitize=True) preserves our real markup and + drops only `style` on `` when it sanitizes. +- the staff-only image upload picker view (auth gate, CKEditor-protocol callback, + rejection of non-image payloads), and +- the `normalize_news_image_styles` command that makes legacy images responsive. +""" + +import shutil +import tempfile + +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.test import override_settings +from django.urls import reverse + +from website.models import News +from website.management.commands.normalize_news_image_styles import ( + normalize_image_dimensions, +) +from website.tests.base import DatabaseTestCase +from website.tests.factories import _GIF_1PX + +# Representative legacy News body covering every tag our 51-post production audit +# found, plus an image carrying the CKEditor-style inline dimensions we want gone. +LEGACY_HTML = ( + "

Big news

" + "

Congrats to Chu and Jared! " + 'Read more.

' + "" + "
A quote.
" + "

pip install x

" + 'Chu presenting' +) + +_TMP_MEDIA = tempfile.mkdtemp() + + +def tearDownModule(): + shutil.rmtree(_TMP_MEDIA, ignore_errors=True) + + +class NewsContentSanitizeTests(DatabaseTestCase): + """The ProseEditorField sanitizer (extension-derived nh3 allowlist).""" + + def _sanitize(self, html): + news = self.make_news_item() + return News._meta.get_field("content").clean(html, news) + + def test_preserves_rich_markup(self): + out = self._sanitize(LEGACY_HTML) + # Structure and text survive. + self.assertIn("

Big news

", out) + self.assertIn("Chu", out) + self.assertIn("Jared", out) + self.assertIn("
  • One
  • ", out) + self.assertIn("
    ", out) + self.assertIn("pip install x", out) + # Links keep href and target. + self.assertIn('href="https://example.com/x"', out) + self.assertIn('target="_blank"', out) + # The image itself (src/alt) survives. + self.assertIn('src="/media/uploads/2024/01/01/CHU.JPG"', out) + self.assertIn('alt="Chu presenting"', out) + + def test_drops_inline_style_on_images(self): + out = self._sanitize(LEGACY_HTML) + # The Image extension allows src/alt/width/height but NOT style, so the + # inline style="width:100%;height:300px" is removed. + self.assertNotIn("style=", out) + + +class NewsImageUploadViewTests(DatabaseTestCase): + """The staff-only picker view wired to prose-editor's Figure pickerUrl.""" + + def setUp(self): + self.url = reverse("website:news_image_upload") + User = get_user_model() + self.staff = User.objects.create_user( + username="editor", password="pw", is_staff=True + ) + + def test_requires_staff(self): + # Anonymous users are redirected to the admin login, not served the form. + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 302) + self.assertIn("/admin/login", resp["Location"]) + + def test_get_renders_form_with_func_num(self): + self.client.force_login(self.staff) + resp = self.client.get(self.url, {"CKEditorFuncNum": "7"}) + self.assertEqual(resp.status_code, 200) + body = resp.content.decode() + self.assertIn('name="upload"', body) + self.assertIn('name="alt_text"', body) + self.assertIn('value="7"', body) # round-tripped func num + + @override_settings(MEDIA_ROOT=_TMP_MEDIA) + def test_post_saves_and_calls_back(self): + self.client.force_login(self.staff) + upload = SimpleUploadedFile("photo.gif", _GIF_1PX, content_type="image/gif") + resp = self.client.post( + self.url, + {"CKEditorFuncNum": "7", "alt_text": "A description", "upload": upload}, + ) + self.assertEqual(resp.status_code, 200) + body = resp.content.decode() + # Calls the CKEditor-protocol callback with the func num + saved URL. + self.assertIn("callFunction", body) + self.assertIn("/uploads/", body) + self.assertIn("PHOTO.GIF", body) # get_ckeditor_image_filename uppercases + self.assertIn("A description", body) + + @override_settings(MEDIA_ROOT=_TMP_MEDIA) + def test_rejects_non_image_payload(self): + self.client.force_login(self.staff) + evil = SimpleUploadedFile( + "evil.svg", b"", content_type="image/svg+xml" + ) + resp = self.client.post( + self.url, {"CKEditorFuncNum": "7", "upload": evil} + ) + self.assertEqual(resp.status_code, 200) + body = resp.content.decode() + self.assertNotIn("callFunction", body) # no insertion happened + self.assertIn("upload", body) # re-rendered the form + + +class NewsAdminWidgetTests(DatabaseTestCase): + """The News admin renders the prose-editor widget wired to our upload view.""" + + def test_admin_add_page_renders_prose_editor(self): + User = get_user_model() + boss = User.objects.create_superuser("boss", "boss@example.com", "pw") + self.client.force_login(boss) + resp = self.client.get(reverse("admin:website_news_add")) + self.assertEqual(resp.status_code, 200) + body = resp.content.decode() + # The ProseEditorField widget injects its static assets via Media... + self.assertIn("django_prose_editor", body) + # ...and the Figure pickerUrl points at our staff-only upload view. + self.assertIn(reverse("website:news_image_upload"), body) + + +class NormalizeNewsImageStylesTests(DatabaseTestCase): + """The idempotent legacy-image normalizer (command + transform fn).""" + + def test_transform_strips_dimensions_preserves_markup(self): + out = normalize_image_dimensions(LEGACY_HTML) + # All inline dimensions gone (style decls AND width/height attributes). + self.assertNotIn("style=", out) + self.assertNotIn("width=", out) + self.assertNotIn("height=", out) + self.assertNotIn("width:", out) + # The image and the rest of the markup are untouched. + self.assertIn('src="/media/uploads/2024/01/01/CHU.JPG"', out) + self.assertIn('alt="Chu presenting"', out) + self.assertIn('href="https://example.com/x"', out) + self.assertIn("
    ", out) + + def test_transform_is_idempotent(self): + once = normalize_image_dimensions(LEGACY_HTML) + twice = normalize_image_dimensions(once) + self.assertEqual(once, twice) + + def test_command_updates_rows(self): + # Factory create() saves without full_clean, so the raw legacy HTML + # (with inline dimensions) persists, mimicking a pre-migration row. + news = self.make_news_item(content=LEGACY_HTML) + call_command("normalize_news_image_styles") + news.refresh_from_db() + self.assertNotIn("style=", news.content) + self.assertNotIn("width=", news.content) + self.assertIn('src="/media/uploads/2024/01/01/CHU.JPG"', news.content) diff --git a/website/urls.py b/website/urls.py index 794be83d..bede2f68 100644 --- a/website/urls.py +++ b/website/urls.py @@ -91,6 +91,11 @@ # re_path(r'^media/publications/(?P.+)$', views.serve_pdf, name='serve_pdf'), # path('media/publications/', views.serve_pdf, name='serve_pdf'), + # Staff-only image upload popup for the News rich-text editor (issue #1269), + # wired to django-prose-editor's Figure `pickerUrl`. Declared BEFORE the + # `news//` route below so the slug pattern can't swallow it. + path('news/upload-image/', views.news_image_upload, name='news_image_upload'), + # Matches URLs like "news/123/" where 123 is a numeric news ID, and routes it to the `news_item` view. path('news//', views.news_item, name='news_item_by_id'), diff --git a/website/views/__init__.py b/website/views/__init__.py index 8e5915f9..0cbd2219 100644 --- a/website/views/__init__.py +++ b/website/views/__init__.py @@ -2,6 +2,7 @@ from .member import * from .news_listing import * from .news_item import * +from .news_image_upload import * from .people import * from .project import * from .project_listing import * diff --git a/website/views/news_image_upload.py b/website/views/news_image_upload.py new file mode 100644 index 00000000..7945a6d4 --- /dev/null +++ b/website/views/news_image_upload.py @@ -0,0 +1,88 @@ +""" +Staff-only image upload endpoint for the News rich-text editor (issue #1269). + +django-prose-editor's Figure extension opens this URL in a popup as a file +"picker" using the CKEditor 4 filebrowser protocol: the editor appends +``?CKEditorFuncNum=N`` to the URL, and the popup is expected to call +``window.opener.CKEDITOR.tools.callFunction(N, url, {alternative_text})`` once a +file has been chosen. django-prose-editor ships a small shim that registers that +callback, so no actual CKEditor install is involved (see the Figure extension / +``pickerUrl`` config on website/models/news.py). + +This preserves the upload behavior we had under CKEditor: files land in +``MEDIA_ROOT/uploads/YYYY/MM/DD/`` with names produced by +``get_ckeditor_image_filename``, so the filename convention and historical +``/media/uploads/...`` URLs are unchanged. Uploads are validated with +``validate_image_upload`` (extension allowlist + magic-byte sniff that rejects +SVG/HTML), the same defense-in-depth used on other media fields. The callback +returns only the URL (no width/height), so inserted images carry no inline +dimensions and the responsive ``.news-item-content img`` CSS governs sizing. + +Example flow (admin only): + GET /news/upload-image/?CKEditorFuncNum=12 -> renders the upload form + POST /news/upload-image/ (multipart: upload, alt_text, CKEditorFuncNum) + -> saves the file, renders a page that calls the editor callback +""" + +import os + +from django.conf import settings +from django.contrib.admin.views.decorators import staff_member_required +from django.core.exceptions import ValidationError +from django.core.files.storage import default_storage +from django.shortcuts import render +from django.utils import timezone +from django.views.decorators.http import require_http_methods + +from website.utils.fileutils import get_ckeditor_image_filename +from website.utils.upload_validators import validate_image_upload + +__all__ = ["news_image_upload"] + +_TEMPLATE = "website/news_image_picker.html" + + +@staff_member_required +@require_http_methods(["GET", "POST"]) +def news_image_upload(request): + """Render the picker form (GET) or save an upload and call the editor back (POST).""" + # The editor passes CKEditorFuncNum on the initial GET; we round-trip it + # through the form so the success page can call back the right function. + func_num = ( + request.GET.get("CKEditorFuncNum") + or request.POST.get("CKEditorFuncNum") + or "" + ) + + if request.method == "GET": + return render(request, _TEMPLATE, {"func_num": func_num}) + + upload = request.FILES.get("upload") + alt_text = (request.POST.get("alt_text") or "").strip() + + error = None + if not upload: + error = "Please choose an image file to upload." + else: + try: + validate_image_upload(upload) + except ValidationError as exc: + error = " ".join(exc.messages) + + if error: + return render(request, _TEMPLATE, {"func_num": func_num, "error": error}) + + # Mirror the old CKEditor upload layout: media/uploads/YYYY/MM/DD/. + today = timezone.localdate() + name = get_ckeditor_image_filename(upload.name) + rel_path = os.path.join( + "uploads", f"{today:%Y}", f"{today:%m}", f"{today:%d}", name + ) + saved_path = default_storage.save(rel_path, upload) # auto-uniquifies on collision + url = default_storage.url(saved_path) + + return render( + request, + _TEMPLATE, + {"func_num": func_num, "uploaded_url": url, "alt_text": alt_text}, + )