Tes
This commit is contained in:
@@ -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,10 +271,17 @@ 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)
|
||||
|
||||
return web.json_response({"ok": True, "readme": md, "html": html})
|
||||
return web.json_response({"ok": True, "readme": md, "html": html})
|
||||
Reference in New Issue
Block a user