136 lines
5.4 KiB
Python
136 lines
5.4 KiB
Python
"""پاکسازی ورودیهای غنی در زمان ذخیره.
|
|
|
|
بدنه مقالهها سند TipTap (ProseMirror) است و فرانتاند همان را با
|
|
renderToHTMLString به HTML تبدیل و مستقیم تزریق میکند؛ متن گرهها در آن
|
|
مسیر escape میشود، اما آدرسهای href/src عیناً وارد HTML میشوند — پس
|
|
اسکیمهای خطرناک (javascript: و data: و ...) باید در سمت سرور حذف شوند.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
# اسکیمهای مجاز برای href/src — مسیرهای نسبی و لنگر هم بیاسکیماند و میگذرند
|
|
SAFE_URL_SCHEMES = {"http", "https", "mailto", "tel"}
|
|
URL_ATTRS = ("href", "src")
|
|
|
|
|
|
def _clean_url(value: Any) -> str | None:
|
|
"""آدرس امن را برمیگرداند؛ در غیر این صورت None (مشخصه حذف میشود)."""
|
|
if not isinstance(value, str):
|
|
return value if value is not None else None
|
|
stripped = value.strip().replace("\n", "").replace("\r", "").replace("\t", "")
|
|
if not stripped:
|
|
return None
|
|
parsed = urlparse(stripped)
|
|
if parsed.scheme and parsed.scheme.lower() not in SAFE_URL_SCHEMES:
|
|
return None
|
|
return stripped
|
|
|
|
|
|
def _is_junk_paragraph(node: Any) -> bool:
|
|
"""پاراگراف خالی یا فقط نویسهٔ خنثی (nbsp/فاصله) — داخل نقلقول حذف میشود."""
|
|
if not isinstance(node, dict) or node.get("type") != "paragraph":
|
|
return False
|
|
content = node.get("content") or []
|
|
if not content:
|
|
return True
|
|
return all(
|
|
isinstance(item, dict)
|
|
and item.get("type") == "text"
|
|
and not str(item.get("text", "")).replace("\xa0", " ").strip()
|
|
for item in content
|
|
)
|
|
|
|
|
|
def _lift_lists_flat(node: Any) -> list[Any]:
|
|
"""گره را به دنبالهای از گرههای نرمال تبدیل میکند — نقلقولهای داخل
|
|
آیتم فهرست به سطح خود فهرست میآیند تا خط مسی همهٔ نقلقولها یکخط شود."""
|
|
if not isinstance(node, dict):
|
|
return [node]
|
|
flat_content: list[Any] = []
|
|
for child in node.get("content") or []:
|
|
flat_content.extend(_lift_lists_flat(child))
|
|
node["content"] = flat_content
|
|
|
|
if node.get("type") not in ("bulletList", "orderedList"):
|
|
return [node]
|
|
|
|
rebuilt: list[Any] = []
|
|
pending_items: list[Any] = []
|
|
for li in node.get("content") or []:
|
|
if not isinstance(li, dict) or li.get("type") != "listItem":
|
|
pending_items.append(li)
|
|
continue
|
|
parts: list[list[Any]] = [[]]
|
|
for child in li.get("content") or []:
|
|
if isinstance(child, dict) and child.get("type") == "blockquote":
|
|
parts.append(child)
|
|
parts.append([])
|
|
else:
|
|
parts[-1].append(child)
|
|
if len(parts) == 1:
|
|
pending_items.append(li)
|
|
continue
|
|
if parts[0]:
|
|
li["content"] = parts[0]
|
|
pending_items.append(li)
|
|
for part in parts[1:]:
|
|
if isinstance(part, dict) and part.get("type") == "blockquote":
|
|
if pending_items:
|
|
rebuilt.append({**node, "content": pending_items})
|
|
pending_items = []
|
|
rebuilt.append(part)
|
|
else:
|
|
pending_items.extend(part)
|
|
if pending_items:
|
|
rebuilt.append({**node, "content": pending_items})
|
|
return rebuilt
|
|
|
|
|
|
def _lift_doc(doc: Any) -> Any:
|
|
lifted = _lift_lists_flat(doc)
|
|
if len(lifted) == 1 and isinstance(lifted[0], dict) and lifted[0].get("type") == "doc":
|
|
return lifted[0]
|
|
content: list[Any] = []
|
|
for part in lifted:
|
|
if isinstance(part, dict) and part.get("type") == "doc":
|
|
content.extend(part.get("content") or [])
|
|
else:
|
|
content.append(part)
|
|
return {"type": "doc", "content": content}
|
|
|
|
|
|
def _walk(node: Any) -> Any:
|
|
if isinstance(node, list):
|
|
return [_walk(item) for item in node]
|
|
if not isinstance(node, dict):
|
|
return node
|
|
cleaned: dict[str, Any] = {}
|
|
for key, value in node.items():
|
|
if key == "attrs" and isinstance(value, dict):
|
|
attrs = dict(value)
|
|
for attr in URL_ATTRS:
|
|
if attr in attrs:
|
|
cleaned_url = _clean_url(attrs[attr])
|
|
if cleaned_url is None:
|
|
attrs.pop(attr, None)
|
|
else:
|
|
attrs[attr] = cleaned_url
|
|
cleaned[key] = attrs
|
|
else:
|
|
cleaned[key] = _walk(value)
|
|
# داخل نقلقول: پاراگرافهای خالی/نویسهٔ خنثی حذف — وگرنه خط خالی اضافه رندر میشود
|
|
if cleaned.get("type") == "blockquote" and isinstance(cleaned.get("content"), list):
|
|
cleaned["content"] = [c for c in cleaned["content"] if not _is_junk_paragraph(c)]
|
|
return cleaned
|
|
|
|
|
|
def sanitize_doc(doc: Any) -> Any:
|
|
"""سند TipTap را درجا پاک میکند (سایر ساختارها دستنخورده برمیگردند)."""
|
|
if not isinstance(doc, dict):
|
|
return doc
|
|
walked = _walk(doc)
|
|
return _lift_doc(walked)
|