This commit is contained in:
2026-01-15 12:23:38 +00:00
parent ec60211339
commit f15d932d54

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import base64
import logging import logging
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
@@ -17,8 +18,8 @@ _LOGGER = logging.getLogger(__name__)
def _render_markdown_server_side(md: str) -> str | None: def _render_markdown_server_side(md: str) -> str | None:
""" """
Render Markdown -> sanitized HTML. Render Markdown -> sanitized HTML.
We do NOT rely on Home Assistant internal markdown utilities (they may change), Server-side rendering is required because HA frontend context for custom panels
and we do NOT rely on frontend JS libs (marked/DOMPurify may be unavailable). may not expose marked/DOMPurify/ha-markdown consistently.
""" """
text = (md or "").strip() text = (md or "").strip()
if not text: if not text:
@@ -26,7 +27,7 @@ def _render_markdown_server_side(md: str) -> str | None:
html: str | None = None html: str | None = None
# 1) Try python-markdown (commonly available in HA) # 1) Try python-markdown
try: try:
import markdown as mdlib # type: ignore import markdown as mdlib # type: ignore
@@ -47,12 +48,11 @@ def _render_markdown_server_side(md: str) -> str | None:
if not html: if not html:
return None return None
# 2) Sanitize via bleach if available (preferred) # 2) Sanitize via bleach if available
try: try:
import bleach # type: ignore import bleach # type: ignore
allowed_tags = [ allowed_tags = [
# structure
"p", "p",
"br", "br",
"hr", "hr",
@@ -61,28 +61,23 @@ def _render_markdown_server_side(md: str) -> str | None:
"blockquote", "blockquote",
"pre", "pre",
"code", "code",
# headings
"h1", "h1",
"h2", "h2",
"h3", "h3",
"h4", "h4",
"h5", "h5",
"h6", "h6",
# lists
"ul", "ul",
"ol", "ol",
"li", "li",
# emphasis
"strong", "strong",
"em", "em",
"b", "b",
"i", "i",
"u", "u",
"s", "s",
# links/images
"a", "a",
"img", "img",
# tables
"table", "table",
"thead", "thead",
"tbody", "tbody",
@@ -107,25 +102,66 @@ def _render_markdown_server_side(md: str) -> str | None:
strip=True, strip=True,
) )
# Make links safe by default (no opener) # Make links safe
sanitized = sanitized.replace('<a href="', '<a rel="noreferrer noopener" target="_blank" href="') sanitized = sanitized.replace(
'<a href="',
'<a rel="noreferrer noopener" target="_blank" href="',
)
return sanitized return sanitized
except Exception as e: except Exception as e:
_LOGGER.debug("bleach sanitize failed/unavailable: %s", e) _LOGGER.debug("bleach sanitize failed/unavailable: %s", e)
# 3) If bleach is not available, return raw rendered HTML as last resort # 3) Last resort: return rendered HTML
# (still okay-ish because Markdown renderer does not execute scripts by itself,
# but sanitization is strongly preferred).
return html return html
def _coerce_readme_to_text(maybe_md: Any) -> str | None:
"""
Ensure README is always a plain markdown string.
Handles cases where upstream functions return:
- str (already fine)
- dict from GitHub/GitLab "contents" endpoints (base64 content)
- dict with a nested 'readme' field
"""
if maybe_md is None:
return None
if isinstance(maybe_md, str):
return maybe_md
if isinstance(maybe_md, dict):
# Common pattern: { "content": "<base64>", "encoding": "base64" }
content = maybe_md.get("content")
encoding = maybe_md.get("encoding")
if isinstance(content, str) and isinstance(encoding, str) and encoding.lower() == "base64":
try:
raw = base64.b64decode(content.encode("utf-8"), validate=False)
return raw.decode("utf-8", errors="replace")
except Exception:
pass
# Another common pattern: { "readme": "..." }
nested = maybe_md.get("readme")
if isinstance(nested, str):
return nested
# Some APIs use { "content": "plain text" } without encoding
if isinstance(content, str) and not encoding:
return content
# Anything else (list/int/etc.) is unsupported
return None
class StaticAssetsView(HomeAssistantView): class StaticAssetsView(HomeAssistantView):
url = "/api/bahmcloud_store_static/{path:.*}" url = "/api/bahmcloud_store_static/{path:.*}"
name = "api:bahmcloud_store_static" name = "api:bahmcloud_store_static"
# Keep this as you currently have it working (no auth for static) # Keep your working behavior (static assets must load without auth for panel modules)
requires_auth = False requires_auth = False
async def get(self, request: web.Request, path: str) -> web.Response: async def get(self, request: web.Request, path: str) -> web.Response:
@@ -138,6 +174,7 @@ class StaticAssetsView(HomeAssistantView):
target = (base / req_path).resolve() target = (base / req_path).resolve()
# Prevent traversal
if not str(target).startswith(str(base_resolved)): if not str(target).startswith(str(base_resolved)):
return web.Response(status=404) return web.Response(status=404)
@@ -148,6 +185,7 @@ class StaticAssetsView(HomeAssistantView):
_LOGGER.error("BCS static asset not found: %s", target) _LOGGER.error("BCS static asset not found: %s", target)
return web.Response(status=404) return web.Response(status=404)
# IMPORTANT: content_type must NOT include charset (aiohttp restriction)
content_type = "text/plain" content_type = "text/plain"
charset = None charset = None
@@ -233,9 +271,16 @@ class BCSReadmeView(HomeAssistantView):
if not repo_id: if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400) return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
md = await self.core.fetch_readme_markdown(repo_id) maybe_md = await self.core.fetch_readme_markdown(repo_id)
md = _coerce_readme_to_text(maybe_md)
if not md: if not md:
return web.json_response({"ok": False, "message": "README not found."}, status=404) # Provide a useful debug hint without leaking internal objects
t = type(maybe_md).__name__
return web.json_response(
{"ok": False, "message": f"README not found or invalid format (got {t})."},
status=404,
)
html = _render_markdown_server_side(md) html = _render_markdown_server_side(md)