service detail change - md docs and toggable sections.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""Čtení markdown dokumentace služby z jejího Gitea repozitáře.
|
||||
|
||||
Portál běží bez práv k workspace na disku (viz komentář v config.py), ale má Gitea admin
|
||||
token, takže seznam i obsah *.md souborů čte přes Gitea API:
|
||||
- seznam: GET /api/v1/repos/{org}/{repo}/git/trees/{ref}?recursive=true
|
||||
- obsah: GET /api/v1/repos/{org}/{repo}/raw/{path}?ref={ref}
|
||||
- render: POST /api/v1/markdown (Gitea si HTML samo sanitizuje)
|
||||
|
||||
Když Gitea není nakonfigurovaná nebo repozitář neexistuje, vyhodí RepoDocsError
|
||||
se srozumitelným důvodem - volající ho zobrazí místo obsahu.
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request as UrlRequest
|
||||
from urllib.request import urlopen
|
||||
|
||||
from app.config import (
|
||||
DEFAULT_GITEA_ORG,
|
||||
get_gitea_admin_token,
|
||||
get_gitea_server_url,
|
||||
read_env_value,
|
||||
)
|
||||
from app.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Limity drží stránku svižnou i u repozitáře s rozsáhlou dokumentací.
|
||||
MAX_DOC_FILES = 100
|
||||
MAX_DOC_BYTES = 300_000
|
||||
REQUEST_TIMEOUT = 8
|
||||
|
||||
|
||||
class RepoDocsError(RuntimeError):
|
||||
"""Dokumentaci se nepodařilo načíst (chybí token, Gitea je nedostupná, repo neexistuje)."""
|
||||
|
||||
|
||||
def gitea_org() -> str:
|
||||
return read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
|
||||
|
||||
|
||||
def is_markdown_path(path: str) -> bool:
|
||||
"""Povolíme jen relativní cestu k *.md uvnitř repozitáře.
|
||||
|
||||
Cesta jde do Gitea API, ne na lokální disk, ale i tak odmítáme "..", absolutní cesty
|
||||
a zpětná lomítka - ať se přes parametr nedá sáhnout nikam jinam než na dokumentaci.
|
||||
"""
|
||||
path = (path or "").strip()
|
||||
if not path or not path.lower().endswith(".md"):
|
||||
return False
|
||||
if path.startswith("/") or "\\" in path or "\x00" in path:
|
||||
return False
|
||||
return ".." not in path.split("/")
|
||||
|
||||
|
||||
def list_markdown_files(repo: str, branch: str = "") -> list[dict]:
|
||||
"""Vrátí [{"path", "size"}] všech *.md souborů v repozitáři.
|
||||
|
||||
Řazení: soubory v kořeni (README.md, AGENTS.md) nahoře, pak abecedně podle cesty.
|
||||
"""
|
||||
repo = (repo or "").strip()
|
||||
if not repo:
|
||||
raise RepoDocsError("Služba nemá evidovaný název repozitáře.")
|
||||
|
||||
ref = (branch or "").strip() or _default_branch(repo)
|
||||
payload = _request_json(f"/api/v1/repos/{quote(gitea_org())}/{quote(repo)}/git/trees/{quote(ref, safe='')}?recursive=true&per_page=1000")
|
||||
|
||||
files = []
|
||||
for entry in payload.get("tree") or []:
|
||||
if entry.get("type") != "blob":
|
||||
continue
|
||||
path = entry.get("path") or ""
|
||||
if not is_markdown_path(path):
|
||||
continue
|
||||
files.append({"path": path, "size": int(entry.get("size") or 0)})
|
||||
|
||||
files.sort(key=lambda item: (item["path"].count("/"), item["path"].lower()))
|
||||
return files[:MAX_DOC_FILES]
|
||||
|
||||
|
||||
def read_markdown(repo: str, path: str, branch: str = "") -> str:
|
||||
"""Vrátí obsah *.md souboru jako text (oříznutý na MAX_DOC_BYTES)."""
|
||||
repo = (repo or "").strip()
|
||||
if not repo:
|
||||
raise RepoDocsError("Služba nemá evidovaný název repozitáře.")
|
||||
if not is_markdown_path(path):
|
||||
raise RepoDocsError("Neplatná cesta k souboru dokumentace.")
|
||||
|
||||
ref = (branch or "").strip() or _default_branch(repo)
|
||||
encoded_path = "/".join(quote(part, safe="") for part in path.split("/"))
|
||||
raw = _request_bytes(
|
||||
f"/api/v1/repos/{quote(gitea_org())}/{quote(repo)}/raw/{encoded_path}?ref={quote(ref, safe='')}",
|
||||
accept="text/plain",
|
||||
)
|
||||
|
||||
truncated = len(raw) > MAX_DOC_BYTES
|
||||
text = raw[:MAX_DOC_BYTES].decode("utf-8", errors="replace")
|
||||
if truncated:
|
||||
text += "\n\n*(Soubor je delší, náhled je zkrácený.)*"
|
||||
return text
|
||||
|
||||
|
||||
def render_markdown(text: str, repo: str) -> str:
|
||||
"""Vyrenderuje markdown do HTML přes Gitea API.
|
||||
|
||||
Portál nemá markdown knihovnu a Gitea render umí a zároveň si výstupní HTML sanitizuje.
|
||||
Když render selže, vrátíme obsah jako neformátovaný (escapovaný) text - náhled tak
|
||||
funguje i při výpadku render endpointu.
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"text": text,
|
||||
"mode": "gfm",
|
||||
"context": f"/{gitea_org()}/{repo}",
|
||||
"wiki": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
try:
|
||||
rendered = _request_bytes(
|
||||
"/api/v1/markdown",
|
||||
method="POST",
|
||||
data=payload,
|
||||
accept="text/html",
|
||||
content_type="application/json",
|
||||
).decode("utf-8", errors="replace")
|
||||
return f'<div class="md-body">{rendered}</div>'
|
||||
except RepoDocsError as exc:
|
||||
logger.warning("Gitea markdown render selhal (%s), zobrazuji neformátovaný text: %s", repo, exc)
|
||||
return f'<pre class="md-raw">{html.escape(text)}</pre>'
|
||||
|
||||
|
||||
def _default_branch(repo: str) -> str:
|
||||
"""Výchozí větev repozitáře podle Gitea (služby zakládané portálem mají main)."""
|
||||
payload = _request_json(f"/api/v1/repos/{quote(gitea_org())}/{quote(repo)}")
|
||||
return (payload.get("default_branch") or "main").strip() or "main"
|
||||
|
||||
|
||||
def _request_json(path: str) -> dict:
|
||||
body = _request_bytes(path).decode("utf-8", errors="replace")
|
||||
try:
|
||||
return json.loads(body) if body else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RepoDocsError(f"Gitea vrátila neplatnou odpověď: {exc}") from exc
|
||||
|
||||
|
||||
def _request_bytes(
|
||||
path: str,
|
||||
method: str = "GET",
|
||||
data: bytes | None = None,
|
||||
accept: str = "application/json",
|
||||
content_type: str = "",
|
||||
) -> bytes:
|
||||
base_url = get_gitea_server_url().rstrip("/")
|
||||
token = get_gitea_admin_token()
|
||||
if not base_url:
|
||||
raise RepoDocsError("Gitea není nakonfigurovaná (chybí GITEA_URL).")
|
||||
if not token:
|
||||
raise RepoDocsError("Chybí Gitea admin token.")
|
||||
|
||||
headers = {"Accept": accept, "Authorization": f"token {token}"}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
request = UrlRequest(f"{base_url}{path}", data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
||||
return response.read()
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
raise RepoDocsError("Repozitář nebo soubor v Gitea neexistuje.") from exc
|
||||
raise RepoDocsError(f"Gitea API vrátila HTTP {exc.code}.") from exc
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise RepoDocsError(f"Gitea API je nedostupná: {exc}") from exc
|
||||
Reference in New Issue
Block a user