Skip to content
Open
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
110 changes: 110 additions & 0 deletions wiki/api/wiki_space.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,117 @@
import hashlib
import re

import frappe
from frappe import _
from frappe.utils.nestedset import get_descendants_of

# Matches the `src` attribute of `<img>` tags in rendered markdown — used to
# enumerate embedded assets so the service worker can precache them.
_IMG_SRC_RE = re.compile(r'<img\b[^>]*?\bsrc=["\']([^"\']+)["\']', re.IGNORECASE)


@frappe.whitelist(
allow_guest=True, methods=["GET"]
) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def get_space_manifest(space_route: str) -> dict:
Comment on lines +13 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Guest manifest endpoint needs abuse controls before release.

Line 13 exposes this endpoint to guests, and Lines 32-68 do potentially heavy descendant traversal + hashing for every request. Without rate limiting and/or cached manifest reuse, this can be abused into avoidable load spikes.

Also applies to: 32-68

🧰 Tools
🪛 GitHub Actions: Linters / 0_Semgrep Rules.txt

[error] 13-16: semgrep reported a blocking finding (frappe-semgrep-rules.rules.security.guest-whitelisted-method): Whitelisted method accessible to guest should be manually reviewed by the security team. Location: @frappe.whitelist(allow_guest=True, methods=["GET"]) on def get_space_manifest(space_route: str) -> dict.

🪛 GitHub Actions: Linters / Semgrep Rules

