diff --git a/app/repo_docs.py b/app/repo_docs.py
new file mode 100644
index 0000000..57adef0
--- /dev/null
+++ b/app/repo_docs.py
@@ -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'
"""
+
+ # Seznam *.md souborů se tahá z Gitea, proto se načítá až po rozbalení sekce (portal.js) -
+ # nedostupná Gitea tak nikdy nezdrží vykreslení detailu.
+ docs_card = render_section(
+ ' Dokumentace',
+ f"""
+
+ render_section(
+ ' Security / IP Access',
+ f"""
Pravidla určují, z jakých IP/CIDR adres lze volat dané HTTP metody.
WRITE = POST, PUT, PATCH, DELETE; ALL = všechny běžné metody včetně GET.
@@ -956,12 +1003,122 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
+
+ """
+
+ return HTMLResponse(items)
+
+
+@router.get("/apps/{app_id}/docs-file", response_class=HTMLResponse)
+def app_docs_file(app_id: str, path: str = Query(""), user=Depends(require_user)):
+ """HTML fragment s obsahem jednoho *.md souboru z repozitáře služby."""
+ app = get_app(app_id)
+ if not app:
+ raise HTTPException(status_code=404, detail="App not found")
+ if not is_markdown_path(path):
+ raise HTTPException(status_code=400, detail="Neplatná cesta k souboru dokumentace")
+
+ repo = (app.get("repository_name") or app_id).strip()
+ branch = (app.get("default_branch") or "").strip()
+ try:
+ text = read_markdown(repo, path, branch)
+ except RepoDocsError as exc:
+ return HTMLResponse(f'
Soubor se nepodařilo načíst: {html.escape(str(exc))}
')
+
+ return HTMLResponse(render_markdown(text, repo))
+
+
@router.post("/apps/{app_id}/metadata")
def save_app_metadata(
app_id: str,
diff --git a/app/static/portal.js b/app/static/portal.js
index 1548c95..22de0a5 100644
--- a/app/static/portal.js
+++ b/app/static/portal.js
@@ -126,6 +126,87 @@ document.addEventListener("click", (event) => {
}
});
+// Sbalitelné sekce detailu služby: odkaz s kotvou (#metadata) musí cílovou sekci i otevřít,
+// jinak by prohlížeč skočil na zavřený a uživatel by neviděl žádný obsah.
+function openHashSection() {
+ let hash = window.location.hash.slice(1);
+ if (!hash) {
+ return;
+ }
+
+ try {
+ hash = decodeURIComponent(hash);
+ } catch (error) {
+ return;
+ }
+
+ const target = document.getElementById(hash);
+ if (!target) {
+ return;
+ }
+
+ let node = target;
+ while (node) {
+ if (node.tagName === "DETAILS") {
+ node.open = true;
+ }
+ node = node.parentElement;
+ }
+ target.scrollIntoView();
+}
+
+window.addEventListener("hashchange", openHashSection);
+document.addEventListener("DOMContentLoaded", openHashSection);
+
+// Obsah načítaný až při rozbalení (seznam .md souborů a jejich náhledy). Načte se jen jednou;
+// při chybě se příznak vrátí, aby šlo zkusit znovu dalším rozbalením.
+function loadLazyPanel(panel) {
+ if (panel.dataset.lazyLoaded === "1") {
+ return;
+ }
+ panel.dataset.lazyLoaded = "1";
+
+ const url = panel.dataset.lazySrc;
+ panel.innerHTML = '