"""پاک‌سازی ورودی‌های غنی در زمان ذخیره. بدنه مقاله‌ها سند 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)