Files
appfactory-portal/app/repo_docs.py
T
2026-08-03 12:04:13 +02:00

207 lines
7.7 KiB
Python

"""Č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
# Gitea si u stromu repozitáře vynucuje vlastní stránkování (api.DEFAULT_PAGING_NUM, ve
# výchozím nastavení 30 položek) a náš per_page si klidně sníží. Strom vrací VŠECHNY soubory,
# ne jen *.md, takže bez dotažení dalších stránek by se dokumentace ve větším repu vůbec
# nenašla. Proto čteme stránku po stránce, dokud Gitea hlásí truncated.
TREE_PAGE_SIZE = 100
MAX_TREE_PAGES = 50
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)
tree_url = f"/api/v1/repos/{quote(gitea_org())}/{quote(repo)}/git/trees/{quote(ref, safe='')}"
files = []
seen_entries = 0
for page in range(1, MAX_TREE_PAGES + 1):
payload = _request_json(f"{tree_url}?recursive=true&per_page={TREE_PAGE_SIZE}&page={page}")
entries = payload.get("tree") or []
if not entries:
break
seen_entries += len(entries)
for entry in entries:
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)})
total_count = int(payload.get("total_count") or 0)
if not payload.get("truncated") or (total_count and seen_entries >= total_count):
break
if len(files) >= MAX_DOC_FILES:
logger.info("Repozitář %s má více než %s souborů *.md, seznam je zkrácený.", repo, MAX_DOC_FILES)
break
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, Gitea render umí a zároveň si výstupní HTML sanitizuje.
Náhled ale nesmí skončit prázdný: když render selže NEBO vrátí prázdné tělo (Gitea to
dělá i bez chybové hlášky, např. když si nepřečte pole text), zobrazíme obsah souboru
jako neformátovaný escapovaný text.
"""
if not text.strip():
return '<p class="muted">Soubor je prázdný.</p>'
payload = json.dumps(
{
"text": text,
"mode": "gfm",
"context": f"/{gitea_org()}/{repo}",
}
).encode("utf-8")
rendered = ""
try:
rendered = _request_bytes(
"/api/v1/markdown",
method="POST",
data=payload,
accept="text/html",
content_type="application/json",
).decode("utf-8", errors="replace")
except RepoDocsError as exc:
logger.warning("Gitea markdown render selhal (%s), zobrazuji neformátovaný text: %s", repo, exc)
if not rendered.strip():
logger.warning("Gitea markdown render vrátil prázdný výstup (%s), zobrazuji neformátovaný text.", repo)
return f'<pre class="md-raw">{html.escape(text)}</pre>'
return f'<div class="md-body">{rendered}</div>'
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