"""Чтение README.md и безопасное чтение ``app/docs/*.md`` для API документации. Разметка Markdown преобразуется в браузере: ``/static/js/vendor/marked.min.js`` и ``purify.min.js`` (файлы входят в репозиторий, без CDN). README: ``KIND_K8S_README_PATH`` или ``README.md`` в корне рядом с ``app/``; в Docker-образе — ``/opt/kind-k8s/README.md``. Файлы под ``app/docs/`` — через ``resolve_app_docs_markdown`` / ``read_app_docs_file_text`` (эндпоинт ``GET /api/v1/docs/file``). Автор: Сергей Антропов Сайт: https://devops.org.ru """ from __future__ import annotations import logging import os from pathlib import Path logger = logging.getLogger("kind_k8s.readme_doc") # app/core/readme_doc.py: parents[2] = корень репозитория (рядом с app/) или /opt/kind-k8s в образе _LIB_FILE = Path(__file__).resolve() def _candidates_without_env() -> list[Path]: """ Возможные пути к README без KIND_K8S_README_PATH. Порядок: родитель каталога app/ (типично репозиторий), затем фиксированный путь образа. В compose рекомендуется монтировать ./README.md → /opt/kind-k8s/README.md (см. docker-compose.yml). """ out: list[Path] = [] seen: set[Path] = set() try: repo_readme = (_LIB_FILE.parents[2] / "README.md").resolve() if repo_readme not in seen: seen.add(repo_readme) out.append(repo_readme) except (IndexError, OSError): pass fixed = Path("/opt/kind-k8s/README.md") try: fixed_r = fixed.resolve() if fixed_r not in seen: seen.add(fixed_r) out.append(fixed_r) except OSError: out.append(fixed) return out def get_readme_path() -> Path | None: """Первый существующий путь к README или ``None``.""" raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip() if raw: p = Path(raw).expanduser().resolve() return p if p.is_file() else None for p in _candidates_without_env(): if p.is_file(): return p return None def read_readme_text() -> str: """Прочитать README как UTF-8; ``FileNotFoundError`` если файла нет.""" raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip() if raw: p = Path(raw).expanduser().resolve() if not p.is_file(): logger.warning("KIND_K8S_README_PATH: файл не найден: %s", p) raise FileNotFoundError(str(p)) text = p.read_text(encoding="utf-8") logger.debug("README из KIND_K8S_README_PATH, %s символов", len(text)) return text for p in _candidates_without_env(): if p.is_file(): text = p.read_text(encoding="utf-8") logger.info("README прочитан: %s (%s символов)", p, len(text)) return text logger.warning( "README.md не найден. Проверены пути: %s. " "В Docker Compose добавьте монтирование ./README.md:/opt/kind-k8s/README.md " "или пересоберите образ (COPY README.md в Dockerfile).", [str(x) for x in _candidates_without_env()], ) raise FileNotFoundError("README.md") def repo_root() -> Path: """Корень репозитория (родитель каталога ``app/``).""" return _LIB_FILE.parents[2] def resolve_app_docs_markdown(relative_path: str) -> Path | None: """ Безопасно разрешить путь к ``.md`` только внутри ``app/docs/``. Ожидается вид ``app/docs/api_routes.md`` (без ``..`` и абсолютных путей). """ raw = (relative_path or "").strip().lstrip("/").replace("\\", "/") if not raw or ".." in raw or "\x00" in raw: return None if not raw.endswith(".md"): return None if not raw.startswith("app/docs/"): return None base = repo_root() try: full = (base / raw).resolve() allowed = (base / "app" / "docs").resolve() full.relative_to(allowed) except (ValueError, OSError): return None if not full.is_file(): return None return full def read_app_docs_file_text(relative_path: str) -> str: """Прочитать UTF-8 текст файла под ``app/docs/``; ``FileNotFoundError`` если путь недопустим или файла нет.""" p = resolve_app_docs_markdown(relative_path) if p is None: raise FileNotFoundError(relative_path) text = p.read_text(encoding="utf-8") logger.debug("Документ app/docs: %s, %s символов", p, len(text)) return text