# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt import re import frappe from crm.api.form import ALLOWED_DOCTYPES, guest_can_select no_cache = 1 # Cap on options a Link renders into the page — a page-weight guard against a huge # target table, not a permission gate. MAX_LINK_OPTIONS = 500 # bare host, optional scheme/port, optional leading "*." wildcard — rejects any # token containing CSP metacharacters like ";" so admin-entered domains can't # inject extra directives into the Content-Security-Policy header ALLOWED_EMBEDDING_DOMAIN_RE = re.compile( r"^(https?://)?(\*\.)?[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$" ) def get_context(context): route = resolve_route() filters = {"route": route, "crm_published": 1, "doc_type": ["in", ALLOWED_DOCTYPES]} name = frappe.db.get_value("Web Form", filters) # let CRM managers preview an unpublished (draft) form; guests only see published is_author = frappe.session.user != "Guest" and bool( set(frappe.get_roles()) & {"System Manager", "Sales Manager"} ) if not name and is_author: name = frappe.db.get_value("Web Form", {"route": route, "doc_type": ["in", ALLOWED_DOCTYPES]}) if not name: raise frappe.DoesNotExistError doc = frappe.get_doc("Web Form", name) set_embedding_headers(doc) context.no_cache = 1 try: context.csrf_token = frappe.sessions.get_csrf_token() except Exception: context.csrf_token = "" context.web_form_name = doc.name # ?embed=1 (set by the iframe snippet) strips the page chrome so the form sits # flush inside the host page instead of showing our own card-on-gray-background context.embed = frappe.form_dict.get("embed") in ("1", "true", "yes") context.draft_preview = not doc.crm_published context.form_title = doc.title context.form_description = doc.introduction_text or "" context.form_route = doc.route context.submit_label = doc.button_label or "Submit" context.success_message = doc.success_message or "Thank you!" context.success_url = doc.success_url or "" context.fields = [ { "fieldname": f.fieldname, # breaks (Section/Column) keep an empty label when blank so unlabeled # sections render no heading; only real fields fall back to fieldname "label": f.label or ("" if f.fieldtype in ("Section Break", "Column Break") else f.fieldname), "fieldtype": f.fieldtype, "options": f.options or "", "reqd": int(f.reqd or 0), "placeholder": f.placeholder or "", "description": f.description or "", # conditional-logic expressions, evaluated client-side (see crm_form.html) "depends_on": f.depends_on or "", "mandatory_depends_on": f.mandatory_depends_on or "", "read_only_depends_on": f.read_only_depends_on or "", } for f in doc.web_form_fields ] context.layout = build_layout(context.fields) # Link fields render as a server-populated dropdown of existing records. Resolve # resolve Link options here (keyed by fieldname) so the template stays presentation-only. context.link_options = { f["fieldname"]: _link_field_options(f["options"]) for f in context.fields if f["fieldtype"] == "Link" and f["options"] } return context def _link_field_options(doctype: str) -> list[dict]: """Existing records of `doctype` as {value, label} dropdown options, scoped to what an anonymous visitor may see — honouring the target's permissions at every level (doctype `select`, row rules, if_owner). An actual guest is already in that context, so we query directly. Only a logged-in author *previewing* the page needs the session temporarily switched to Guest (rather than `get_list(user="Guest")`, which still derives its permission *tier* from the active session and would check the manager for Guest `read`, failing on a select-only target). That switch is destructive — `frappe.set_user()` overwrites `session.sid` with the username and wipes `session.data` — and restoring only the user leaves a corrupted session that gets persisted under the real sid, logging the author out on their next request. So snapshot and restore sid + data as well.""" if not doctype or not frappe.db.exists("DocType", doctype): return [] if not guest_can_select(doctype): return [] meta = frappe.get_meta(doctype) title_field = meta.title_field or "name" fields = ["name"] if title_field == "name" else ["name", title_field] def _query(): return frappe.get_list( doctype, fields=fields, order_by=f"{title_field} asc", limit=MAX_LINK_OPTIONS, ) current_user = frappe.session.user if current_user == "Guest": rows = _query() else: saved_sid = frappe.session.sid saved_data = frappe.session.data try: frappe.set_user("Guest") # nosemgrep — session fully restored in finally rows = _query() finally: frappe.set_user(current_user) # set_user() clobbers sid (→ username) and wipes data; put the real ones # back so the request doesn't persist a corrupted session (see #logout). frappe.session.sid = saved_sid frappe.session.data = saved_data return [{"value": r["name"], "label": r.get(title_field) or r["name"]} for r in rows] def set_embedding_headers(doc): """Allow this page to be embedded as an iframe on the form's allow-listed origins. Emits a Content-Security-Policy `frame-ancestors` header. Modern browsers ignore the default `X-Frame-Options: SAMEORIGIN` (set by nginx) whenever `frame-ancestors` is present, so this is what makes cross-origin embedding work — without it a form can only be embedded on its own site. """ raw_domains = (doc.allowed_embedding_domains or "").split() domains = [d for d in raw_domains if ALLOWED_EMBEDDING_DOMAIN_RE.match(d)] if not domains: return frappe.local.response_headers["Content-Security-Policy"] = "frame-ancestors 'self' " + " ".join(domains) def build_layout(fields): """Group fields into sections -> columns using Section/Column Break rows.""" sections = [] current = {"label": None, "columns": [[]]} for f in fields: ft = f["fieldtype"] if ft == "Section Break": sections.append(current) current = {"label": f.get("label") or None, "columns": [[]]} elif ft == "Column Break": current["columns"].append([]) else: current["columns"][-1].append(f) sections.append(current) return [s for s in sections if s["label"] or any(col for col in s["columns"])] def resolve_route() -> str: """The public slug, from the /crm-form/ path rule (with a path fallback).""" route = (frappe.form_dict.get("route") or "").strip("/") if route and route != "crm-form": return route path = (getattr(frappe.request, "path", "") or "").strip("/") prefix = "crm-form/" return path[len(prefix) :] if path.startswith(prefix) else path