You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

581 rivejä
15 KiB

  1. # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
  2. # License: MIT. See LICENSE
  3. import json
  4. import mimetypes
  5. import os
  6. import re
  7. from functools import lru_cache, wraps
  8. from typing import Dict, Optional
  9. import yaml
  10. from werkzeug.wrappers import Response
  11. import frappe
  12. from frappe import _
  13. from frappe.model.document import Document
  14. from frappe.utils import md_to_html
  15. FRONTMATTER_PATTERN = re.compile(r"^\s*(?:---|\+\+\+)(.*?)(?:---|\+\+\+)\s*(.+)$", re.S | re.M)
  16. H1_TAG_PATTERN = re.compile("<h1>([^<]*)")
  17. IMAGE_TAG_PATTERN = re.compile(r"""<img[^>]*src\s?=\s?['"]([^'"]*)['"]""")
  18. CLEANUP_PATTERN_1 = re.compile(r'[~!@#$%^&*+()<>,."\'\?]')
  19. CLEANUP_PATTERN_2 = re.compile("[:/]")
  20. CLEANUP_PATTERN_3 = re.compile(r"(-)\1+")
  21. def delete_page_cache(path):
  22. cache = frappe.cache()
  23. cache.delete_value("full_index")
  24. groups = ("website_page", "page_context")
  25. if path:
  26. for name in groups:
  27. cache.hdel(name, path)
  28. else:
  29. for name in groups:
  30. cache.delete_key(name)
  31. def find_first_image(html):
  32. m = IMAGE_TAG_PATTERN.finditer(html)
  33. try:
  34. return next(m).groups()[0]
  35. except StopIteration:
  36. return None
  37. def can_cache(no_cache=False):
  38. if frappe.flags.force_website_cache:
  39. return True
  40. if frappe.conf.disable_website_cache or frappe.conf.developer_mode:
  41. return False
  42. if getattr(frappe.local, "no_cache", False):
  43. return False
  44. return not no_cache
  45. def get_comment_list(doctype, name):
  46. comments = frappe.get_all(
  47. "Comment",
  48. fields=["name", "creation", "owner", "comment_email", "comment_by", "content"],
  49. filters=dict(
  50. reference_doctype=doctype,
  51. reference_name=name,
  52. comment_type="Comment",
  53. ),
  54. or_filters=[["owner", "=", frappe.session.user], ["published", "=", 1]],
  55. )
  56. communications = frappe.get_all(
  57. "Communication",
  58. fields=[
  59. "name",
  60. "creation",
  61. "owner",
  62. "owner as comment_email",
  63. "sender_full_name as comment_by",
  64. "content",
  65. "recipients",
  66. ],
  67. filters=dict(
  68. reference_doctype=doctype,
  69. reference_name=name,
  70. ),
  71. or_filters=[
  72. ["recipients", "like", "%{0}%".format(frappe.session.user)],
  73. ["cc", "like", "%{0}%".format(frappe.session.user)],
  74. ["bcc", "like", "%{0}%".format(frappe.session.user)],
  75. ],
  76. )
  77. return sorted((comments + communications), key=lambda comment: comment["creation"], reverse=True)
  78. def get_home_page():
  79. if frappe.local.flags.home_page and not frappe.flags.in_test:
  80. return frappe.local.flags.home_page
  81. def _get_home_page():
  82. home_page = None
  83. # for user
  84. if frappe.session.user != "Guest":
  85. # by role
  86. for role in frappe.get_roles():
  87. home_page = frappe.db.get_value("Role", role, "home_page")
  88. if home_page:
  89. break
  90. # portal default
  91. if not home_page:
  92. home_page = frappe.db.get_single_value("Portal Settings", "default_portal_home")
  93. # by hooks
  94. if not home_page:
  95. home_page = get_home_page_via_hooks()
  96. # global
  97. if not home_page:
  98. home_page = frappe.db.get_single_value("Website Settings", "home_page")
  99. if not home_page:
  100. home_page = "login" if frappe.session.user == "Guest" else "me"
  101. home_page = home_page.strip("/")
  102. return home_page
  103. if frappe.local.dev_server:
  104. # dont return cached homepage in development
  105. return _get_home_page()
  106. return frappe.cache().hget("home_page", frappe.session.user, _get_home_page)
  107. def get_home_page_via_hooks():
  108. home_page = None
  109. home_page_method = frappe.get_hooks("get_website_user_home_page")
  110. if home_page_method:
  111. home_page = frappe.get_attr(home_page_method[-1])(frappe.session.user)
  112. elif frappe.get_hooks("website_user_home_page"):
  113. home_page = frappe.get_hooks("website_user_home_page")[-1]
  114. if not home_page:
  115. role_home_page = frappe.get_hooks("role_home_page")
  116. if role_home_page:
  117. for role in frappe.get_roles():
  118. if role in role_home_page:
  119. home_page = role_home_page[role][-1]
  120. break
  121. if not home_page:
  122. home_page = frappe.get_hooks("home_page")
  123. if home_page:
  124. home_page = home_page[-1]
  125. if home_page:
  126. home_page = home_page.strip("/")
  127. return home_page
  128. def is_signup_disabled():
  129. return frappe.db.get_single_value("Website Settings", "disable_signup", True)
  130. def cleanup_page_name(title: str) -> str:
  131. """make page name from title"""
  132. if not title:
  133. return ""
  134. name = title.lower()
  135. name = CLEANUP_PATTERN_1.sub("", name)
  136. name = CLEANUP_PATTERN_2.sub("-", name)
  137. name = "-".join(name.split())
  138. # replace repeating hyphens
  139. name = CLEANUP_PATTERN_3.sub(r"\1", name)
  140. return name[:140]
  141. def get_shade(color, percent=None):
  142. frappe.msgprint(_("get_shade method has been deprecated."))
  143. return color
  144. def abs_url(path):
  145. """Deconstructs and Reconstructs a URL into an absolute URL or a URL relative from root '/'"""
  146. if not path:
  147. return
  148. if path.startswith("http://") or path.startswith("https://"):
  149. return path
  150. if path.startswith("tel:"):
  151. return path
  152. if path.startswith("data:"):
  153. return path
  154. if not path.startswith("/"):
  155. path = "/" + path
  156. return path
  157. def get_toc(route, url_prefix=None, app=None):
  158. """Insert full index (table of contents) for {index} tag"""
  159. full_index = get_full_index(app=app)
  160. return frappe.get_template("templates/includes/full_index.html").render(
  161. {"full_index": full_index, "url_prefix": url_prefix or "/", "route": route.rstrip("/")}
  162. )
  163. def get_next_link(route, url_prefix=None, app=None):
  164. # insert next link
  165. next_item = None
  166. route = route.rstrip("/")
  167. children_map = get_full_index(app=app)
  168. parent_route = os.path.dirname(route)
  169. children = children_map.get(parent_route, None)
  170. if parent_route and children:
  171. for i, c in enumerate(children):
  172. if c.route == route and i < (len(children) - 1):
  173. next_item = children[i + 1]
  174. next_item.url_prefix = url_prefix or "/"
  175. if next_item:
  176. if next_item.route and next_item.title:
  177. html = (
  178. '<p class="btn-next-wrapper">'
  179. + frappe._("Next")
  180. + ': <a class="btn-next" href="{url_prefix}{route}">{title}</a></p>'
  181. ).format(**next_item)
  182. return html
  183. return ""
  184. def get_full_index(route=None, app=None):
  185. """Returns full index of the website for www upto the n-th level"""
  186. from frappe.website.router import get_pages
  187. if not frappe.local.flags.children_map:
  188. def _build():
  189. children_map = {}
  190. added = []
  191. pages = get_pages(app=app)
  192. # make children map
  193. for route, page_info in pages.items():
  194. parent_route = os.path.dirname(route)
  195. if parent_route not in added:
  196. children_map.setdefault(parent_route, []).append(page_info)
  197. # order as per index if present
  198. for route, children in children_map.items():
  199. if route not in pages:
  200. # no parent (?)
  201. continue
  202. page_info = pages[route]
  203. if page_info.index or ("index" in page_info.template):
  204. new_children = []
  205. page_info.extn = ""
  206. for name in page_info.index or []:
  207. child_route = page_info.route + "/" + name
  208. if child_route in pages:
  209. if child_route not in added:
  210. new_children.append(pages[child_route])
  211. added.append(child_route)
  212. # add remaining pages not in index.txt
  213. _children = sorted(children, key=lambda x: os.path.basename(x.route))
  214. for child_route in _children:
  215. if child_route not in new_children:
  216. if child_route not in added:
  217. new_children.append(child_route)
  218. added.append(child_route)
  219. children_map[route] = new_children
  220. return children_map
  221. children_map = frappe.cache().get_value("website_full_index", _build)
  222. frappe.local.flags.children_map = children_map
  223. return frappe.local.flags.children_map
  224. def extract_title(source, path):
  225. """Returns title from `&lt;!-- title --&gt;` or &lt;h1&gt; or path"""
  226. title = extract_comment_tag(source, "title")
  227. if not title and "<h1>" in source:
  228. # extract title from h1
  229. match = H1_TAG_PATTERN.search(source).group()
  230. title_content = match.strip()[:300]
  231. if "{{" not in title_content:
  232. title = title_content
  233. if not title:
  234. # make title from name
  235. title = (
  236. os.path.basename(
  237. path.rsplit(".",)[
  238. 0
  239. ].rstrip("/")
  240. )
  241. .replace("_", " ")
  242. .replace("-", " ")
  243. .title()
  244. )
  245. return title
  246. def extract_comment_tag(source: str, tag: str):
  247. """Extract custom tags in comments from source.
  248. :param source: raw template source in HTML
  249. :param title: tag to search, example "title"
  250. """
  251. if f"<!-- {tag}:" in source:
  252. return re.search(f"<!-- {tag}:([^>]*) -->", source).group().strip()
  253. return None
  254. def get_html_content_based_on_type(doc, fieldname, content_type):
  255. """
  256. Set content based on content_type
  257. """
  258. content = doc.get(fieldname)
  259. if content_type == "Markdown":
  260. content = md_to_html(doc.get(fieldname + "_md"))
  261. elif content_type == "HTML":
  262. content = doc.get(fieldname + "_html")
  263. if content is None:
  264. content = ""
  265. return content
  266. def clear_cache(path=None):
  267. """Clear website caches
  268. :param path: (optional) for the given path"""
  269. for key in ("website_generator_routes", "website_pages", "website_full_index", "sitemap_routes"):
  270. frappe.cache().delete_value(key)
  271. frappe.cache().delete_value("website_404")
  272. if path:
  273. frappe.cache().hdel("website_redirects", path)
  274. delete_page_cache(path)
  275. else:
  276. clear_sitemap()
  277. frappe.clear_cache("Guest")
  278. for key in (
  279. "portal_menu_items",
  280. "home_page",
  281. "website_route_rules",
  282. "doctypes_with_web_view",
  283. "website_redirects",
  284. "page_context",
  285. "website_page",
  286. ):
  287. frappe.cache().delete_value(key)
  288. for method in frappe.get_hooks("website_clear_cache"):
  289. frappe.get_attr(method)(path)
  290. def clear_website_cache(path=None):
  291. clear_cache(path)
  292. def clear_sitemap():
  293. delete_page_cache("*")
  294. def get_frontmatter(string):
  295. "Reference: https://github.com/jonbeebe/frontmatter"
  296. frontmatter = ""
  297. body = ""
  298. result = FRONTMATTER_PATTERN.search(string)
  299. if result:
  300. frontmatter = result.group(1)
  301. body = result.group(2)
  302. return {
  303. "attributes": yaml.safe_load(frontmatter),
  304. "body": body,
  305. }
  306. def get_sidebar_items(parent_sidebar, basepath):
  307. import frappe.www.list
  308. sidebar_items = []
  309. hooks = frappe.get_hooks("look_for_sidebar_json")
  310. look_for_sidebar_json = hooks[0] if hooks else frappe.flags.look_for_sidebar
  311. if basepath and look_for_sidebar_json:
  312. sidebar_items = get_sidebar_items_from_sidebar_file(basepath, look_for_sidebar_json)
  313. if not sidebar_items and parent_sidebar:
  314. sidebar_items = frappe.get_all(
  315. "Website Sidebar Item",
  316. filters=dict(parent=parent_sidebar),
  317. fields=["title", "route", "`group`"],
  318. order_by="idx asc",
  319. )
  320. if not sidebar_items:
  321. sidebar_items = get_portal_sidebar_items()
  322. return sidebar_items
  323. def get_portal_sidebar_items():
  324. sidebar_items = frappe.cache().hget("portal_menu_items", frappe.session.user)
  325. if sidebar_items is None:
  326. sidebar_items = []
  327. roles = frappe.get_roles()
  328. portal_settings = frappe.get_doc("Portal Settings", "Portal Settings")
  329. def add_items(sidebar_items, items):
  330. for d in items:
  331. if d.get("enabled") and ((not d.get("role")) or d.get("role") in roles):
  332. sidebar_items.append(d.as_dict() if isinstance(d, Document) else d)
  333. if not portal_settings.hide_standard_menu:
  334. add_items(sidebar_items, portal_settings.get("menu"))
  335. if portal_settings.custom_menu:
  336. add_items(sidebar_items, portal_settings.get("custom_menu"))
  337. items_via_hooks = frappe.get_hooks("portal_menu_items")
  338. if items_via_hooks:
  339. for i in items_via_hooks:
  340. i["enabled"] = 1
  341. add_items(sidebar_items, items_via_hooks)
  342. frappe.cache().hset("portal_menu_items", frappe.session.user, sidebar_items)
  343. return sidebar_items
  344. def get_sidebar_items_from_sidebar_file(basepath, look_for_sidebar_json):
  345. sidebar_items = []
  346. sidebar_json_path = get_sidebar_json_path(basepath, look_for_sidebar_json)
  347. if not sidebar_json_path:
  348. return sidebar_items
  349. with open(sidebar_json_path, "r") as sidebarfile:
  350. try:
  351. sidebar_json = sidebarfile.read()
  352. sidebar_items = json.loads(sidebar_json)
  353. except json.decoder.JSONDecodeError:
  354. frappe.throw("Invalid Sidebar JSON at " + sidebar_json_path)
  355. return sidebar_items
  356. def get_sidebar_json_path(path, look_for=False):
  357. """Get _sidebar.json path from directory path
  358. :param path: path of the current diretory
  359. :param look_for: if True, look for _sidebar.json going upwards from given path
  360. :return: _sidebar.json path
  361. """
  362. if os.path.split(path)[1] == "www" or path == "/" or not path:
  363. return ""
  364. sidebar_json_path = os.path.join(path, "_sidebar.json")
  365. if os.path.exists(sidebar_json_path):
  366. return sidebar_json_path
  367. else:
  368. if look_for:
  369. return get_sidebar_json_path(os.path.split(path)[0], look_for)
  370. else:
  371. return ""
  372. def cache_html(func):
  373. @wraps(func)
  374. def cache_html_decorator(*args, **kwargs):
  375. if can_cache():
  376. html = None
  377. page_cache = frappe.cache().hget("website_page", args[0].path)
  378. if page_cache and frappe.local.lang in page_cache:
  379. html = page_cache[frappe.local.lang]
  380. if html:
  381. frappe.local.response.from_cache = True
  382. return html
  383. html = func(*args, **kwargs)
  384. context = args[0].context
  385. if can_cache(context.no_cache):
  386. page_cache = frappe.cache().hget("website_page", args[0].path) or {}
  387. page_cache[frappe.local.lang] = html
  388. frappe.cache().hset("website_page", args[0].path, page_cache)
  389. return html
  390. return cache_html_decorator
  391. def build_response(path, data, http_status_code, headers: Optional[Dict] = None):
  392. # build response
  393. response = Response()
  394. response.data = set_content_type(response, data, path)
  395. response.status_code = http_status_code
  396. response.headers["X-Page-Name"] = path.encode("ascii", errors="xmlcharrefreplace")
  397. response.headers["X-From-Cache"] = frappe.local.response.from_cache or False
  398. add_preload_headers(response)
  399. if headers:
  400. for key, val in headers.items():
  401. response.headers[key] = val.encode("ascii", errors="xmlcharrefreplace")
  402. return response
  403. def set_content_type(response, data, path):
  404. if isinstance(data, dict):
  405. response.mimetype = "application/json"
  406. response.charset = "utf-8"
  407. data = json.dumps(data)
  408. return data
  409. response.mimetype = "text/html"
  410. response.charset = "utf-8"
  411. # ignore paths ending with .com to avoid unnecessary download
  412. # https://bugs.python.org/issue22347
  413. if "." in path and not path.endswith(".com"):
  414. content_type, encoding = mimetypes.guess_type(path)
  415. if content_type:
  416. response.mimetype = content_type
  417. if encoding:
  418. response.charset = encoding
  419. return data
  420. def add_preload_headers(response):
  421. from bs4 import BeautifulSoup, SoupStrainer
  422. try:
  423. preload = []
  424. strainer = SoupStrainer(re.compile("script|link"))
  425. soup = BeautifulSoup(response.data, "lxml", parse_only=strainer)
  426. for elem in soup.find_all("script", src=re.compile(".*")):
  427. preload.append(("script", elem.get("src")))
  428. for elem in soup.find_all("link", rel="stylesheet"):
  429. preload.append(("style", elem.get("href")))
  430. links = []
  431. for _type, link in preload:
  432. links.append("<{}>; rel=preload; as={}".format(link, _type))
  433. if links:
  434. response.headers["Link"] = ",".join(links)
  435. except Exception:
  436. import traceback
  437. traceback.print_exc()
  438. @lru_cache()
  439. def is_binary_file(path):
  440. # ref: https://stackoverflow.com/a/7392391/10309266
  441. textchars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7F})
  442. with open(path, "rb") as f:
  443. content = f.read(1024)
  444. return bool(content.translate(None, textchars))