bd-editor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bd_editor/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Secure, configurable rich-text editing for Django."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ default_app_config = "bd_editor.apps.BDEditorConfig"
bd_editor/apps.py ADDED
@@ -0,0 +1,12 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class BDEditorConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "bd_editor"
7
+ verbose_name = "BD-Editor"
8
+
9
+ def ready(self):
10
+ from .conf import validate_settings
11
+
12
+ validate_settings()
bd_editor/conf.py ADDED
@@ -0,0 +1,468 @@
1
+ from collections.abc import Mapping
2
+ from copy import deepcopy
3
+ from importlib.util import find_spec
4
+
5
+ from django.apps import apps
6
+ from django.conf import settings
7
+ from django.core.exceptions import ImproperlyConfigured
8
+
9
+ SAFE_TAGS = {
10
+ "a",
11
+ "b",
12
+ "blockquote",
13
+ "br",
14
+ "caption",
15
+ "code",
16
+ "col",
17
+ "colgroup",
18
+ "dd",
19
+ "del",
20
+ "div",
21
+ "dl",
22
+ "dt",
23
+ "em",
24
+ "figcaption",
25
+ "figure",
26
+ "h1",
27
+ "h2",
28
+ "h3",
29
+ "h4",
30
+ "h5",
31
+ "h6",
32
+ "hr",
33
+ "i",
34
+ "iframe",
35
+ "img",
36
+ "li",
37
+ "ol",
38
+ "p",
39
+ "pre",
40
+ "s",
41
+ "span",
42
+ "strong",
43
+ "sub",
44
+ "sup",
45
+ "table",
46
+ "tbody",
47
+ "td",
48
+ "tfoot",
49
+ "th",
50
+ "thead",
51
+ "tr",
52
+ "u",
53
+ "ul",
54
+ }
55
+
56
+ SAFE_ATTRIBUTES = {
57
+ "a": {"href", "title"},
58
+ "blockquote": {"style"},
59
+ "div": {"style"},
60
+ "h1": {"style"},
61
+ "h2": {"style"},
62
+ "h3": {"style"},
63
+ "h4": {"style"},
64
+ "h5": {"style"},
65
+ "h6": {"style"},
66
+ "iframe": {"src", "title", "width", "height"},
67
+ "img": {"src", "alt", "title", "width", "height"},
68
+ "li": {"style"},
69
+ "p": {"style"},
70
+ "pre": {"style"},
71
+ "span": {"style"},
72
+ "td": {"colspan", "rowspan", "data-colwidth", "style"},
73
+ "th": {"colspan", "rowspan", "data-colwidth", "style"},
74
+ }
75
+
76
+ SAFE_URL_SCHEMES = {"http", "https", "mailto", "tel"}
77
+ SAFE_EMBED_PROVIDERS = {"youtube", "vimeo"}
78
+ SAFE_IMAGE_FORMATS = {"JPEG", "PNG", "WEBP"}
79
+ SAFE_CSS_PROPERTIES = {
80
+ "background-color",
81
+ "border",
82
+ "border-bottom-color",
83
+ "border-bottom-style",
84
+ "border-bottom-width",
85
+ "border-color",
86
+ "border-left-color",
87
+ "border-left-style",
88
+ "border-left-width",
89
+ "border-right-color",
90
+ "border-right-style",
91
+ "border-right-width",
92
+ "border-style",
93
+ "border-top-color",
94
+ "border-top-style",
95
+ "border-top-width",
96
+ "border-width",
97
+ "color",
98
+ "font-family",
99
+ "font-size",
100
+ "font-style",
101
+ "font-weight",
102
+ "line-height",
103
+ "margin",
104
+ "margin-bottom",
105
+ "margin-left",
106
+ "margin-right",
107
+ "margin-top",
108
+ "max-width",
109
+ "padding",
110
+ "padding-bottom",
111
+ "padding-left",
112
+ "padding-right",
113
+ "padding-top",
114
+ "text-align",
115
+ "text-decoration",
116
+ "text-indent",
117
+ "text-transform",
118
+ "vertical-align",
119
+ "white-space",
120
+ }
121
+
122
+ _SMALL_TOOLBAR = {
123
+ "heading": True,
124
+ "bold": True,
125
+ "italic": True,
126
+ "text_color": False,
127
+ "background_color": False,
128
+ "underline": False,
129
+ "strike": False,
130
+ "bullet_list": True,
131
+ "ordered_list": True,
132
+ "blockquote": False,
133
+ "code_block": False,
134
+ "link": True,
135
+ "image": False,
136
+ "embed": False,
137
+ "table": False,
138
+ "source": False,
139
+ "undo": True,
140
+ "redo": True,
141
+ }
142
+
143
+ _STANDARD_TOOLBAR = {
144
+ **_SMALL_TOOLBAR,
145
+ "text_color": True,
146
+ "background_color": True,
147
+ "strike": True,
148
+ "blockquote": True,
149
+ "code_block": True,
150
+ "embed": True,
151
+ "table": True,
152
+ "source": True,
153
+ }
154
+
155
+ _FULL_TOOLBAR = {
156
+ **_STANDARD_TOOLBAR,
157
+ "underline": True,
158
+ "image": True,
159
+ }
160
+
161
+ _TOOLBAR_PRESETS = {
162
+ "small": _SMALL_TOOLBAR,
163
+ "standard": _STANDARD_TOOLBAR,
164
+ "full": _FULL_TOOLBAR,
165
+ }
166
+
167
+ _DEFAULTS = {
168
+ "toolbar_preset": "standard",
169
+ "toolbar": deepcopy(_STANDARD_TOOLBAR),
170
+ "sanitizer": {
171
+ "tags": sorted(SAFE_TAGS),
172
+ "attributes": {tag: sorted(attributes) for tag, attributes in SAFE_ATTRIBUTES.items()},
173
+ "css_properties": sorted(SAFE_CSS_PROPERTIES),
174
+ "url_schemes": ["http", "https", "mailto", "tel"],
175
+ "allow_relative_urls": True,
176
+ },
177
+ "uploads": {
178
+ "enabled": False,
179
+ "max_file_size": 5 * 1024 * 1024,
180
+ "max_image_pixels": 20_000_000,
181
+ "output_format": "keep",
182
+ "allowed_formats": ["JPEG", "PNG", "WEBP"],
183
+ "allow_format_selection": True,
184
+ "allow_quality_selection": True,
185
+ "quality": 85,
186
+ "storage_alias": "default",
187
+ },
188
+ "drafts": {"enabled": False, "ttl_days": 7},
189
+ "embeds": {"providers": ["youtube"]},
190
+ "locale": None,
191
+ }
192
+
193
+ _CONFIG_KEYS = set(_DEFAULTS)
194
+ _TOOLBAR_KEYS = set(_DEFAULTS["toolbar"])
195
+ _SANITIZER_KEYS = set(_DEFAULTS["sanitizer"])
196
+ _UPLOAD_KEYS = set(_DEFAULTS["uploads"])
197
+ _DRAFT_KEYS = set(_DEFAULTS["drafts"])
198
+ _FORBIDDEN_TAGS = {"audio", "embed", "object", "script", "source", "style", "video"}
199
+ _IMAGE_FORMATS = {"JPEG", "PNG", "WEBP"}
200
+ _MAX_UPLOAD_BYTES = 100 * 1024 * 1024
201
+ _MAX_IMAGE_PIXELS = 50_000_000
202
+
203
+
204
+ def _fail(path, message):
205
+ raise ImproperlyConfigured(f"BD_EDITOR {path}: {message}")
206
+
207
+
208
+ def _merge(target, overrides, path):
209
+ if not isinstance(overrides, Mapping):
210
+ _fail(path, "must be a mapping.")
211
+
212
+ for key, value in overrides.items():
213
+ if key not in target:
214
+ _fail(path, f"unknown option {key!r}.")
215
+ current = target[key]
216
+ child_path = f"{path}.{key}"
217
+ if isinstance(current, dict):
218
+ if not isinstance(value, Mapping):
219
+ _fail(child_path, "must be a mapping.")
220
+ _merge(current, value, child_path)
221
+ else:
222
+ target[key] = deepcopy(value)
223
+
224
+
225
+ def _merge_config_layer(config, overrides, path):
226
+ if not isinstance(overrides, Mapping):
227
+ _fail(path, "must be a mapping.")
228
+
229
+ if "toolbar_preset" in overrides:
230
+ preset = overrides["toolbar_preset"]
231
+ if not isinstance(preset, str) or preset not in _TOOLBAR_PRESETS:
232
+ _fail(
233
+ f"{path}.toolbar_preset",
234
+ "must be one of 'small', 'standard', or 'full'.",
235
+ )
236
+ config["toolbar_preset"] = preset
237
+ config["toolbar"] = deepcopy(_TOOLBAR_PRESETS[preset])
238
+
239
+ # A preset establishes the base toolbar for this configuration layer.
240
+ # Explicit toolbar options in the same layer remain the final override.
241
+ remaining = {key: value for key, value in overrides.items() if key != "toolbar_preset"}
242
+ _merge(config, remaining, path)
243
+
244
+
245
+ def _require_list(value, path):
246
+ if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
247
+ _fail(path, "must be a list of strings.")
248
+
249
+
250
+ def _validate_config(config, path):
251
+ if (
252
+ not isinstance(config["toolbar_preset"], str)
253
+ or config["toolbar_preset"] not in _TOOLBAR_PRESETS
254
+ ):
255
+ _fail(
256
+ f"{path}.toolbar_preset",
257
+ "must be one of 'small', 'standard', or 'full'.",
258
+ )
259
+
260
+ toolbar = config["toolbar"]
261
+ if not isinstance(toolbar, Mapping):
262
+ _fail(f"{path}.toolbar", "must be a mapping of toolbar names to booleans.")
263
+ if any(not isinstance(key, str) for key in toolbar):
264
+ _fail(f"{path}.toolbar", "option names must be strings.")
265
+ if set(toolbar) != _TOOLBAR_KEYS:
266
+ missing = sorted(_TOOLBAR_KEYS - set(toolbar))
267
+ unknown = sorted(set(toolbar) - _TOOLBAR_KEYS)
268
+ if missing:
269
+ _fail(f"{path}.toolbar", f"missing options: {', '.join(missing)}.")
270
+ _fail(f"{path}.toolbar", f"unknown options: {', '.join(unknown)}.")
271
+ if any(type(value) is not bool for value in toolbar.values()):
272
+ _fail(f"{path}.toolbar", "every toolbar option must be a boolean.")
273
+
274
+ sanitizer = config["sanitizer"]
275
+ if not isinstance(sanitizer, Mapping) or set(sanitizer) != _SANITIZER_KEYS:
276
+ _fail(f"{path}.sanitizer", "must contain the supported sanitizer options.")
277
+
278
+ _require_list(sanitizer["tags"], f"{path}.sanitizer.tags")
279
+ tags = set(sanitizer["tags"])
280
+ if len(tags) != len(sanitizer["tags"]):
281
+ _fail(f"{path}.sanitizer.tags", "must not contain duplicates.")
282
+ unsafe_tags = tags - SAFE_TAGS
283
+ forbidden_tags = tags & _FORBIDDEN_TAGS
284
+ if unsafe_tags or forbidden_tags:
285
+ invalid = sorted(unsafe_tags | forbidden_tags)
286
+ _fail(f"{path}.sanitizer.tags", f"unsupported or unsafe tags: {', '.join(invalid)}.")
287
+
288
+ attributes = sanitizer["attributes"]
289
+ if not isinstance(attributes, Mapping):
290
+ _fail(f"{path}.sanitizer.attributes", "must be a mapping of tags to attribute lists.")
291
+ for tag, values in attributes.items():
292
+ if tag not in SAFE_ATTRIBUTES:
293
+ _fail(f"{path}.sanitizer.attributes", f"unsupported tag {tag!r}.")
294
+ _require_list(values, f"{path}.sanitizer.attributes.{tag}")
295
+ if not set(values).issubset(SAFE_ATTRIBUTES[tag]):
296
+ invalid = sorted(set(values) - SAFE_ATTRIBUTES[tag])
297
+ _fail(
298
+ f"{path}.sanitizer.attributes.{tag}",
299
+ f"unsupported or unsafe attributes: {', '.join(invalid)}.",
300
+ )
301
+
302
+ _require_list(sanitizer["css_properties"], f"{path}.sanitizer.css_properties")
303
+ if not set(sanitizer["css_properties"]).issubset(SAFE_CSS_PROPERTIES):
304
+ invalid = sorted(set(sanitizer["css_properties"]) - SAFE_CSS_PROPERTIES)
305
+ _fail(
306
+ f"{path}.sanitizer.css_properties",
307
+ f"unsupported or unsafe CSS properties: {', '.join(invalid)}.",
308
+ )
309
+ allowed_css = set(sanitizer["css_properties"])
310
+ span_attributes = set(attributes.get("span", []))
311
+ for toolbar_option, css_property in (
312
+ ("text_color", "color"),
313
+ ("background_color", "background-color"),
314
+ ):
315
+ if toolbar[toolbar_option] and (
316
+ css_property not in allowed_css
317
+ or "span" not in tags
318
+ or "style" not in span_attributes
319
+ ):
320
+ _fail(
321
+ f"{path}.toolbar.{toolbar_option}",
322
+ f"requires the span style attribute and sanitizer.css_properties "
323
+ f"to allow {css_property!r}.",
324
+ )
325
+
326
+ _require_list(sanitizer["url_schemes"], f"{path}.sanitizer.url_schemes")
327
+ if not set(sanitizer["url_schemes"]).issubset(SAFE_URL_SCHEMES):
328
+ invalid = sorted(set(sanitizer["url_schemes"]) - SAFE_URL_SCHEMES)
329
+ _fail(
330
+ f"{path}.sanitizer.url_schemes",
331
+ f"unsupported or unsafe URL schemes: {', '.join(invalid)}.",
332
+ )
333
+ if type(sanitizer["allow_relative_urls"]) is not bool:
334
+ _fail(f"{path}.sanitizer.allow_relative_urls", "must be a boolean.")
335
+
336
+ uploads = config["uploads"]
337
+ if not isinstance(uploads, Mapping) or set(uploads) != _UPLOAD_KEYS:
338
+ _fail(f"{path}.uploads", "must contain the supported upload options.")
339
+ if type(uploads["enabled"]) is not bool:
340
+ _fail(f"{path}.uploads.enabled", "must be a boolean.")
341
+ for key in ("max_file_size", "max_image_pixels"):
342
+ if type(uploads[key]) is not int or uploads[key] <= 0:
343
+ _fail(f"{path}.uploads.{key}", "must be a positive integer.")
344
+ if uploads["max_file_size"] > _MAX_UPLOAD_BYTES:
345
+ _fail(
346
+ f"{path}.uploads.max_file_size",
347
+ f"must not exceed {_MAX_UPLOAD_BYTES} bytes.",
348
+ )
349
+ if uploads["max_image_pixels"] > _MAX_IMAGE_PIXELS:
350
+ _fail(
351
+ f"{path}.uploads.max_image_pixels",
352
+ f"must not exceed {_MAX_IMAGE_PIXELS} pixels.",
353
+ )
354
+ if not isinstance(uploads["output_format"], str) or uploads["output_format"] not in {
355
+ "keep",
356
+ *_IMAGE_FORMATS,
357
+ }:
358
+ _fail(
359
+ f"{path}.uploads.output_format",
360
+ "must be one of 'keep', 'JPEG', 'PNG', or 'WEBP'.",
361
+ )
362
+ if type(uploads["quality"]) is not int or not 1 <= uploads["quality"] <= 100:
363
+ _fail(f"{path}.uploads.quality", "must be an integer from 1 to 100.")
364
+ _require_list(uploads["allowed_formats"], f"{path}.uploads.allowed_formats")
365
+ allowed_formats = set(uploads["allowed_formats"])
366
+ if (
367
+ not allowed_formats
368
+ or len(allowed_formats) != len(uploads["allowed_formats"])
369
+ or not allowed_formats.issubset(_IMAGE_FORMATS)
370
+ ):
371
+ _fail(
372
+ f"{path}.uploads.allowed_formats",
373
+ "must contain unique values from JPEG, PNG, and WEBP.",
374
+ )
375
+ if uploads["output_format"] != "keep" and uploads["output_format"] not in allowed_formats:
376
+ _fail(
377
+ f"{path}.uploads.output_format",
378
+ "must also appear in uploads.allowed_formats.",
379
+ )
380
+ if not isinstance(uploads["storage_alias"], str) or not uploads["storage_alias"].strip():
381
+ _fail(f"{path}.uploads.storage_alias", "must be a non-empty storage alias.")
382
+ if toolbar["image"] and not uploads["enabled"]:
383
+ _fail(f"{path}.toolbar.image", "requires uploads.enabled = True.")
384
+ if uploads["enabled"] and find_spec("PIL") is None:
385
+ _fail(
386
+ f"{path}.uploads.enabled",
387
+ "requires Pillow; install the 'bd-editor[images]' extra.",
388
+ )
389
+ for key in ("allow_format_selection", "allow_quality_selection"):
390
+ if type(uploads[key]) is not bool:
391
+ _fail(f"{path}.uploads.{key}", "must be a boolean.")
392
+
393
+ drafts = config["drafts"]
394
+ if not isinstance(drafts, Mapping) or set(drafts) != _DRAFT_KEYS:
395
+ _fail(f"{path}.drafts", "must contain the supported draft options.")
396
+ if type(drafts["enabled"]) is not bool:
397
+ _fail(f"{path}.drafts.enabled", "must be a boolean.")
398
+ if type(drafts["ttl_days"]) is not int or not 1 <= drafts["ttl_days"] <= 90:
399
+ _fail(f"{path}.drafts.ttl_days", "must be an integer from 1 to 90.")
400
+
401
+ embeds = config["embeds"]
402
+ if not isinstance(embeds, Mapping) or set(embeds) != {"providers"}:
403
+ _fail(f"{path}.embeds", "must contain only the providers option.")
404
+ _require_list(embeds["providers"], f"{path}.embeds.providers")
405
+ providers = set(embeds["providers"])
406
+ if (
407
+ len(providers) != len(embeds["providers"])
408
+ or not providers.issubset(SAFE_EMBED_PROVIDERS)
409
+ ):
410
+ invalid = sorted(providers - SAFE_EMBED_PROVIDERS)
411
+ message = (
412
+ f"unsupported providers: {', '.join(invalid)}."
413
+ if invalid
414
+ else "must not contain duplicates."
415
+ )
416
+ _fail(f"{path}.embeds.providers", message)
417
+ if toolbar["embed"] and not providers:
418
+ _fail(f"{path}.toolbar.embed", "requires at least one configured embed provider.")
419
+
420
+ if config["locale"] is not None and (
421
+ not isinstance(config["locale"], str) or config["locale"] not in {"en", "tr"}
422
+ ):
423
+ _fail(f"{path}.locale", "must be None, 'en', or 'tr'.")
424
+
425
+
426
+ def get_config(app_label=None):
427
+ """Return a validated, isolated settings snapshot for one Django app."""
428
+ raw_settings = getattr(settings, "BD_EDITOR", {})
429
+ if not isinstance(raw_settings, Mapping):
430
+ _fail("settings", "must be a mapping.")
431
+ if any(not isinstance(key, str) for key in raw_settings):
432
+ _fail("settings", "top-level option names must be strings.")
433
+ unknown_root_options = set(raw_settings) - {"defaults", "apps"}
434
+ if unknown_root_options:
435
+ _fail("settings", f"unknown top-level options: {', '.join(sorted(unknown_root_options))}.")
436
+
437
+ config = deepcopy(_DEFAULTS)
438
+ _merge_config_layer(config, raw_settings.get("defaults", {}), "defaults")
439
+
440
+ app_configs = raw_settings.get("apps", {})
441
+ if not isinstance(app_configs, Mapping):
442
+ _fail("apps", "must be a mapping of Django app labels to option mappings.")
443
+ if app_label is not None:
444
+ if not isinstance(app_label, str):
445
+ _fail("app_label", "must be a string or None.")
446
+ app_overrides = app_configs.get(app_label, {})
447
+ _merge_config_layer(config, app_overrides, f"apps.{app_label}")
448
+
449
+ scope = f"apps.{app_label}" if app_label in app_configs else "defaults"
450
+ _validate_config(config, scope)
451
+ return config
452
+
453
+
454
+ def validate_settings():
455
+ """Validate global options and every app-specific configuration at startup."""
456
+ raw_settings = getattr(settings, "BD_EDITOR", {})
457
+ get_config()
458
+ app_configs = raw_settings.get("apps", {})
459
+ for app_label in app_configs:
460
+ if not isinstance(app_label, str):
461
+ _fail("apps", "every key must be a Django app label string.")
462
+ try:
463
+ apps.get_app_config(app_label)
464
+ except LookupError as error:
465
+ raise ImproperlyConfigured(
466
+ f"BD_EDITOR apps.{app_label}: no installed Django app has this label."
467
+ ) from error
468
+ get_config(app_label)
bd_editor/embeds.py ADDED
@@ -0,0 +1,61 @@
1
+ import re
2
+ from urllib.parse import parse_qs, urlsplit
3
+
4
+ _YOUTUBE_ID = re.compile(r"^[A-Za-z0-9_-]{11}$")
5
+ _VIMEO_ID = re.compile(r"^\d{1,12}$")
6
+
7
+
8
+ def canonicalize_embed_url(value, providers):
9
+ """Return a canonical HTTPS embed URL for a supported video URL."""
10
+ if not isinstance(value, str) or len(value) > 2048:
11
+ return None
12
+
13
+ try:
14
+ parsed = urlsplit(value.strip())
15
+ port = parsed.port
16
+ except ValueError:
17
+ return None
18
+
19
+ if (
20
+ parsed.scheme.lower() not in {"http", "https"}
21
+ or parsed.username
22
+ or parsed.password
23
+ or port is not None
24
+ or not parsed.hostname
25
+ ):
26
+ return None
27
+
28
+ host = parsed.hostname.lower()
29
+ path_parts = [part for part in parsed.path.split("/") if part]
30
+ enabled = set(providers)
31
+
32
+ # Exact host matching prevents attacker-controlled lookalike domains from
33
+ # passing checks intended for a trusted provider.
34
+ if "youtube" in enabled and host in {
35
+ "youtube.com",
36
+ "www.youtube.com",
37
+ "m.youtube.com",
38
+ "youtu.be",
39
+ "www.youtube-nocookie.com",
40
+ }:
41
+ video_id = None
42
+ if host in {"youtu.be"} and path_parts:
43
+ video_id = path_parts[0]
44
+ elif path_parts and path_parts[0] == "watch":
45
+ video_id = parse_qs(parsed.query).get("v", [None])[0]
46
+ elif len(path_parts) >= 2 and path_parts[0] in {"embed", "shorts", "live"}:
47
+ video_id = path_parts[1]
48
+ if video_id and _YOUTUBE_ID.fullmatch(video_id):
49
+ return f"https://www.youtube-nocookie.com/embed/{video_id}"
50
+
51
+ if "vimeo" in enabled and host in {"vimeo.com", "www.vimeo.com", "player.vimeo.com"}:
52
+ if host == "player.vimeo.com" and len(path_parts) == 2 and path_parts[0] == "video":
53
+ video_id = path_parts[1]
54
+ elif host != "player.vimeo.com" and len(path_parts) == 1:
55
+ video_id = path_parts[0]
56
+ else:
57
+ video_id = None
58
+ if video_id and _VIMEO_ID.fullmatch(video_id):
59
+ return f"https://player.vimeo.com/video/{video_id}"
60
+
61
+ return None
bd_editor/fields.py ADDED
@@ -0,0 +1,55 @@
1
+ from django.db import models
2
+
3
+ from .sanitizer import sanitize_html
4
+
5
+
6
+ class RichTextField(models.TextField):
7
+ """A TextField that sanitizes HTML on Django-managed reads and writes."""
8
+
9
+ description = "Sanitized rich-text HTML"
10
+
11
+ def _app_label(self):
12
+ return self.model._meta.app_label if self.model is not None else None
13
+
14
+ def _model_label(self):
15
+ return self.model._meta.label_lower if self.model is not None else None
16
+
17
+ def to_python(self, value):
18
+ converted = super().to_python(value)
19
+ if converted is None:
20
+ return None
21
+ return sanitize_html(converted, app_label=self._app_label())
22
+
23
+ def from_db_value(self, value, expression, connection):
24
+ if value is None:
25
+ return None
26
+ return sanitize_html(value, app_label=self._app_label())
27
+
28
+ def get_prep_value(self, value):
29
+ # QuerySet.update() and bulk operations use field preparation without
30
+ # consistently passing through model save hooks, so sanitize here too.
31
+ converted = super().get_prep_value(value)
32
+ if converted is None:
33
+ return None
34
+ return sanitize_html(converted, app_label=self._app_label())
35
+
36
+ def pre_save(self, model_instance, add):
37
+ value = getattr(model_instance, self.attname)
38
+ if value is not None:
39
+ value = super().get_prep_value(value)
40
+ value = sanitize_html(value, app_label=self._app_label())
41
+ setattr(model_instance, self.attname, value)
42
+ return value
43
+
44
+ def formfield(self, **kwargs):
45
+ from .forms import RichTextFormField
46
+ from .widgets import BDRichTextWidget
47
+
48
+ kwargs.setdefault("form_class", RichTextFormField)
49
+ kwargs.setdefault("app_label", self._app_label())
50
+ kwargs.setdefault("model_label", self._model_label())
51
+ kwargs["widget"] = BDRichTextWidget(
52
+ app_label=self._app_label(),
53
+ model_label=self._model_label(),
54
+ )
55
+ return super().formfield(**kwargs)
bd_editor/forms.py ADDED
@@ -0,0 +1,23 @@
1
+ from django import forms
2
+
3
+ from .sanitizer import sanitize_html
4
+ from .widgets import BDRichTextWidget
5
+
6
+
7
+ class RichTextFormField(forms.CharField):
8
+ """A CharField that sanitizes the submitted rich-text fragment."""
9
+
10
+ def __init__(self, *args, app_label=None, model_label=None, **kwargs):
11
+ self.app_label = app_label
12
+ self.model_label = model_label
13
+ kwargs.setdefault(
14
+ "widget",
15
+ BDRichTextWidget(app_label=app_label, model_label=model_label),
16
+ )
17
+ super().__init__(*args, **kwargs)
18
+
19
+ def clean(self, value):
20
+ cleaned = super().clean(value)
21
+ if cleaned in self.empty_values:
22
+ return cleaned
23
+ return sanitize_html(cleaned, app_label=self.app_label)
@@ -0,0 +1,6 @@
1
+ msgid ""
2
+ msgstr ""
3
+ "Content-Type: text/plain; charset=UTF-8\n"
4
+ "Language: en\n"
5
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
6
+