Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>Frappe Wiki</title>
</head>
<body class="bg-surface-white">
Expand Down
70 changes: 69 additions & 1 deletion wiki/frappe_wiki/doctype/wiki_document/test_wiki_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
from frappe.tests import IntegrationTestCase
from frappe.utils import get_test_client

from wiki.frappe_wiki.doctype.wiki_document.wiki_document import process_navbar_items
from wiki.frappe_wiki.doctype.wiki_document.wiki_document import (
download_pdf,
process_navbar_items,
)
from wiki.wiki.markdown import render_markdown, render_markdown_with_toc

# On IntegrationTestCase, the doctype test records and all
Expand All @@ -28,6 +31,7 @@ def create_test_wiki_document(test_case, title, **kwargs):
"parent_wiki_document": kwargs.get("parent"),
"is_group": kwargs.get("is_group", False),
"is_published": kwargs.get("is_published", True),
"is_private": kwargs.get("is_private", False),
"sort_order": kwargs.get("sort_order", 0),
"slug": kwargs.get("slug"),
"is_external_link": kwargs.get("is_external_link", False),
Expand Down Expand Up @@ -1223,6 +1227,70 @@ def test_repeated_saves_do_not_compound_escape(self):
self.assertEqual(page.content, self.IFRAME_CONTENT)


class TestWikiDocumentPdfDownload(WikiDocumentTestBase):
def tearDown(self):
frappe.set_user("Administrator")
super().tearDown()

def test_download_pdf_returns_pdf_for_published_public_page(self):
root_group = create_test_wiki_document(self, "Root PDF Public", is_group=True)
page = create_test_wiki_document(
self,
"Downloadable Page",
parent=root_group.name,
content="# Public Page\n\nThis page should download.",
slug="downloadable-page",
)
create_test_wiki_space(self, "PDF Public Space", "pdf-public-space", root_group.name)

frappe.set_user("Guest")
frappe.local.response = frappe._dict()

with patch(
"wiki.frappe_wiki.doctype.wiki_document.wiki_document.get_print",
return_value=b"%PDF-test%",
) as mocked_get_print:
download_pdf(route=page.route)

mocked_get_print.assert_called_once()
self.assertEqual(mocked_get_print.call_args.kwargs["print_format"], "Standard Wiki Document")
self.assertEqual(frappe.local.response.type, "download")
self.assertEqual(frappe.local.response.content_type, "application/pdf")
self.assertEqual(frappe.local.response.filecontent, b"%PDF-test%")
self.assertEqual(frappe.local.response.filename, "downloadable-page.pdf")

def test_download_pdf_blocks_private_page_for_guest(self):
root_group = create_test_wiki_document(self, "Root PDF Private", is_group=True)
page = create_test_wiki_document(
self,
"Private Download Page",
parent=root_group.name,
is_private=True,
slug="private-download-page",
)
create_test_wiki_space(self, "PDF Private Space", "pdf-private-space", root_group.name)

frappe.set_user("Guest")

with self.assertRaises(frappe.PermissionError):
download_pdf(route=page.route)

def test_before_print_renders_markdown_content(self):
root_group = create_test_wiki_document(self, "Root PDF Context", is_group=True)
page = create_test_wiki_document(
self,
"Printable Context Page",
parent=root_group.name,
content="## Section\n\nParagraph text.",
slug="printable-context-page",
)
create_test_wiki_space(self, "PDF Context Space", "pdf-context-space", root_group.name)

page.before_print()

self.assertIn("<h2", page.rendered_content_for_pdf)


def _make_request(test_client, method, path, **kwargs):
"""Run a werkzeug test-client request in a thread (mirrors frappe test_api pattern)."""
site = frappe.local.site
Expand Down
42 changes: 40 additions & 2 deletions wiki/frappe_wiki/doctype/wiki_document/wiki_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
from frappe import _
from frappe.utils import pretty_date
from frappe.utils.nestedset import NestedSet, get_descendants_of
from frappe.utils.print_utils import get_print
from frappe.website.page_renderers.base_renderer import BaseRenderer
from werkzeug.wrappers import Response

from wiki.wiki.markdown import render_markdown_with_toc
from wiki.wiki.markdown import render_markdown, render_markdown_with_toc

WIKI_DOCUMENT_PRINT_FORMAT = "Standard Wiki Document"

# Mapping of known service domains to icon identifiers
KNOWN_SERVICE_ICONS = {
Expand Down Expand Up @@ -346,7 +349,7 @@ def get_web_context(self) -> dict:
"next_doc": None,
"edit_link": self.get_edit_link(),
"last_updated": pretty_date(self.modified),
"last_updated_on": frappe.utils.format_datetime(self.modified),
"last_updated_on": self.get_formatted("modified"),
"hide_chrome": not wiki_space,
}

Expand Down Expand Up @@ -377,6 +380,10 @@ def get_web_context(self) -> dict:

return context

def before_print(self, print_settings=None):
"""Render markdown content so the print format can drop it in as HTML."""
self.rendered_content_for_pdf = render_markdown(self.content or "")

@frappe.whitelist()
def get_children_count(self) -> int:
"""Get the count of children for this Wiki Document that the user can read."""
Expand Down Expand Up @@ -578,6 +585,37 @@ def get_page_data(route: str) -> dict:
return doc.get_web_context()


@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def download_pdf(route: str):
doc_name = frappe.db.get_value(
"Wiki Document", {"route": route, "is_group": 0, "is_external_link": 0}, "name"
)
if not doc_name:
frappe.throw(_("Page not found"), frappe.DoesNotExistError)

