Tes
This commit is contained in:
@@ -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,10 +271,17 @@ 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)
|
||||||
|
|
||||||
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