|
1 | 1 | import json |
2 | 2 | import sys |
| 3 | +from html.parser import HTMLParser |
3 | 4 |
|
| 5 | +import nh3 |
4 | 6 | from ckeditor.widgets import CKEditorWidget |
5 | 7 | from ckeditor_uploader.widgets import CKEditorUploadingWidget |
6 | 8 | from django import forms |
@@ -328,6 +330,249 @@ def __init__(self, form_field=None, attrs=None): |
328 | 330 | super().__init__(form_field, attrs={"class": "input", **(attrs or {})}) |
329 | 331 |
|
330 | 332 |
|
| 333 | +def _parse_richtext_image_id(value): |
| 334 | + if not value or not value.isascii() or not value.isdigit() or value.startswith("0"): |
| 335 | + return None |
| 336 | + try: |
| 337 | + return int(value) |
| 338 | + except ValueError: |
| 339 | + return None |
| 340 | + |
| 341 | + |
| 342 | +class _RichTextImageIdParser(HTMLParser): |
| 343 | + def __init__(self): |
| 344 | + super().__init__(convert_charrefs=True) |
| 345 | + self.image_ids = [] |
| 346 | + |
| 347 | + def handle_starttag(self, tag, attrs): |
| 348 | + if tag.casefold() != "img": |
| 349 | + return |
| 350 | + image_id = _parse_richtext_image_id(dict(attrs).get("data-filer-image-id")) |
| 351 | + if image_id is not None: |
| 352 | + self.image_ids.append(image_id) |
| 353 | + |
| 354 | + |
| 355 | +_RICH_TEXT_ALLOWED_ATTRIBUTES = { |
| 356 | + tag: set(attributes) for tag, attributes in nh3.ALLOWED_ATTRIBUTES.items() |
| 357 | +} |
| 358 | +_RICH_TEXT_ALLOWED_ATTRIBUTES["*"] = {"style"} |
| 359 | +_RICH_TEXT_ALLOWED_ATTRIBUTES.setdefault("img", set()).update( |
| 360 | + {"data-filer-image-id", "loading", "title"} |
| 361 | +) |
| 362 | +_RICH_TEXT_ALLOWED_ATTRIBUTES.setdefault("td", set()).add("colwidth") |
| 363 | +_RICH_TEXT_ALLOWED_ATTRIBUTES.setdefault("th", set()).add("colwidth") |
| 364 | + |
| 365 | + |
| 366 | +def _sanitize_rich_text(value): |
| 367 | + """Sanitize submitted HTML while preserving the attributes Tiptap emits. |
| 368 | +
|
| 369 | + The nh3 defaults provide the security allowlist, but omit ``style``, filer image |
| 370 | + IDs, image metadata, and table ``colwidth``. Those attributes are added above, |
| 371 | + with CSS still restricted to the three properties supported by this editor. |
| 372 | + ``link_rel`` is disabled because Tiptap does not emit ``rel`` without ``target``. |
| 373 | + """ |
| 374 | + if value is None or value == "": |
| 375 | + return value |
| 376 | + return nh3.clean( |
| 377 | + str(value), |
| 378 | + tags=nh3.ALLOWED_TAGS, |
| 379 | + attributes=_RICH_TEXT_ALLOWED_ATTRIBUTES, |
| 380 | + filter_style_properties={"color", "text-align", "width"}, |
| 381 | + link_rel=None, |
| 382 | + ) |
| 383 | + |
| 384 | + |
| 385 | +class SBAdminRichTextWidget(SBAdminTextareaWidget): |
| 386 | + template_name = "sb_admin/widgets/richtext.html" |
| 387 | + button_template = "sb_admin/widgets/includes/richtext/action_button.html" |
| 388 | + toolbar_actions = ( |
| 389 | + { |
| 390 | + "name": "block", |
| 391 | + "template": "sb_admin/widgets/includes/richtext/block.html", |
| 392 | + }, |
| 393 | + {"name": "bold", "label": _("Bold"), "icon": "Text-bold"}, |
| 394 | + {"name": "italic", "label": _("Italic"), "icon": "Text-italic"}, |
| 395 | + { |
| 396 | + "name": "underline", |
| 397 | + "label": _("Underline"), |
| 398 | + "icon": "Text-underline", |
| 399 | + }, |
| 400 | + { |
| 401 | + "name": "color", |
| 402 | + "label": _("Text color"), |
| 403 | + "template": "sb_admin/widgets/includes/richtext/color.html", |
| 404 | + }, |
| 405 | + { |
| 406 | + "name": "align-left", |
| 407 | + "label": _("Align left"), |
| 408 | + "icon": "Align-text-left", |
| 409 | + }, |
| 410 | + { |
| 411 | + "name": "align-center", |
| 412 | + "label": _("Align center"), |
| 413 | + "icon": "Align-text-center", |
| 414 | + }, |
| 415 | + { |
| 416 | + "name": "align-right", |
| 417 | + "label": _("Align right"), |
| 418 | + "icon": "Align-text-right", |
| 419 | + }, |
| 420 | + { |
| 421 | + "name": "align-justify", |
| 422 | + "label": _("Justify"), |
| 423 | + "icon": "Align-text-both", |
| 424 | + }, |
| 425 | + { |
| 426 | + "name": "link", |
| 427 | + "label": _("Link"), |
| 428 | + "icon": "Link", |
| 429 | + "dialog_template": "sb_admin/widgets/includes/richtext/link_dialog.html", |
| 430 | + }, |
| 431 | + { |
| 432 | + "name": "image", |
| 433 | + "label": _("Image"), |
| 434 | + "icon": "Add-picture", |
| 435 | + "template": "sb_admin/widgets/includes/richtext/image.html", |
| 436 | + }, |
| 437 | + { |
| 438 | + "name": "table", |
| 439 | + "label": _("Insert table"), |
| 440 | + "icon": "Insert-table", |
| 441 | + }, |
| 442 | + { |
| 443 | + "name": "bullet-list", |
| 444 | + "label": _("Bulleted list"), |
| 445 | + "icon": "List-two", |
| 446 | + }, |
| 447 | + { |
| 448 | + "name": "ordered-list", |
| 449 | + "label": _("Numbered list"), |
| 450 | + "icon": "List-numbers", |
| 451 | + }, |
| 452 | + {"name": "outdent", "label": _("Outdent"), "icon": "Indent-left"}, |
| 453 | + {"name": "indent", "label": _("Indent"), "icon": "Indent-right"}, |
| 454 | + { |
| 455 | + "name": "clear", |
| 456 | + "label": _("Clear formatting"), |
| 457 | + "icon": "Clear-format", |
| 458 | + }, |
| 459 | + {"name": "source", "label": _("HTML source"), "icon": "Code"}, |
| 460 | + { |
| 461 | + "name": "table-menu", |
| 462 | + "label": _("Table options"), |
| 463 | + "requires": "table", |
| 464 | + "template": "sb_admin/widgets/includes/richtext/table_menu.html", |
| 465 | + }, |
| 466 | + ) |
| 467 | + |
| 468 | + class Media: |
| 469 | + extend = False |
| 470 | + js = [ |
| 471 | + "sb_admin/dist/media_picker.js", |
| 472 | + "sb_admin/dist/richtext.js", |
| 473 | + ] |
| 474 | + |
| 475 | + def __init__( |
| 476 | + self, |
| 477 | + form_field=None, |
| 478 | + attrs=None, |
| 479 | + *, |
| 480 | + is_public=True, |
| 481 | + disabled_actions=(), |
| 482 | + ): |
| 483 | + self.form = None |
| 484 | + self.field_name = None |
| 485 | + self.view = None |
| 486 | + self.request = None |
| 487 | + self.is_public = is_public |
| 488 | + self.disabled_actions = tuple(disabled_actions) |
| 489 | + attrs = attrs or {} |
| 490 | + super().__init__( |
| 491 | + form_field=form_field, |
| 492 | + attrs={ |
| 493 | + "class": ( |
| 494 | + f"input sbadmin-richtext__source hidden " |
| 495 | + f"{attrs.get('class') or ''}" |
| 496 | + ).strip(), |
| 497 | + "rows": 8, |
| 498 | + **{key: value for key, value in attrs.items() if key != "class"}, |
| 499 | + }, |
| 500 | + ) |
| 501 | + |
| 502 | + def init_widget_dynamic(self, form, form_field, field_name, view, request): |
| 503 | + super().init_widget_dynamic(form, form_field, field_name, view, request) |
| 504 | + self.form = form |
| 505 | + self.field_name = field_name |
| 506 | + self.view = view |
| 507 | + self.request = request |
| 508 | + |
| 509 | + def get_request(self): |
| 510 | + return self.request or SBAdminThreadLocalService.get_request() |
| 511 | + |
| 512 | + def value_from_datadict(self, data, files, name): |
| 513 | + return _sanitize_rich_text(super().value_from_datadict(data, files, name)) |
| 514 | + |
| 515 | + @staticmethod |
| 516 | + def image_ids(value): |
| 517 | + parser = _RichTextImageIdParser() |
| 518 | + parser.feed(str(value or "")) |
| 519 | + parser.close() |
| 520 | + return tuple(dict.fromkeys(parser.image_ids)) |
| 521 | + |
| 522 | + def image_items(self, value): |
| 523 | + image_ids = self.image_ids(value) |
| 524 | + request = self.get_request() |
| 525 | + if not image_ids or request is None or not hasattr(request, "request_data"): |
| 526 | + return {} |
| 527 | + queryset = FilerMediaPickerService.accessible_item_queryset( |
| 528 | + request, |
| 529 | + MEDIA_PICKER_TYPE_IMAGE, |
| 530 | + ).filter(pk__in=image_ids) |
| 531 | + if self.is_public is not None: |
| 532 | + queryset = queryset.filter(is_public=self.is_public) |
| 533 | + return { |
| 534 | + str(image.pk): { |
| 535 | + "label": item["name"], |
| 536 | + "thumbnail_url": item["thumbnail_url"], |
| 537 | + "original_url": item["original_url"], |
| 538 | + } |
| 539 | + for image in queryset |
| 540 | + for item in (FilerMediaPickerService.item_data(image),) |
| 541 | + } |
| 542 | + |
| 543 | + def get_toolbar_actions(self): |
| 544 | + disabled_actions = set(self.disabled_actions) |
| 545 | + actions = [] |
| 546 | + for definition in self.toolbar_actions: |
| 547 | + if definition["name"] in disabled_actions: |
| 548 | + continue |
| 549 | + if definition.get("requires") in disabled_actions: |
| 550 | + continue |
| 551 | + action = {"template": self.button_template, **definition} |
| 552 | + if action["name"] == "image": |
| 553 | + action["media_picker_url"] = ( |
| 554 | + f"{reverse('sb_admin:media_picker')}?picker_type=image" |
| 555 | + f"&is_public={str(self.is_public).lower()}" |
| 556 | + ) |
| 557 | + actions.append(action) |
| 558 | + return tuple(actions) |
| 559 | + |
| 560 | + def get_context(self, name, value, attrs): |
| 561 | + context = super().get_context(name, value, attrs) |
| 562 | + widget = context["widget"] |
| 563 | + widget["items"] = self.image_items(value) |
| 564 | + widget["is_readonly"] = bool( |
| 565 | + widget["attrs"].get("readonly") or widget["attrs"].get("disabled") |
| 566 | + ) |
| 567 | + # The textarea is hidden behind Tiptap, so native required validation cannot |
| 568 | + # focus it. Django remains responsible for required-field validation and the |
| 569 | + # surrounding form templates render the resulting bound-field errors. |
| 570 | + widget["attrs"].pop("required", None) |
| 571 | + widget["disabled_actions"] = self.disabled_actions |
| 572 | + widget["toolbar_actions"] = self.get_toolbar_actions() |
| 573 | + return context |
| 574 | + |
| 575 | + |
331 | 576 | class SBAdminEmailInputWidget( |
332 | 577 | SBAdminInputAffixMixin, SBAdminBaseWidget, forms.EmailInput |
333 | 578 | ): |
|
0 commit comments