Render space pages from Forgejo markdown (CSOC-WP-0004-T04)
Fetch raw files from Forgejo, sanitize markdown to HTML, and show them on space detail. Ship a public demo fixture path and seed_demo_space command.
This commit is contained in:
parent
86b54243b8
commit
1cedd8f219
13 changed files with 606 additions and 26 deletions
113
coulomb_social/apps/spaces/forgejo.py
Normal file
113
coulomb_social/apps/spaces/forgejo.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue