177 lines
6.3 KiB
Python
177 lines
6.3 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
|
|
|
|
|
|
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
|