Files

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)