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 +5 -0
- bd_editor/apps.py +12 -0
- bd_editor/conf.py +468 -0
- bd_editor/embeds.py +61 -0
- bd_editor/fields.py +55 -0
- bd_editor/forms.py +23 -0
- bd_editor/locale/en/LC_MESSAGES/django.mo +0 -0
- bd_editor/locale/en/LC_MESSAGES/django.po +6 -0
- bd_editor/locale/en.json +91 -0
- bd_editor/locale/tr/LC_MESSAGES/django.mo +0 -0
- bd_editor/locale/tr/LC_MESSAGES/django.po +71 -0
- bd_editor/locale/tr.json +91 -0
- bd_editor/sanitizer.py +131 -0
- bd_editor/static/bd_editor/THIRD_PARTY_NOTICES.txt +1143 -0
- bd_editor/static/bd_editor/editor.css +738 -0
- bd_editor/static/bd_editor/editor.js +566 -0
- bd_editor/templates/bd_editor/widget.html +122 -0
- bd_editor/templatetags/__init__.py +0 -0
- bd_editor/templatetags/bd_editor_tags.py +24 -0
- bd_editor/uploads.py +114 -0
- bd_editor/urls.py +7 -0
- bd_editor/views.py +155 -0
- bd_editor/widgets.py +55 -0
- bd_editor-0.1.0.dist-info/METADATA +101 -0
- bd_editor-0.1.0.dist-info/RECORD +28 -0
- bd_editor-0.1.0.dist-info/WHEEL +5 -0
- bd_editor-0.1.0.dist-info/licenses/LICENSE +21 -0
- bd_editor-0.1.0.dist-info/top_level.txt +1 -0
bd_editor/__init__.py
ADDED
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)
|
|
Binary file
|