doc = frappe.get_cached_doc("Wiki Document", doc_name)
doc.check_guest_access()
doc.check_published()

# Guests can't print by default; we've already authorized them above via check_guest_access.
frappe.local.flags.ignore_print_permissions = True
try:
pdf_file = get_print(
doctype="Wiki Document",
print_format=WIKI_DOCUMENT_PRINT_FORMAT,
doc=doc,
as_pdf=True,
no_letterhead=1,
)
finally:
frappe.local.flags.ignore_print_permissions = False

frappe.local.response.filename = f"{doc.slug or doc.name}.pdf"
frappe.local.response.filecontent = pdf_file
frappe.local.response.content_type = "application/pdf"
frappe.local.response.type = "download"


def on_wiki_document_update(doc, method):
"""Sync desk edits to the revision system so CRs stay aligned with the live tree."""
_sync_document_to_revision(doc)
Expand Down
1 change: 1 addition & 0 deletions wiki/frappe_wiki/print_format/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<style>
.wiki-document-shell {
max-width: 720px;
margin: 0 auto;
}

.wiki-document-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e2e2e2;
}

.wiki-document-eyebrow {
margin-bottom: 0.5rem;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #7c7c7c;
}

.wiki-document-eyebrow .sep {
margin: 0 0.4em;
color: #c7c7c7;
}

.wiki-document-title {
margin: 0 0 1rem;
}

.wiki-document-meta .row + .row {
margin-top: 0.75rem;
}

.wiki-document-meta label {
margin-bottom: 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #7c7c7c;
}

.wiki-document-content img {
max-width: 100%;
height: auto;
}

.wiki-document-content a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}

.wiki-document-content pre {
white-space: pre-wrap;
word-break: break-word;
}

.wiki-document-content table {
width: 100%;
border-collapse: collapse;
}

.wiki-document-content th,
.wiki-document-content td {
padding: 0.5rem;
border: 1px solid #e2e2e2;
vertical-align: top;
}

.wiki-document-content blockquote {
/* Reset Bootstrap defaults (full border + 10px 20px padding) that Frappe's print CSS layers in. */
border: 0;
border-left: 4px solid #e2e2e2;
padding: 0 0 0 1em;
margin: 1.6em 0;
font-size: inherit;
font-style: italic;
font-weight: 500;
color: #383838;
quotes: "\201C" "\201D" "\2018" "\2019";
}

.wiki-document-content blockquote p {
margin: 0.5em 0;
}

.wiki-document-content blockquote p:first-of-type::before {
content: open-quote;
}

.wiki-document-content blockquote p:last-of-type::after {
content: close-quote;
}

/* Callouts — mirror the public-page treatment. Public CSS uses display:grid;
wkhtmltopdf's QtWebKit predates Grid, so use a float for icon-left layout. */
.wiki-document-content .callout {
margin: 1rem 0;
padding: 0.875rem 1rem;
border-radius: 6px;
overflow: hidden;
}

.wiki-document-content .callout-icon {
float: left;
width: 1rem;
margin-right: 0.75rem;
padding-top: 0.125rem;
line-height: 0;
}

.wiki-document-content .callout-icon svg {
width: 1rem;
height: 1rem;
}

.wiki-document-content .callout-body {
overflow: hidden;
}

.wiki-document-content .callout-title {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
color: #171717;
}

.wiki-document-content .callout-content {
font-size: 0.875rem;
line-height: 1.5;
color: #525252;
}

.wiki-document-content .callout-content > *:first-child {
margin-top: 0;
}

.wiki-document-content .callout-content > *:last-child {
margin-bottom: 0;
}

.wiki-document-content .callout-note {
background-color: #E6F4FF;
}

.wiki-document-content .callout-note .callout-icon {
color: #007BE0;
}

.wiki-document-content .callout-tip {
background-color: #E4FAEB;
}

.wiki-document-content .callout-tip .callout-icon {
color: #278F5E;
}

.wiki-document-content .callout-caution {
background-color: #FFF7D3;
}

.wiki-document-content .callout-caution .callout-icon {
color: #DB7706;
}

.wiki-document-content .callout-danger {
background-color: #FFE7E7;
}

.wiki-document-content .callout-danger .callout-icon {
color: #E03636;
}
</style>

{% set breadcrumbs = doc.get_breadcrumbs() %}
{% set space = breadcrumbs.space %}
{# Skip the first ancestor — it's the wiki space's root group, already represented by the space name. #}
{% set ancestors = breadcrumbs.ancestors[1:] if breadcrumbs.ancestors else [] %}
<div class="page-break">
<div class="wiki-document-shell">
<header class="wiki-document-header">
{% if space or ancestors %}
<div class="wiki-document-eyebrow">
{% if space %}{{ space.space_name }}{% endif %}
{% for ancestor in ancestors %}
<span class="sep">›</span>{{ ancestor.title }}
{% endfor %}
</div>
{% endif %}

<h1 class="wiki-document-title">{{ doc.title }}</h1>

<div class="wiki-document-meta">
<div class="row">
<div class="col-xs-3">
<label>{{ _("Last Updated") }}</label>
</div>
<div class="col-xs-9">{{ doc.get_formatted("modified") }}</div>
</div>
</div>
</header>

<div class="wiki-document-content">
{{ doc.rendered_content_for_pdf or "" }}
</div>
</div>
</div>
Loading
Loading