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
191
coulomb_social/apps/spaces/content.py
Normal file
191
coulomb_social/apps/spaces/content.py
Normal file
|
|
@ -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()
|
||||||
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")
|
||||||
0
coulomb_social/apps/spaces/management/__init__.py
Normal file
0
coulomb_social/apps/spaces/management/__init__.py
Normal file
|
|
@ -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})"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -4,6 +4,7 @@ from django.shortcuts import render
|
||||||
|
|
||||||
from coulomb_social.apps.core.principal import build_principal
|
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
|
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)
|
space = get_space_for_member(member, slug)
|
||||||
if space is None:
|
if space is None:
|
||||||
return HttpResponseNotFound("Space not found.")
|
return HttpResponseNotFound("Space not found.")
|
||||||
|
|
||||||
|
page = request.GET.get("page") or "index"
|
||||||
|
rendered = load_space_page(space, page)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"spaces/detail.html",
|
"spaces/detail.html",
|
||||||
|
|
@ -25,10 +29,10 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
|
||||||
"principal": principal,
|
"principal": principal,
|
||||||
"display_name": principal["display_name"],
|
"display_name": principal["display_name"],
|
||||||
"space": space,
|
"space": space,
|
||||||
|
"page": rendered,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# used by core.app_home — re-export list helper
|
|
||||||
def list_spaces_context(member) -> dict:
|
def list_spaces_context(member) -> dict:
|
||||||
return {"spaces": list(spaces_for_member(member))}
|
return {"spaces": list(spaces_for_member(member))}
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,10 @@ FLEX_AUTH_PROTECTED_SYSTEM_ID = config(
|
||||||
"FLEX_AUTH_PROTECTED_SYSTEM_ID", default="coulomb-social"
|
"FLEX_AUTH_PROTECTED_SYSTEM_ID", default="coulomb-social"
|
||||||
)
|
)
|
||||||
FLEX_AUTH_TIMEOUT_SECONDS = config("FLEX_AUTH_TIMEOUT_SECONDS", default=3.0, cast=float)
|
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: <root>/<space-slug>/<content_root>/index.md
|
||||||
|
SPACE_CONTENT_FIXTURE_ROOT = config("SPACE_CONTENT_FIXTURE_ROOT", default="")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,35 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ space.title }} — {{ site_name }}{% endblock %}
|
{% block title %}{{ page.title|default:space.title }} — {{ site_name }}{% endblock %}
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.md-body h1 { font-size: 1.75rem; margin-top: 0; }
|
||||||
|
.md-body h2 { font-size: 1.25rem; margin-top: 1.5rem; }
|
||||||
|
.md-body pre {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.md-body code { font-family: ui-monospace, monospace; font-size: 0.9em; }
|
||||||
|
.md-body table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
|
||||||
|
.md-body th, .md-body td {
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.md-body blockquote {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.content-meta { font-size: 0.85rem; margin-top: 0.5rem; }
|
||||||
|
.content-error {
|
||||||
|
border-color: #fcd34d;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="muted" style="margin:0 0 0.5rem;">
|
<p class="muted" style="margin:0 0 0.5rem;">
|
||||||
<a href="{% url 'core:app_home' %}">← Spaces</a>
|
<a href="{% url 'core:app_home' %}">← Spaces</a>
|
||||||
|
|
@ -9,28 +39,26 @@
|
||||||
<p class="muted">{{ space.description }}</p>
|
<p class="muted">{{ space.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
{% if page.error %}
|
||||||
<h2 style="margin-top:0;">Space metadata</h2>
|
<div class="card content-error">
|
||||||
<dl>
|
<h2 style="margin-top:0;">Content unavailable</h2>
|
||||||
<dt>Slug</dt><dd>{{ space.slug }}</dd>
|
<p style="margin-bottom:0;">{{ page.error }}</p>
|
||||||
<dt>Tenant</dt><dd>{{ space.tenant_id }}</dd>
|
{% if space.has_content_binding %}
|
||||||
<dt>Content</dt>
|
<p class="content-meta muted">
|
||||||
<dd>
|
Binding: {{ space.forgejo_owner }}/{{ space.forgejo_repo }}
|
||||||
{% if space.has_content_binding %}
|
· {{ page.path }} @ {{ space.default_branch }}
|
||||||
{{ space.forgejo_owner }}/{{ space.forgejo_repo }}
|
</p>
|
||||||
@ {{ space.default_branch }}
|
{% endif %}
|
||||||
· root <code>{{ space.content_root }}</code>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
Not bound to a Forgejo repo yet (CSOC-WP-0004-T04).
|
<div class="card md-body">
|
||||||
{% endif %}
|
{{ page.html|safe }}
|
||||||
</dd>
|
</div>
|
||||||
</dl>
|
<p class="content-meta muted">
|
||||||
</div>
|
Source: {{ page.source }} · {{ page.path }}
|
||||||
|
{% if space.has_content_binding %}
|
||||||
<div class="card">
|
· {{ space.forgejo_owner }}/{{ space.forgejo_repo }}@{{ space.default_branch }}
|
||||||
<h2 style="margin-top:0;">Pages</h2>
|
{% endif %}
|
||||||
<p class="muted" style="margin-bottom:0;">
|
|
||||||
Markdown page rendering from Forgejo lands in T04. This view is metadata only.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
23
docs/space-fixtures/demo/pages/index.md
Normal file
23
docs/space-fixtures/demo/pages/index.md
Normal file
|
|
@ -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")
|
||||||
|
```
|
||||||
|
|
@ -12,6 +12,8 @@ dependencies = [
|
||||||
"gunicorn>=22.0",
|
"gunicorn>=22.0",
|
||||||
"authlib>=1.3",
|
"authlib>=1.3",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"markdown>=3.10.3",
|
||||||
|
"bleach>=6.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
111
tests/test_content.py
Normal file
111
tests/test_content.py
Normal file
|
|
@ -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<script>alert(1)</script>\n\n**bold**")
|
||||||
|
assert "<script>" not in html
|
||||||
|
assert "bold" in html
|
||||||
|
assert "Hi" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_load_space_page_unbound():
|
||||||
|
space = Space(tenant_id="t", slug="x", title="X")
|
||||||
|
page = load_space_page(space)
|
||||||
|
assert page.error
|
||||||
|
assert "not bound" in page.error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_load_space_page_fetches_and_renders():
|
||||||
|
clear_content_cache()
|
||||||
|
space = Space(
|
||||||
|
tenant_id="t",
|
||||||
|
slug="demo",
|
||||||
|
title="Demo",
|
||||||
|
forgejo_owner="coulomb",
|
||||||
|
forgejo_repo="coulomb-social",
|
||||||
|
default_branch="main",
|
||||||
|
content_root="docs/space-fixtures/demo/pages",
|
||||||
|
)
|
||||||
|
fake = FetchedFile(
|
||||||
|
path="docs/space-fixtures/demo/pages/index.md",
|
||||||
|
text="# Hello demo\n\nParagraph.\n",
|
||||||
|
source="forgejo-raw",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"coulomb_social.apps.spaces.content.fetch_raw_file", return_value=fake
|
||||||
|
) as mocked:
|
||||||
|
page = load_space_page(space, "index")
|
||||||
|
mocked.assert_called_once()
|
||||||
|
assert page.error is None
|
||||||
|
assert page.title == "Hello demo"
|
||||||
|
assert "Paragraph" in page.html
|
||||||
|
assert page.source == "forgejo-raw"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_load_space_page_fail_closed():
|
||||||
|
clear_content_cache()
|
||||||
|
space = Space(
|
||||||
|
tenant_id="t",
|
||||||
|
slug="demo",
|
||||||
|
title="Demo",
|
||||||
|
forgejo_owner="o",
|
||||||
|
forgejo_repo="r",
|
||||||
|
content_root="pages",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"coulomb_social.apps.spaces.content.fetch_raw_file",
|
||||||
|
side_effect=ContentFetchError("File not found: pages/index.md", status_code=404),
|
||||||
|
):
|
||||||
|
page = load_space_page(space)
|
||||||
|
assert page.error
|
||||||
|
assert "not found" in page.error.lower()
|
||||||
|
assert page.html == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_space_detail_renders_content(client, settings):
|
||||||
|
clear_content_cache()
|
||||||
|
settings.DEBUG = True
|
||||||
|
settings.OIDC_ENABLED = False
|
||||||
|
client.post(
|
||||||
|
reverse("identity:dev_login"),
|
||||||
|
{
|
||||||
|
"subject": "reader",
|
||||||
|
"issuer": "https://local.dev/issuer",
|
||||||
|
"name": "Reader",
|
||||||
|
"tenant": "tenant:coulomb",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
member = Member.objects.get(subject="reader")
|
||||||
|
Space.objects.create(
|
||||||
|
tenant_id="tenant:coulomb",
|
||||||
|
slug="demo",
|
||||||
|
title="Demo",
|
||||||
|
created_by=member,
|
||||||
|
forgejo_owner="coulomb",
|
||||||
|
forgejo_repo="coulomb-social",
|
||||||
|
content_root="docs/space-fixtures/demo/pages",
|
||||||
|
)
|
||||||
|
fake = FetchedFile(
|
||||||
|
path="docs/space-fixtures/demo/pages/index.md",
|
||||||
|
text="# From Forgejo\n\nVisible body.\n",
|
||||||
|
source="forgejo-raw",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"coulomb_social.apps.spaces.content.fetch_raw_file", return_value=fake
|
||||||
|
):
|
||||||
|
r = client.get(reverse("spaces:detail", kwargs={"slug": "demo"}))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert b"From Forgejo" in r.content
|
||||||
|
assert b"Visible body" in r.content
|
||||||
34
uv.lock
generated
34
uv.lock
generated
|
|
@ -36,6 +36,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548 },
|
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bleach"
|
||||||
|
version = "6.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "webencodings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.7.22"
|
version = "2026.7.22"
|
||||||
|
|
@ -145,10 +157,12 @@ version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
|
{ name = "bleach" },
|
||||||
{ name = "dj-database-url" },
|
{ name = "dj-database-url" },
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "markdown" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "python-decouple" },
|
{ name = "python-decouple" },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
|
|
@ -165,10 +179,12 @@ dev = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "authlib", specifier = ">=1.3" },
|
{ name = "authlib", specifier = ">=1.3" },
|
||||||
|
{ name = "bleach", specifier = ">=6.4.0" },
|
||||||
{ name = "dj-database-url", specifier = ">=2.1" },
|
{ name = "dj-database-url", specifier = ">=2.1" },
|
||||||
{ name = "django", specifier = ">=5.2" },
|
{ name = "django", specifier = ">=5.2" },
|
||||||
{ name = "gunicorn", specifier = ">=22.0" },
|
{ name = "gunicorn", specifier = ">=22.0" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "markdown", specifier = ">=3.10.3" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
||||||
{ name = "python-decouple", specifier = ">=3.8" },
|
{ name = "python-decouple", specifier = ">=3.8" },
|
||||||
{ name = "whitenoise", specifier = ">=6.7" },
|
{ name = "whitenoise", specifier = ">=6.7" },
|
||||||
|
|
@ -436,6 +452,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000 },
|
{ url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown"
|
||||||
|
version = "3.10.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.3"
|
version = "26.3"
|
||||||
|
|
@ -633,6 +658,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168 },
|
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webencodings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whitenoise"
|
name = "whitenoise"
|
||||||
version = "6.12.0"
|
version = "6.12.0"
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ per space, `pages/` root, Forgejo API read for T04; write-in-Forgejo for T05.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CSOC-WP-0004-T04
|
id: CSOC-WP-0004-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "77fa3454-6f2e-4a76-8a0c-ff7e13ca4b6a"
|
state_hub_task_id: "77fa3454-6f2e-4a76-8a0c-ff7e13ca4b6a"
|
||||||
```
|
```
|
||||||
|
|
@ -138,6 +138,9 @@ Implement a vertical slice:
|
||||||
**Done when:** tegwick can open a space on app.coulomb.social and see rendered
|
**Done when:** tegwick can open a space on app.coulomb.social and see rendered
|
||||||
markdown sourced from Forgejo (not Bubble).
|
markdown sourced from Forgejo (not Bubble).
|
||||||
|
|
||||||
|
2026-08-12: Forgejo raw fetch + bleach-sanitized markdown render; demo fixture
|
||||||
|
at `docs/space-fixtures/demo/pages/index.md`; `seed_demo_space` management
|
||||||
|
command; env `FORGEJO_BASE_URL` / optional `FORGEJO_TOKEN`.
|
||||||
## T05 — Write / sync path (minimal)
|
## T05 — Write / sync path (minimal)
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue