"""Č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'
{html.escape(text)}'
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