Guide visibility is now scoped by PORTAL first and role second: - the buyer portal lists shared and buyer guides, even for an admin, because it is the buyer portal; admin guides never surface there - the ops portal lists shared and ops guides, plus admin guides for an admin/superuser - the detail view names its context object, so guide pages get a real browser title instead of a blank one Link rewriting, so one page can serve both the wiki and the in-app help centre: - absolute /en/user-guides/... links become the in-app help path - other absolute /en/... links point at the wiki itself; the app hosts serve no /en/ routes, so leaving them untouched was a 404 for whoever clicked - relative links keep their existing behaviour Tests cover the new rewriting and the portal scoping rules. Also adds docs/USER_GUIDES_VIEWER_SPEC.md, the spec this viewer was built to.
112 lines
3.2 KiB
Python
112 lines
3.2 KiB
Python
import posixpath
|
|
import re
|
|
from urllib.parse import urlparse
|
|
|
|
import bleach
|
|
from django.conf import settings
|
|
from markdown import markdown
|
|
|
|
ALLOWED_TAGS = [
|
|
"p",
|
|
"br",
|
|
"strong",
|
|
"em",
|
|
"ul",
|
|
"ol",
|
|
"li",
|
|
"table",
|
|
"thead",
|
|
"tbody",
|
|
"tr",
|
|
"th",
|
|
"td",
|
|
"h1",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"a",
|
|
"code",
|
|
"pre",
|
|
"blockquote",
|
|
"hr",
|
|
"img",
|
|
"span",
|
|
"div",
|
|
]
|
|
|
|
ALLOWED_ATTRIBUTES = {
|
|
"a": ["href", "title"],
|
|
"img": ["src", "alt", "title"],
|
|
"th": ["align"],
|
|
"td": ["align"],
|
|
}
|
|
|
|
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
|
|
|
|
EXTENSIONS = ["tables", "fenced_code", "sane_lists"]
|
|
|
|
WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/")
|
|
|
|
# Links authored in the wiki are absolute: `/en/user-guides/<x>` is a guide that
|
|
# this help centre mirrors, while any other `/en/<x>` is a wiki-only page.
|
|
WIKI_GUIDE_PREFIX = "/en/user-guides/"
|
|
WIKI_PAGE_PREFIX = "/en/"
|
|
|
|
_IMAGE_SRC_RE = re.compile(r'(<img\b[^>]*?\bsrc=")([^"]*)(")', re.IGNORECASE)
|
|
_ANCHOR_HREF_RE = re.compile(r'(<a\b[^>]*?\bhref=")([^"]*)(")', re.IGNORECASE)
|
|
|
|
|
|
def _rewrite_absolute_anchor(prefix, href, suffix, help_root):
|
|
"""Point an absolute wiki link at somewhere that actually resolves.
|
|
|
|
These hrefs cannot be left alone. Every app host serves only its own
|
|
portal (/portal/, /ops/, /admin/) and has no `/en/` route, so an untouched
|
|
`/en/...` link is a 404 for whoever clicks it inside the help centre.
|
|
|
|
- `/en/user-guides/<x>` is mirrored in-app -> the in-app help path.
|
|
- any other `/en/<x>` exists only in the wiki -> the wiki public base.
|
|
"""
|
|
path = href.split("#", 1)[0].split("?", 1)[0]
|
|
if path.startswith(WIKI_GUIDE_PREFIX):
|
|
target = path[len(WIKI_GUIDE_PREFIX):].strip("/")
|
|
if target:
|
|
return f'{prefix}{help_root}{target}/{suffix}'
|
|
return f'{prefix}{help_root}{suffix}'
|
|
if path.startswith(WIKI_PAGE_PREFIX):
|
|
return f'{prefix}{settings.WIKI_PUBLIC_BASE}{path}{suffix}'
|
|
return None
|
|
|
|
|
|
def _rewrite_anchor(match, base_folder, help_root):
|
|
prefix, href, suffix = match.groups()
|
|
parsed = urlparse(href)
|
|
if parsed.scheme or href.startswith("#"):
|
|
return match.group(0)
|
|
if href.startswith("/"):
|
|
rewritten = _rewrite_absolute_anchor(prefix, href, suffix, help_root)
|
|
return rewritten if rewritten is not None else match.group(0)
|
|
resolved = posixpath.normpath(posixpath.join(base_folder, href))
|
|
if resolved.startswith("user-guides/"):
|
|
suffix_path = resolved[len("user-guides/"):]
|
|
return f'{prefix}{help_root}{suffix_path}/{suffix}'
|
|
return match.group(0)
|
|
|
|
|
|
def _rewrite_image(match):
|
|
prefix, src, suffix = match.groups()
|
|
if src.startswith(WIKI_IMAGE_PREFIXES):
|
|
return f'{prefix}{settings.WIKI_PUBLIC_BASE}{src}{suffix}'
|
|
return match.group(0)
|
|
|
|
|
|
def render_markdown(content, page_path, help_root):
|
|
html = markdown(content or "", extensions=EXTENSIONS)
|
|
base_folder = posixpath.dirname(page_path)
|
|
html = _ANCHOR_HREF_RE.sub(lambda m: _rewrite_anchor(m, base_folder, help_root), html)
|
|
html = _IMAGE_SRC_RE.sub(_rewrite_image, html)
|
|
return bleach.clean(
|
|
html,
|
|
tags=ALLOWED_TAGS,
|
|
attributes=ALLOWED_ATTRIBUTES,
|
|
protocols=ALLOWED_PROTOCOLS,
|
|
)
|