From 1cedd8f219ccd663603a44455ac918c071b37f6a Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 12 Aug 2026 01:56:30 +0200 Subject: [PATCH] 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. --- coulomb_social/apps/spaces/content.py | 191 ++++++++++++++++++ coulomb_social/apps/spaces/forgejo.py | 113 +++++++++++ .../apps/spaces/management/__init__.py | 0 .../spaces/management/commands/__init__.py | 0 .../management/commands/seed_demo_space.py | 64 ++++++ coulomb_social/apps/spaces/views.py | 6 +- coulomb_social/settings/base.py | 7 + coulomb_social/templates/spaces/detail.html | 76 ++++--- docs/space-fixtures/demo/pages/index.md | 23 +++ pyproject.toml | 2 + tests/test_content.py | 111 ++++++++++ uv.lock | 34 ++++ ...SOC-WP-0004-app-shell-and-space-content.md | 5 +- 13 files changed, 606 insertions(+), 26 deletions(-) create mode 100644 coulomb_social/apps/spaces/content.py create mode 100644 coulomb_social/apps/spaces/forgejo.py create mode 100644 coulomb_social/apps/spaces/management/__init__.py create mode 100644 coulomb_social/apps/spaces/management/commands/__init__.py create mode 100644 coulomb_social/apps/spaces/management/commands/seed_demo_space.py create mode 100644 docs/space-fixtures/demo/pages/index.md create mode 100644 tests/test_content.py diff --git a/coulomb_social/apps/spaces/content.py b/coulomb_social/apps/spaces/content.py new file mode 100644 index 0000000..811579b --- /dev/null +++ b/coulomb_social/apps/spaces/content.py @@ -0,0 +1,191 @@ +"""Load and render space markdown pages (Forgejo-backed).""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +import bleach +import markdown as md_lib +from django.conf import settings + +from .forgejo import ContentFetchError, FetchedFile, fetch_raw_file +from .models import Space + +logger = logging.getLogger(__name__) + +_PAGE_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + +# Conservative allowlist for rendered markdown HTML +_ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [ + "p", + "pre", + "code", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "hr", + "br", + "img", + "blockquote", + "ul", + "ol", + "li", + "strong", + "em", + "a", +] +_ALLOWED_ATTRS = { + **bleach.sanitizer.ALLOWED_ATTRIBUTES, + "img": ["src", "alt", "title"], + "a": ["href", "title", "rel"], + "code": ["class"], + "th": ["align"], + "td": ["align"], +} +_ALLOWED_PROTOCOLS = ["http", "https", "mailto"] + + +@dataclass(frozen=True) +class RenderedPage: + slug: str + path: str + title: str + html: str + source: str + error: str | None = None + + +def normalize_page_slug(page: str | None) -> str: + slug = (page or "index").strip().lower() or "index" + if slug.endswith(".md"): + slug = slug[: -len(".md")] + if slug != "index" and not _PAGE_SLUG_RE.match(slug): + raise ContentFetchError("Invalid page slug") + return slug + + +def page_path_for(space: Space, page_slug: str) -> str: + root = (space.content_root or "pages").strip().strip("/") + filename = "index.md" if page_slug == "index" else f"{page_slug}.md" + return f"{root}/{filename}" if root else filename + + +def render_markdown(text: str) -> str: + raw_html = md_lib.markdown( + text, + extensions=["fenced_code", "tables", "nl2br", "sane_lists"], + output_format="html", + ) + return bleach.clean( + raw_html, + tags=_ALLOWED_TAGS, + attributes=_ALLOWED_ATTRS, + protocols=_ALLOWED_PROTOCOLS, + strip=True, + ) + + +def _title_from_markdown(text: str, fallback: str) -> str: + for line in text.splitlines(): + line = line.strip() + if line.startswith("# "): + return line[2:].strip() or fallback + return fallback + + +def load_space_page(space: Space, page: str | None = None) -> RenderedPage: + """Fetch and render a page for a space. Fail closed with error field set.""" + try: + page_slug = normalize_page_slug(page) + path = page_path_for(space, page_slug) + except ContentFetchError as exc: + return RenderedPage( + slug=page or "index", + path="", + title=space.title, + html="", + source="", + error=exc.message, + ) + + if not space.has_content_binding: + return RenderedPage( + slug=page_slug, + path=path, + title=space.title, + html="", + source="", + error="This space is not bound to a Forgejo repository yet.", + ) + + try: + fetched = _fetch(space, path) + html = render_markdown(fetched.text) + title = _title_from_markdown(fetched.text, space.title) + return RenderedPage( + slug=page_slug, + path=path, + title=title, + html=html, + source=fetched.source, + error=None, + ) + except ContentFetchError as exc: + return RenderedPage( + slug=page_slug, + path=path, + title=space.title, + html="", + source="", + error=exc.message, + ) + + +def _fetch(space: Space, path: str) -> FetchedFile: + # Optional local fixture root for offline tests / air-gapped demos + fixture_root = (getattr(settings, "SPACE_CONTENT_FIXTURE_ROOT", None) or "").strip() + if fixture_root: + local = Path(fixture_root) / space.slug / path + if local.is_file(): + return FetchedFile(path=path, text=local.read_text(encoding="utf-8"), source="fixture") + + cache_key = ( + space.forgejo_owner, + space.forgejo_repo, + space.default_branch or "main", + path, + getattr(settings, "FORGEJO_BASE_URL", ""), + ) + hit = _FETCH_CACHE.get(cache_key) + if hit is not None: + return hit + fetched = fetch_raw_file( + owner=space.forgejo_owner, + repo=space.forgejo_repo, + ref=space.default_branch or "main", + path=path, + ) + # Cache successes only (avoid sticky 404s while authoring) + if len(_FETCH_CACHE) > 128: + _FETCH_CACHE.clear() + _FETCH_CACHE[cache_key] = fetched + return fetched + + +_FETCH_CACHE: dict[tuple[str, str, str, str, str], FetchedFile] = {} + + +def clear_content_cache() -> None: + _FETCH_CACHE.clear() diff --git a/coulomb_social/apps/spaces/forgejo.py b/coulomb_social/apps/spaces/forgejo.py new file mode 100644 index 0000000..876abf6 --- /dev/null +++ b/coulomb_social/apps/spaces/forgejo.py @@ -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") diff --git a/coulomb_social/apps/spaces/management/__init__.py b/coulomb_social/apps/spaces/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coulomb_social/apps/spaces/management/commands/__init__.py b/coulomb_social/apps/spaces/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coulomb_social/apps/spaces/management/commands/seed_demo_space.py b/coulomb_social/apps/spaces/management/commands/seed_demo_space.py new file mode 100644 index 0000000..0691975 --- /dev/null +++ b/coulomb_social/apps/spaces/management/commands/seed_demo_space.py @@ -0,0 +1,64 @@ +"""Seed a demo Space bound to the in-repo Forgejo fixture path (public raw).""" + +from __future__ import annotations + +from django.conf import settings +from django.core.management.base import BaseCommand + +from coulomb_social.apps.spaces.models import Space + + +class Command(BaseCommand): + help = ( + "Create or update the demo space (tenant from DEFAULT_TENANT_ID) bound to " + "coulomb/coulomb-social docs/space-fixtures/demo/pages for T04 smoke." + ) + + def add_arguments(self, parser): + parser.add_argument("--slug", default="demo") + parser.add_argument("--title", default="Demo space") + parser.add_argument( + "--tenant", + default="", + help="Defaults to DEFAULT_TENANT_ID", + ) + parser.add_argument( + "--owner", + default="coulomb", + help="Forgejo owner", + ) + parser.add_argument( + "--repo", + default="coulomb-social", + help="Forgejo repo containing fixture markdown", + ) + parser.add_argument( + "--content-root", + default="docs/space-fixtures/demo/pages", + ) + parser.add_argument("--branch", default="main") + + def handle(self, *args, **options): + tenant = options["tenant"] or settings.DEFAULT_TENANT_ID + slug = options["slug"] + space, created = Space.objects.update_or_create( + tenant_id=tenant, + slug=slug, + defaults={ + "title": options["title"], + "description": "Seeded demo space with markdown from Forgejo (CSOC-WP-0004-T04).", + "forgejo_owner": options["owner"], + "forgejo_repo": options["repo"], + "default_branch": options["branch"], + "content_root": options["content_root"], + "is_active": True, + }, + ) + action = "Created" if created else "Updated" + self.stdout.write( + self.style.SUCCESS( + f"{action} space {space.slug} tenant={space.tenant_id} " + f"→ {space.forgejo_owner}/{space.forgejo_repo} " + f"({space.content_root}/index.md @ {space.default_branch})" + ) + ) diff --git a/coulomb_social/apps/spaces/views.py b/coulomb_social/apps/spaces/views.py index 2d5a6f9..2088a75 100644 --- a/coulomb_social/apps/spaces/views.py +++ b/coulomb_social/apps/spaces/views.py @@ -4,6 +4,7 @@ from django.shortcuts import render from coulomb_social.apps.core.principal import build_principal +from .content import load_space_page from .services import get_space_for_member, spaces_for_member @@ -18,6 +19,9 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse: space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") + + page = request.GET.get("page") or "index" + rendered = load_space_page(space, page) return render( request, "spaces/detail.html", @@ -25,10 +29,10 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse: "principal": principal, "display_name": principal["display_name"], "space": space, + "page": rendered, }, ) -# used by core.app_home — re-export list helper def list_spaces_context(member) -> dict: return {"spaces": list(spaces_for_member(member))} diff --git a/coulomb_social/settings/base.py b/coulomb_social/settings/base.py index 951758a..3c6fd5f 100644 --- a/coulomb_social/settings/base.py +++ b/coulomb_social/settings/base.py @@ -119,3 +119,10 @@ FLEX_AUTH_PROTECTED_SYSTEM_ID = config( "FLEX_AUTH_PROTECTED_SYSTEM_ID", default="coulomb-social" ) FLEX_AUTH_TIMEOUT_SECONDS = config("FLEX_AUTH_TIMEOUT_SECONDS", default=3.0, cast=float) + +# ── Space content (Forgejo / ADR-0002) ────────────────────────────────────── +FORGEJO_BASE_URL = config("FORGEJO_BASE_URL", default="https://forgejo.coulomb.social") +FORGEJO_TOKEN = config("FORGEJO_TOKEN", default="") # optional; public raw needs none +FORGEJO_TIMEOUT_SECONDS = config("FORGEJO_TIMEOUT_SECONDS", default=10.0, cast=float) +# Optional local root for offline tests: ///index.md +SPACE_CONTENT_FIXTURE_ROOT = config("SPACE_CONTENT_FIXTURE_ROOT", default="") diff --git a/coulomb_social/templates/spaces/detail.html b/coulomb_social/templates/spaces/detail.html index c369378..b2aa5ee 100644 --- a/coulomb_social/templates/spaces/detail.html +++ b/coulomb_social/templates/spaces/detail.html @@ -1,5 +1,35 @@ {% extends "base.html" %} -{% block title %}{{ space.title }} — {{ site_name }}{% endblock %} +{% block title %}{{ page.title|default:space.title }} — {{ site_name }}{% endblock %} +{% block extra_head %} + +{% endblock %} {% block content %}

← Spaces @@ -9,28 +39,26 @@

{{ space.description }}

{% endif %} -
-

Space metadata

-
-
Slug
{{ space.slug }}
-
Tenant
{{ space.tenant_id }}
-
Content
-
- {% if space.has_content_binding %} - {{ space.forgejo_owner }}/{{ space.forgejo_repo }} - @ {{ space.default_branch }} - · root {{ space.content_root }} - {% else %} - Not bound to a Forgejo repo yet (CSOC-WP-0004-T04). - {% endif %} -
-
-
- -
-

Pages

-

- Markdown page rendering from Forgejo lands in T04. This view is metadata only. + {% if page.error %} +

+

Content unavailable

+

{{ page.error }}

+ {% if space.has_content_binding %} + + {% endif %} +
+ {% else %} +
+ {{ page.html|safe }} +
+ -
+ {% endif %} {% endblock %} diff --git a/docs/space-fixtures/demo/pages/index.md b/docs/space-fixtures/demo/pages/index.md new file mode 100644 index 0000000..db9ef1f --- /dev/null +++ b/docs/space-fixtures/demo/pages/index.md @@ -0,0 +1,23 @@ +# Demo space + +Welcome to the **coulomb.social** rebuild on Railiance. + +This page is markdown stored in git (Forgejo) and rendered by the app +(`CSOC-WP-0004-T04`). + +## What you are seeing + +| Layer | Source | +|-------|--------| +| Membership / shell | NetKingdom OIDC + app session | +| Space metadata | Postgres `spaces_space` | +| Page body | This file in `coulomb/coulomb-social` | + +## Next + +- Bind real product spaces to dedicated Forgejo repos (ADR-0002). +- Edit content in git; refresh the app to re-fetch (short cache TTL). + +```python +print("agents can edit this content in git") +``` diff --git a/pyproject.toml b/pyproject.toml index bfec3d1..7bcef01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "gunicorn>=22.0", "authlib>=1.3", "httpx>=0.27", + "markdown>=3.10.3", + "bleach>=6.4.0", ] [dependency-groups] diff --git a/tests/test_content.py b/tests/test_content.py new file mode 100644 index 0000000..2175ba5 --- /dev/null +++ b/tests/test_content.py @@ -0,0 +1,111 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse + +from coulomb_social.apps.members.models import Member +from coulomb_social.apps.spaces.content import clear_content_cache, load_space_page, render_markdown +from coulomb_social.apps.spaces.forgejo import ContentFetchError, FetchedFile +from coulomb_social.apps.spaces.models import Space + + +def test_render_markdown_strips_script(): + html = render_markdown("# Hi\n\n\n\n**bold**") + assert "