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
import base64
import logging
from dataclasses import asdict
from pathlib import Path
@@ -17,8 +18,8 @@ _LOGGER = logging.getLogger(__name__)
def _render_markdown_server_side(md: str) -> str | None:
"""
Render Markdown -> sanitized HTML.
We do NOT rely on Home Assistant internal markdown utilities (they may change),
and we do NOT rely on frontend JS libs (marked/DOMPurify may be unavailable).
Server-side rendering is required because HA frontend context for custom panels
may not expose marked/DOMPurify/ha-markdown consistently.
"""
text = (md or "").strip()
if not text:
@@ -26,7 +27,7 @@ def _render_markdown_server_side(md: str) -> str | None:
html: str | None = None
# 1) Try python-markdown (commonly available in HA)
# 1) Try python-markdown
try:
import markdown as mdlib # type: ignore
@@ -47,12 +48,11 @@ def _render_markdown_server_side(md: str) -> str | None:
if not html:
return None
# 2) Sanitize via bleach if available (preferred)
# 2) Sanitize via bleach if available
try:
import bleach # type: ignore
allowed_tags = [
# structure
"p",
"br",
"hr",
@@ -61,28 +61,23 @@ def _render_markdown_server_side(md: str) -> str | None:
"blockquote",
"pre",
"code",
# headings
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
# lists
"ul",
"ol",
"li",
# emphasis
"strong",
"em",
"b",
"i",
"u",
"s",
# links/images
"a",
"img",
# tables
"table",
"thead",
"tbody",
@@ -107,25 +102,66 @@ def _render_markdown_server_side(md: str) -> str | None:
strip=True,
)
# Make links safe by default (no opener)
sanitized = sanitized.replace('<a href="', '<a rel="noreferrer noopener" target="_blank" href="')
# Make links safe
sanitized = sanitized.replace(
'<a href="',
'<a rel="noreferrer noopener" target="_blank" href="',
)
return sanitized
except Exception as e:
_LOGGER.debug("bleach sanitize failed/unavailable: %s", e)
# 3) If bleach is not available, return raw rendered HTML as last resort
# (still okay-ish because Markdown renderer does not execute scripts by itself,
# but sanitization is strongly preferred).
# 3) Last resort: return rendered 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):
url = "/api/bahmcloud_store_static/{path:.*}"
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
async def get(self, request: web.Request, path: str) -> web.Response:
@@ -138,6 +174,7 @@ class StaticAssetsView(HomeAssistantView):
target = (base / req_path).resolve()
# Prevent traversal
if not str(target).startswith(str(base_resolved)):
return web.Response(status=404)
@@ -148,6 +185,7 @@ class StaticAssetsView(HomeAssistantView):
_LOGGER.error("BCS static asset not found: %s", target)
return web.Response(status=404)
# IMPORTANT: content_type must NOT include charset (aiohttp restriction)
content_type = "text/plain"
charset = None
@@ -233,9 +271,16 @@ class BCSReadmeView(HomeAssistantView):
if not repo_id:
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:
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)