"""Fetch raw files from Forgejo (Gitea-compatible) HTTP API / raw URLs.""" from __future__ import annotations import logging from dataclasses import dataclass from typing import Any from urllib.parse import quote import httpx from django.conf import settings logger = logging.getLogger(__name__) class ContentFetchError(Exception): """Fail-closed content load error (safe message for UI).""" def __init__(self, message: str, *, status_code: int | None = None): super().__init__(message) self.message = message self.status_code = status_code @dataclass(frozen=True) class FetchedFile: path: str text: str source: str # forgejo-raw | forgejo-api | fixture def _auth_headers() -> dict[str, str]: token = (getattr(settings, "FORGEJO_TOKEN", None) or "").strip() if not token: return {} return {"Authorization": f"token {token}"} def fetch_raw_file( *, owner: str, repo: str, ref: str, path: str, timeout: float | None = None, ) -> FetchedFile: """GET raw file content from Forgejo. Prefer public/raw URL (works without token for public repos). Fall back to contents API when raw fails and a token is configured. """ base = (getattr(settings, "FORGEJO_BASE_URL", None) or "").rstrip("/") if not base: raise ContentFetchError("FORGEJO_BASE_URL is not configured") timeout = timeout if timeout is not None else float( getattr(settings, "FORGEJO_TIMEOUT_SECONDS", 10.0) ) # Raw URL path segments path_enc = "/".join(quote(p, safe="") for p in path.strip("/").split("/") if p) raw_url = f"{base}/{quote(owner)}/{quote(repo)}/raw/branch/{quote(ref, safe='')}/{path_enc}" try: with httpx.Client(timeout=timeout, follow_redirects=True) as client: resp = client.get(raw_url, headers=_auth_headers()) if resp.status_code == 200: return FetchedFile(path=path, text=resp.text, source="forgejo-raw") if resp.status_code in (401, 403) and _auth_headers(): return _fetch_via_contents_api( client, base=base, owner=owner, repo=repo, ref=ref, path=path ) if resp.status_code == 404: raise ContentFetchError(f"File not found: {path}", status_code=404) raise ContentFetchError( f"Forgejo returned HTTP {resp.status_code} for {path}", status_code=resp.status_code, ) except ContentFetchError: raise except httpx.HTTPError as exc: logger.exception("Forgejo fetch failed for %s/%s %s", owner, repo, path) raise ContentFetchError("Could not reach Forgejo content store") from exc def _fetch_via_contents_api( client: httpx.Client, *, base: str, owner: str, repo: str, ref: str, path: str, ) -> FetchedFile: """Gitea/Forgejo contents API returns base64 content for a file.""" import base64 import json path_enc = "/".join(quote(p, safe="") for p in path.strip("/").split("/") if p) api = f"{base}/api/v1/repos/{quote(owner)}/{quote(repo)}/contents/{path_enc}" resp = client.get(api, params={"ref": ref}, headers=_auth_headers()) if resp.status_code != 200: raise ContentFetchError( f"Forgejo API returned HTTP {resp.status_code} for {path}", status_code=resp.status_code, ) payload: dict[str, Any] = resp.json() if payload.get("type") != "file": raise ContentFetchError(f"Path is not a file: {path}") encoded = payload.get("content") or "" # API may wrap base64 with newlines raw = base64.b64decode(encoded) text = raw.decode("utf-8") return FetchedFile(path=path, text=text, source="forgejo-api")