Skip to content

fix: Support django CMS 5 data bridge for text-enabled plugins #87

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 15, 2025
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
2 changes: 1 addition & 1 deletion djangocms_text/cms_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ def get_plugins(self, obj=None):
page=page,
)
child_plugins = (get_plugin(name) for name in child_plugin_types)
template = getattr(self.page, "template", None)
template = getattr(page, "template", None)

modules = get_placeholder_conf("plugin_modules", plugin.placeholder.slot, template, default={})
names = get_placeholder_conf("plugin_labels", plugin.placeholder.slot, template, default={})
Expand Down
2 changes: 1 addition & 1 deletion djangocms_text/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def __init__(self, *args, **kwargs):
def clean(self, value):
value = super().clean(value)
value = render_dynamic_attributes(value, admin_objects=False, remove_attr=False)
clean_value = clean_html(value, full=False)
clean_value = clean_html(value)

# We `mark_safe` here (as well as in the correct places) because Django
# Parler cache's the value directly from the in-memory object as it
Expand Down
13 changes: 7 additions & 6 deletions djangocms_text/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
#: An instance of NH3Parser with the default configuration for CMS text content.


def clean_html(data: str, full: bool = False, cleaner: NH3Parser = None) -> str:
def clean_html(data: str, full: Optional[bool] = None, cleaner: NH3Parser = None) -> str:
"""
Cleans HTML from XSS vulnerabilities using nh3
If full is False, only the contents inside <body> will be returned (without
Expand All @@ -105,11 +105,12 @@
if settings.TEXT_HTML_SANITIZE is False:
return data

warnings.warn(
"full argument is deprecated and will be removed",
category=DeprecationWarning,
stacklevel=2,
)
if full is not None:
warnings.warn(

Check warning on line 109 in djangocms_text/html.py

View check run for this annotation

Codecov / codecov/patch

djangocms_text/html.py#L109

Added line #L109 was not covered by tests
"full argument is deprecated and will be removed",
category=DeprecationWarning,
stacklevel=2,
)
cleaner = cleaner or cms_parser
return nh3.clean(data, **cleaner())

Expand Down
2 changes: 1 addition & 1 deletion djangocms_text/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def save(self, *args, **kwargs):
super().save(*args, **kwargs)
body = self.body
body = extract_images(body, self)
body = clean_html(body, full=False)
body = clean_html(body)
if settings.TEXT_AUTO_HYPHENATE:
try:
body = hyphenate(body, language=self.language)
Expand Down
60 changes: 32 additions & 28 deletions private/js/cms.editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -449,24 +449,13 @@ class CMSEditor {
}

}
const script = dom.querySelector('script#data-bridge');
el.dataset.changed = 'false';
if (script && script.textContent.length > 2) {
this.CMS.API.Helpers.dataBridge = JSON.parse(script.textContent);
} else {
const regex1 = /^\s*Window\.CMS\.API\.Helpers\.dataBridge\s=\s(.*?);$/gmu.exec(body);
const regex2 = /^\s*Window\.CMS\.API\.Helpers\.dataBridge\.structure\s=\s(.*?);$/gmu.exec(body);
if (regex1 && regex2 && this.CMS) {
this.CMS.API.Helpers.dataBridge = JSON.parse(regex1[1]);
this.CMS.API.Helpers.dataBridge.structure = JSON.parse(regex2[1]);
} else {
// No databridge found: reload
this.CMS.API.Helpers.reloadBrowser('REFRESH_PAGE');
return;
}
this.processDataBridge(dom);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): dataBridge fallback sets empty object, preventing reload

Assigning {} makes dataBridge truthy, so the if (!dataBridge) check never fires. Return a success flag or set dataBridge to null/undefined on failure to allow the reload path to run.

if (!this.CMS.API.Helpers.dataBridge) {
// No databridge found
this.CMS.API.Helpers.reloadBrowser('REFRESH_PAGE');
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Undefined variable body in processDataBridge

Pass the HTML string (e.g., dom.innerHTML) into processDataBridge or rename the variable so that body is defined.

}
// Additional content for the page disrupts inline editing and needs to be removed
delete this.CMS.API.Helpers.dataBridge.structure?.content;

if (this.CMS.settings.version.startsWith('3.')) {
/* Reflect dirty flag in django CMS < 4 */
Expand Down Expand Up @@ -497,6 +486,27 @@ class CMSEditor {
}
}

processDataBridge(dom) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring the dataBridge extraction logic into a single helper function that handles all cases, removes duplication, and restores immediate reload on failure.

Here’s one way to collapse the three “find‐then‐fallback” branches, kill the duplicated regex, remove the stray logs, and restore the original “reload on missing bridge” behavior—all while keeping every code‐path intact:

// call site
el.dataset.changed = 'false';
const dataBridge = this._fetchDataBridge(dom);
if (!dataBridge) {
  this.CMS.API.Helpers.reloadBrowser('REFRESH_PAGE');
  return;
}
delete dataBridge.structure?.content;
this.CMS.API.Helpers.dataBridge = dataBridge;
// new helper
_fetchDataBridge(dom, body = dom.innerHTML) {
  // 1) inline JSON <script id="data-bridge">…</script>
  const script = dom.querySelector('script#data-bridge');
  const txt    = script?.textContent?.trim();
  if (txt) {
    return JSON.parse(txt);
  }

  // 2) legacy JS assignments in page HTML
  const rx1 = /^\s*Window\.CMS\.API\.Helpers\.dataBridge\s*=\s*(.*?);/gm.exec(body);
  const rx2 = /^\s*Window\.CMS\.API\.Helpers\.dataBridge\.structure\s*=\s*(.*?);/gm.exec(body);
  if (rx1 && rx2) {
    const bridge = JSON.parse(rx1[1]);
    bridge.structure = JSON.parse(rx2[1]);
    return bridge;
  }

  // 3) nothing found
  return null;
}

—This

  • inlines your two regexes into one fall-through block
  • returns null on failure so the caller can reload immediately
  • removes all console.log noise
  • keeps the delete structure.content step right after you’ve parsed
  • never relies on the mysterious undefined body—defaults to dom.innerHTML but can be overridden (e.g. in the form callback)

const script = dom.querySelector('script#data-bridge');

if (script && script.textContent.length > 2) {
this.CMS.API.Helpers.dataBridge = JSON.parse(script.textContent);
} else {
const regex1 = /^\s*Window\.CMS\.API\.Helpers\.dataBridge\s=\s(.*?);$/gmu.exec(dom.innerHTML);
const regex2 = /^\s*Window\.CMS\.API\.Helpers\.dataBridge\.structure\s=\s(.*?);$/gmu.exec(dom.innerHTML);

if (regex1 && regex2 && this.CMS) {
this.CMS.API.Helpers.dataBridge = JSON.parse(regex1[1]);
this.CMS.API.Helpers.dataBridge.structure = JSON.parse(regex2[1]);
} else {
// No databridge found
this.CMS.API.Helpers.dataBridge = null;
}
}
// Additional content for the page disrupts inline editing and needs to be removed
delete this.CMS.API.Helpers.dataBridge.structure?.content;
}

// CMS Editor: addPluginForm
// Get form for a new child plugin
addPluginForm (plugin_type, iframe, el , onLoad, onSave) {
Expand Down Expand Up @@ -554,19 +564,13 @@ class CMSEditor {
el.dataset.changed = 'true';
// Hook into the django CMS dataBridge to get the details of the newly created or saved
// plugin. For new plugins we need their id to get the content.

if (!this.CMS.API.Helpers.dataBridge) {
// The dataBridge sets a timer, so typically it will not yet be present
setTimeout(() => {
// Needed to update StructureBoard
if (onSave) {
onSave(el, form, this.CMS.API.Helpers.dataBridge);
}
}, 100);
} else {
// Needed to update StructureBoard
if (onSave) {
onSave(el, form, this.CMS.API.Helpers.dataBridge);
}
this.processDataBridge(form);
}
// Needed to update StructureBoard
if (onSave && this.CMS.API.Helpers.dataBridge) {
onSave(el, form, this.CMS.API.Helpers.dataBridge);
}
// Do callback
} else if (onLoad) {
Expand Down
12 changes: 1 addition & 11 deletions tests/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ def test_default_tag_removal(self):
settings.TEXT_ADDITIONAL_ATTRIBUTES = {}
text = html.clean_html(
'<iframe src="rtmp://testurl.com/"></iframe>',
full=False,
cleaner=NH3Parser(),
)
self.assertNotIn("iframe", NH3Parser().ALLOWED_TAGS)
Expand All @@ -65,7 +64,6 @@ def test_default_tag_removal(self):
def test_custom_tag_enabled(self):
text = html.clean_html(
'<iframe src="https://testurl.com/"></iframe>',
full=False,
cleaner=NH3Parser(
additional_attributes={"iframe": {"src"}},
),
Expand All @@ -78,7 +76,6 @@ def test_custom_tag_enabled(self):
def test_default_attribute_escaping(self):
text = html.clean_html(
'<span test-attr="2">foo</span>',
full=False,
cleaner=NH3Parser(),
)
self.assertEqual(
Expand All @@ -89,7 +86,6 @@ def test_default_attribute_escaping(self):
def test_custom_attribute_enabled(self):
text = html.clean_html(
'<span test-attr="2">foo</span>',
full=False,
cleaner=NH3Parser(
additional_attributes={
"span": {"test-attr"},
Expand All @@ -105,14 +101,13 @@ def test_default_protocol_removal(self):
settings.TEXT_ADDITIONAL_PROTOCOLS = []
text = html.clean_html(
'<source src="rtmp://testurl.com/">',
full=False,
cleaner=NH3Parser(),
)
self.assertEqual("<source>", text)

def test_custom_protocol_enabled(self):
settings.TEXT_ADDITIONAL_PROTOCOLS = ["rtmp"]
text = html.clean_html('<source src="rtmp://testurl.com/">', full=False, cleaner=NH3Parser())
text = html.clean_html('<source src="rtmp://testurl.com/">', cleaner=NH3Parser())
self.assertEqual('<source src="rtmp://testurl.com/">', text)

def test_clean_html_with_sanitize_enabled(self):
Expand All @@ -122,7 +117,6 @@ def test_clean_html_with_sanitize_enabled(self):
original = '<span test-attr="2">foo</span>'
cleaned = html.clean_html(
original,
full=False,
)
try:
self.assertHTMLEqual("<span>foo</span>", cleaned)
Expand All @@ -136,7 +130,6 @@ def test_clean_html_with_sanitize_disabled(self):
original = '<span test-attr="2" onclick="alert();">foo</span>'
cleaned = html.clean_html(
original,
full=False,
)
try:
self.assertHTMLEqual(original, cleaned)
Expand All @@ -147,23 +140,20 @@ def test_clean_html_preserves_aria_attributes(self):
original = '<span aria-label="foo">foo</span>'
cleaned = html.clean_html(
original,
full=False,
)
self.assertHTMLEqual(original, cleaned)

def test_clean_html_preserves_data_attributes(self):
original = '<span data-test-attr="foo">foo</span>'
cleaned = html.clean_html(
original,
full=False,
)
self.assertHTMLEqual(original, cleaned)

def test_clean_html_preserves_role_attribute(self):
original = '<span role="button">foo</span>'
cleaned = html.clean_html(
original,
full=False,
)
self.assertHTMLEqual(original, cleaned)

Expand Down
1 change: 0 additions & 1 deletion tests/test_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ def test_sub_plugin_config(self):
body="some text",
)
endpoint = self.get_change_plugin_uri(plugin)

with self.login_user_context(self.super_user):
response = self.client.get(endpoint)
self.assertContains(response, '"group": "Extra"')
Expand Down
Loading