[error] 13-15: Semgrep blocking rule fired (frappe-semgrep-rules.rules.security.guest-whitelisted-method): Whitelisted method accessible to guest should be manually reviewed by security team. Exiting with code 1.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wiki/api/wiki_space.py` around lines 13 - 16, The guest-exposed
get_space_manifest is performing expensive descendant traversal and hashing (the
code around the descendant traversal/hashing block referenced at lines 32-68)
and must be protected: implement a caching layer keyed by space_route (use
existing frappe.cache or Redis) with a reasonable TTL to serve repeated requests
without recomputing the manifest, and add request-rate controls for guest users
(e.g., per-IP or per-guest token bucket limits via your app’s rate-limiter
hooks) so heavy work is throttled; alternatively restrict allow_guest=True for
this endpoint and require authenticated access for uncached manifest
generation—update get_space_manifest to first check cache, only compute on miss,
and enforce the rate limit before performing traversal/hashing.

"""Return the precache manifest for a Wiki Space, used by the PWA service worker.

Only published, public, internal leaf pages are listed. External links and
groups are omitted from `pages` but kept in the tree metadata so the sidebar
renders correctly offline.
"""
space = frappe.db.get_value(
"Wiki Space",
{"route": space_route, "is_published": 1},
["name", "space_name", "route", "root_group", "favicon", "light_mode_logo"],
as_dict=True,
)
if not space or not space.root_group:
frappe.throw(_("Space not found"), frappe.DoesNotExistError)

descendants = get_descendants_of("Wiki Document", space.root_group, ignore_permissions=True)
if not descendants:
return _empty_manifest(space)

docs = frappe.db.get_all(
"Wiki Document",
fields=["name", "title", "route", "content", "modified", "is_group", "is_external_link"],
filters={
"name": ("in", descendants),
"is_published": 1,
"is_private": 0,
"is_group": 0,
"is_external_link": 0,
},
order_by="lft asc",
)

pages = []
hasher = hashlib.sha256()
for doc in docs:
page_hash = hashlib.sha256(
(doc.content or "").encode("utf-8") + str(doc.modified).encode("utf-8")
).hexdigest()[:12]

image_urls = sorted(
{src for src in _IMG_SRC_RE.findall(doc.content or "") if src.startswith("/files/")}
)

pages.append(
{
"route": doc.route,
"title": doc.title,
"hash": page_hash,
"images": image_urls,
}
)
hasher.update(page_hash.encode("utf-8"))

manifest = _empty_manifest(space)
manifest.update(
{
"version": hasher.hexdigest()[:16],
"pages": pages,
"shared_assets": _shared_assets(),
}
)
return manifest


def _empty_manifest(space: dict) -> dict:
return {
"space": {
"name": space.name,
"space_name": space.space_name,
"route": space.route,
"favicon": space.favicon,
"logo": space.light_mode_logo,
},
"version": "",
"pages": [],
"shared_assets": _shared_assets(),
}


def _shared_assets() -> list[str]:
"""Shared CSS/JS/font assets every wiki page needs, regardless of space.

These are precached when the service worker installs, before any space is
downloaded.
"""
from wiki.utils import get_tailwindcss_hash

return [
f"/assets/wiki/css/tailwind.css?v={get_tailwindcss_hash()}",
"/assets/wiki/css/hljs-github-light.css",
"/assets/wiki/js/vendor/alpinejs/plugins/persist.js",
"/assets/wiki/js/vendor/alpinejs/plugins/collapse.js",
"/assets/wiki/js/vendor/alpinejs/core.js",
"/assets/wiki/js/code-blocks.js",
"/assets/wiki/js/image-viewer.js",
"/assets/wiki/favicon.png",
]


@frappe.whitelist()
def get_wiki_tree(space_id: str) -> dict:
Expand Down
168 changes: 168 additions & 0 deletions wiki/templates/wiki/includes/install_prompt.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
{% from "templates/wiki/macros/buttons.html" import render_icon %}

{# Install + offline nudge. Mobile-focused, one-shot per space.
- Android-class browsers: capture beforeinstallprompt → show dialog → on accept, install + bulk-download the current space.
- iOS: no install API → show popover with manual Share → Add to Home Screen instructions, plus a "Save for offline" button.
- Already-installed or non-mobile: silent. Desktop users discover offline via /wiki-offline.
#}
{% if wiki_space and wiki_space.is_published %}
<div x-data='installNudge({{ wiki_space.route | tojson }}, {{ (wiki_space.space_name or "Wiki") | tojson }})'
x-init="init()"
class="lg:hidden">

{# Android install dialog #}
<div x-show="dialogOpen"
x-cloak
x-transition.opacity
class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center bg-black/40 p-4"
@click.self="dismiss()">
<div class="bg-[var(--surface-modal)] rounded-2xl shadow-2xl max-w-sm w-full p-5 space-y-4"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="translate-y-4 opacity-0"
x-transition:enter-end="translate-y-0 opacity-100">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<h2 class="text-base font-semibold text-[var(--ink-gray-9)]" x-text="dialogTitle"></h2>
<p class="text-sm text-[var(--ink-gray-6)] mt-1" x-text="dialogBody"></p>
</div>
<button @click="dismiss()" aria-label="Dismiss"
class="p-1 -m-1 text-[var(--ink-gray-5)] hover:text-[var(--ink-gray-9)] rounded">
{{ render_icon("x", "w-4 h-4") }}
</button>
</div>
<div class="flex gap-2 pt-1">
<button @click="dismiss()"
class="flex-1 px-4 py-2 text-sm font-medium text-[var(--ink-gray-7)] bg-[var(--surface-gray-2)] hover:bg-[var(--surface-gray-3)] rounded-lg transition-colors">
Not now
</button>
<button @click="accept()"
class="flex-1 inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-[var(--ink-gray-9)] hover:opacity-90 rounded-lg transition-opacity">
{{ render_icon("download", "w-4 h-4") }}
<span x-text="acceptLabel"></span>
</button>
</div>
</div>
</div>

{# iOS instructional popover #}
<div x-show="iosPopoverOpen"
x-cloak
x-transition
class="fixed bottom-4 left-4 right-4 z-[100] bg-[var(--surface-modal)] rounded-xl shadow-2xl p-4 space-y-3 border border-[var(--outline-gray-1)]">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<h2 class="text-sm font-semibold text-[var(--ink-gray-9)]"
x-text="`Read ${spaceName} offline`"></h2>
<p class="text-xs text-[var(--ink-gray-6)] mt-1 leading-relaxed">
Tap <span class="inline-flex items-center align-middle">
<svg class="inline w-3.5 h-3.5 text-blue-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v13M5 9l7-7 7 7M5 21h14"/></svg>
</span>
in Safari then "Add to Home Screen" to install. You can also save this space for offline reading right now.
</p>
</div>
<button @click="dismiss()" aria-label="Dismiss"
class="p-1 -m-1 text-[var(--ink-gray-5)] hover:text-[var(--ink-gray-9)] rounded">
{{ render_icon("x", "w-4 h-4") }}
</button>
</div>
<button @click="accept()"
class="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-[var(--ink-gray-9)] hover:opacity-90 rounded-lg transition-opacity">
{{ render_icon("download", "w-4 h-4") }}
<span>Save for offline</span>
</button>
</div>
</div>

<script>
function installNudge(spaceRoute, spaceName) {
return {
spaceRoute,
spaceName,
dialogOpen: false,
iosPopoverOpen: false,
deferredPrompt: null,
dialogTitle: '',
dialogBody: '',
acceptLabel: '',

init() {
if (!('serviceWorker' in navigator)) return;
if (this.dismissedForThisSpace()) return;

const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;

// Capture the Android install prompt as soon as it fires —
// calling preventDefault() is required to defer it for later.
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this.deferredPrompt = e;
});

// Defer the decision by ~2s so the SW has time to report which
// spaces are already downloaded and so beforeinstallprompt has
// had a chance to fire.
setTimeout(() => {
if (this.dismissedForThisSpace() || this.alreadyDownloaded()) return;

if (this.deferredPrompt || isStandalone) {
this.showDialog(isStandalone);
} else if (isIos && !isStandalone) {
this.iosPopoverOpen = true;
}
// Other browsers without install API: silent. User can use /wiki-offline.
}, 2000);
},

showDialog(alreadyInstalled) {
this.dialogTitle = alreadyInstalled
? `Read ${this.spaceName} offline`
: `Install Wiki for offline reading`;
this.dialogBody = alreadyInstalled
? `Save this space to your device so you can read it without an internet connection.`
: `Install the app and save ${this.spaceName} so you can read it without an internet connection.`;
this.acceptLabel = alreadyInstalled ? 'Save offline' : 'Install';
this.dialogOpen = true;
},

async accept() {
this.dialogOpen = false;
this.iosPopoverOpen = false;
this.markDismissed();

// Trigger the OS install prompt first (Android-class only).
if (this.deferredPrompt) {
try {
this.deferredPrompt.prompt();
await this.deferredPrompt.userChoice;
} catch (e) { /* user dismissed, continue with download */ }
this.deferredPrompt = null;
}

// Kick off the per-space download via the shared pwa store.
Alpine.store('pwa').download(this.spaceRoute);
},

dismiss() {
this.dialogOpen = false;
this.iosPopoverOpen = false;
this.markDismissed();
},

dismissedKey() { return `wikiOfflineNudge:${this.spaceRoute}`; },
dismissedForThisSpace() {
try { return localStorage.getItem(this.dismissedKey()) === '1'; }
catch (e) { return false; }
},
markDismissed() {
try { localStorage.setItem(this.dismissedKey(), '1'); } catch (e) {}
},
alreadyDownloaded() {
const s = Alpine.store('pwa')?.spaces?.[this.spaceRoute];
return s && s.downloaded;
},
};
}
</script>
{% endif %}
Loading
Loading