diff --git a/coulomb_social/apps/spaces/content.py b/coulomb_social/apps/spaces/content.py index 811579b..741dddb 100644 --- a/coulomb_social/apps/spaces/content.py +++ b/coulomb_social/apps/spaces/content.py @@ -189,3 +189,56 @@ _FETCH_CACHE: dict[tuple[str, str, str, str, str], FetchedFile] = {} def clear_content_cache() -> None: _FETCH_CACHE.clear() + + +def clear_content_cache_for_repo(owner: str, repo: str) -> int: + """Drop cached files for one Forgejo repo. Returns number of entries removed.""" + owner = (owner or "").strip() + repo = (repo or "").strip() + if not owner or not repo: + return 0 + victims = [k for k in _FETCH_CACHE if k[0] == owner and k[1] == repo] + for key in victims: + del _FETCH_CACHE[key] + return len(victims) + + +def clear_content_cache_for_space(space: Space) -> int: + if not space.has_content_binding: + return 0 + return clear_content_cache_for_repo(space.forgejo_owner, space.forgejo_repo) + + +def forgejo_repo_url(space: Space) -> str: + base = (getattr(settings, "FORGEJO_BASE_URL", None) or "").rstrip("/") + if not space.has_content_binding or not base: + return "" + return f"{base}/{space.forgejo_owner}/{space.forgejo_repo}" + + +def forgejo_edit_url(space: Space, page: str | None = None) -> str: + """Deep-link to Forgejo's file editor for the page markdown (git remains SoR).""" + base = forgejo_repo_url(space) + if not base: + return "" + try: + page_slug = normalize_page_slug(page) + path = page_path_for(space, page_slug) + except ContentFetchError: + return base + branch = space.default_branch or "main" + # Gitea/Forgejo: /{owner}/{repo}/_edit/{branch}/{path} + return f"{base}/_edit/{branch}/{path}" + + +def forgejo_blob_url(space: Space, page: str | None = None) -> str: + base = forgejo_repo_url(space) + if not base: + return "" + try: + page_slug = normalize_page_slug(page) + path = page_path_for(space, page_slug) + except ContentFetchError: + return base + branch = space.default_branch or "main" + return f"{base}/src/branch/{branch}/{path}" diff --git a/coulomb_social/apps/spaces/urls.py b/coulomb_social/apps/spaces/urls.py index 3b4e84a..a03b63e 100644 --- a/coulomb_social/apps/spaces/urls.py +++ b/coulomb_social/apps/spaces/urls.py @@ -5,5 +5,11 @@ from . import views app_name = "spaces" urlpatterns = [ + path( + "hooks/forgejo/", + views.forgejo_content_webhook, + name="forgejo_webhook", + ), + path("/refresh/", views.space_refresh, name="refresh"), path("/", views.space_detail, name="detail"), ] diff --git a/coulomb_social/apps/spaces/views.py b/coulomb_social/apps/spaces/views.py index 2088a75..a2c7dcd 100644 --- a/coulomb_social/apps/spaces/views.py +++ b/coulomb_social/apps/spaces/views.py @@ -1,12 +1,37 @@ +import hashlib +import hmac +import json +import logging + +from django.conf import settings +from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, HttpResponseNotFound -from django.shortcuts import render +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseForbidden, + HttpResponseNotFound, + JsonResponse, +) +from django.shortcuts import redirect, render +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST from coulomb_social.apps.core.principal import build_principal -from .content import load_space_page +from .content import ( + clear_content_cache_for_repo, + clear_content_cache_for_space, + forgejo_blob_url, + forgejo_edit_url, + forgejo_repo_url, + load_space_page, +) +from .models import Space from .services import get_space_for_member, spaces_for_member +logger = logging.getLogger(__name__) + @login_required def space_detail(request: HttpRequest, slug: str) -> HttpResponse: @@ -30,9 +55,122 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse: "display_name": principal["display_name"], "space": space, "page": rendered, + "forgejo_repo_url": forgejo_repo_url(space), + "forgejo_edit_url": forgejo_edit_url(space, page), + "forgejo_blob_url": forgejo_blob_url(space, page), }, ) +@login_required +@require_POST +def space_refresh(request: HttpRequest, slug: str) -> HttpResponse: + """Drop cache for this space's Forgejo binding and re-show the page.""" + principal = build_principal(request, authz_resource_id="space_refresh") + if not principal["authz_allow"]: + return HttpResponseForbidden("Not authorized.") + member = principal.get("member") + space = get_space_for_member(member, slug) + if space is None: + return HttpResponseNotFound("Space not found.") + n = clear_content_cache_for_space(space) + messages.success( + request, + f"Content cache cleared ({n} entries). Fresh fetch on this page load.", + ) + page = request.POST.get("page") or request.GET.get("page") or "index" + from django.urls import reverse + + target = reverse("spaces:detail", kwargs={"slug": slug}) + if page and page != "index": + target = f"{target}?page={page}" + return redirect(target) + + +@csrf_exempt +@require_POST +def forgejo_content_webhook(request: HttpRequest) -> JsonResponse: + """Forgejo/Gitea push webhook → invalidate content cache for matching spaces. + + Auth: header ``X-Coulomb-Webhook-Secret`` or ``X-Gitea-Signature`` (HMAC-SHA256 + of body with FORGEJO_WEBHOOK_SECRET) or shared secret query (discouraged). + Configure in Forgejo: repository → Webhooks → Gitea (JSON) → push events. + """ + secret = (getattr(settings, "FORGEJO_WEBHOOK_SECRET", None) or "").strip() + if not secret: + return JsonResponse( + {"ok": False, "error": "webhook not configured"}, + status=503, + ) + if not _webhook_authorized(request, secret): + return JsonResponse({"ok": False, "error": "unauthorized"}, status=401) + + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + return JsonResponse({"ok": False, "error": "invalid json"}, status=400) + + owner, repo = _repo_from_push_payload(payload) + if not owner or not repo: + return JsonResponse({"ok": False, "error": "no repository in payload"}, status=400) + + cleared = clear_content_cache_for_repo(owner, repo) + matched = Space.objects.filter( + forgejo_owner__iexact=owner, + forgejo_repo__iexact=repo, + is_active=True, + ).count() + logger.info( + "Forgejo webhook cache invalidate owner=%s repo=%s cleared=%s spaces=%s", + owner, + repo, + cleared, + matched, + ) + return JsonResponse( + { + "ok": True, + "owner": owner, + "repo": repo, + "cache_entries_cleared": cleared, + "spaces_matched": matched, + } + ) + + def list_spaces_context(member) -> dict: return {"spaces": list(spaces_for_member(member))} + + +def _webhook_authorized(request: HttpRequest, secret: str) -> bool: + header_secret = request.headers.get("X-Coulomb-Webhook-Secret", "") + if header_secret and hmac.compare_digest(header_secret, secret): + return True + # Gitea/Forgejo HMAC-SHA256 hex of body + sig = request.headers.get("X-Gitea-Signature") or request.headers.get( + "X-Hub-Signature-256", "" + ) + if sig.startswith("sha256="): + sig = sig[len("sha256=") :] + if sig: + digest = hmac.new( + secret.encode("utf-8"), request.body, hashlib.sha256 + ).hexdigest() + if hmac.compare_digest(digest, sig): + return True + return False + + +def _repo_from_push_payload(payload: dict) -> tuple[str, str]: + repo = payload.get("repository") or {} + full = (repo.get("full_name") or "").strip() + if "/" in full: + owner, name = full.split("/", 1) + return owner, name + owner = (repo.get("owner") or {}) + if isinstance(owner, dict): + owner_name = owner.get("login") or owner.get("username") or "" + else: + owner_name = str(owner) + name = repo.get("name") or "" + return str(owner_name), str(name) diff --git a/coulomb_social/settings/base.py b/coulomb_social/settings/base.py index 3c6fd5f..f1f0927 100644 --- a/coulomb_social/settings/base.py +++ b/coulomb_social/settings/base.py @@ -124,5 +124,7 @@ FLEX_AUTH_TIMEOUT_SECONDS = config("FLEX_AUTH_TIMEOUT_SECONDS", default=3.0, cas 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) +# Shared secret for POST /app/spaces/hooks/forgejo/ (push → cache invalidate) +FORGEJO_WEBHOOK_SECRET = config("FORGEJO_WEBHOOK_SECRET", default="") # 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 b2aa5ee..0179e39 100644 --- a/coulomb_social/templates/spaces/detail.html +++ b/coulomb_social/templates/spaces/detail.html @@ -23,11 +23,19 @@ border-left: 3px solid var(--color-primary); color: var(--color-muted); } - .content-meta { font-size: 0.85rem; margin-top: 0.5rem; } + .content-meta { font-size: 0.85rem; margin-top: 0.75rem; } .content-error { border-color: #fcd34d; background: #fffbeb; } + .content-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 1rem 0 0; + align-items: center; + } + .content-actions form { display: inline; margin: 0; } {% endblock %} {% block content %} @@ -39,6 +47,25 @@

{{ space.description }}

{% endif %} + {% if space.has_content_binding %} +
+ {% if forgejo_edit_url %} + Edit in Forgejo + {% endif %} + {% if forgejo_blob_url %} + View source + {% endif %} +
+ {% csrf_token %} + + +
+
+ + {% endif %} + {% if page.error %}

Content unavailable

diff --git a/docs/deploy.md b/docs/deploy.md index a43f6a6..b28c1c1 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -77,3 +77,20 @@ When Bubble can retire: make test make run # offline or local OIDC redirect ``` + +## Space content (Forgejo) + +See `docs/spaces-content.md`. Demo: + +```bash +kubectl -n coulomb-social exec deploy/coulomb-social -- python manage.py seed_demo_space +``` + +Optional webhook secret (when configured in cluster env): + +| Key | Purpose | +|-----|---------| +| `FORGEJO_WEBHOOK_SECRET` | Push webhook cache bust | +| `FORGEJO_TOKEN` | Private repo raw/API reads | + +Webhook URL: `https://app.coulomb.social/app/spaces/hooks/forgejo/` \ No newline at end of file diff --git a/docs/spaces-content.md b/docs/spaces-content.md new file mode 100644 index 0000000..3bb5db4 --- /dev/null +++ b/docs/spaces-content.md @@ -0,0 +1,63 @@ +# Space content — Forgejo markdown (read + write path) + +See **ADR-0002**. App DB holds metadata; page bodies are markdown in Forgejo. + +## Read (T04) + +1. Space row has `forgejo_owner`, `forgejo_repo`, `default_branch`, `content_root`. +2. App fetches `{content_root}/index.md` (or `?page=` → `{slug}.md`) via Forgejo raw URL. +3. HTML is sanitized and cached in-process (successes only). + +Env (non-secret defaults in settings): + +| Variable | Purpose | +|----------|---------| +| `FORGEJO_BASE_URL` | default `https://forgejo.coulomb.social` | +| `FORGEJO_TOKEN` | optional; needed for private repos | +| `FORGEJO_WEBHOOK_SECRET` | optional; enables push webhook cache bust | + +## Write / sync (T05) + +**Git remains the source of truth.** There is no in-app markdown editor in v1. + +### Operator / author workflow + +1. Open the space on app.coulomb.social. +2. Click **Edit in Forgejo** (file editor) or **View source**. +3. Commit on the space’s `default_branch` (usually `main`). +4. Click **Refresh content** on the space page (or rely on webhook below). + +Demo seed (public raw, no token): + +```bash +python manage.py seed_demo_space +# binds demo → coulomb/coulomb-social @ docs/space-fixtures/demo/pages +``` + +### Webhook (optional, recommended) + +So authors do not need to click Refresh after every push: + +1. Set `FORGEJO_WEBHOOK_SECRET` in the app env Secret (not in git). +2. In Forgejo: repo → **Settings → Webhooks → Add webhook → Gitea**. +3. **Target URL:** `https://app.coulomb.social/app/spaces/hooks/forgejo/` +4. **HTTP Method:** POST, content type JSON. +5. **Secret:** same value as `FORGEJO_WEBHOOK_SECRET` (Forgejo signs with HMAC-SHA256). +6. Trigger: **Push** events. + +Auth accepted by the app: + +- `X-Gitea-Signature: ` (Forgejo default when secret set), or +- `X-Coulomb-Webhook-Secret: ` + +Response JSON: `{ ok, owner, repo, cache_entries_cleared, spaces_matched }`. + +### Fail closed + +- Missing binding → clear UI error, no Bubble fallback. +- Fetch errors → error card; previous cache entry is not used after Refresh. + +## Related + +- `docs/adr/ADR-0002-space-content-forgejo-markdown.md` +- `python manage.py seed_demo_space` diff --git a/tests/test_content_sync.py b/tests/test_content_sync.py new file mode 100644 index 0000000..1ae6ecf --- /dev/null +++ b/tests/test_content_sync.py @@ -0,0 +1,143 @@ +import hashlib +import hmac +import json +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, + clear_content_cache_for_repo, + forgejo_edit_url, +) +from coulomb_social.apps.spaces.forgejo import FetchedFile +from coulomb_social.apps.spaces.models import Space + + +@pytest.mark.django_db +def test_forgejo_edit_url(): + space = Space( + tenant_id="t", + slug="demo", + title="D", + forgejo_owner="coulomb", + forgejo_repo="coulomb-social", + default_branch="main", + content_root="docs/space-fixtures/demo/pages", + ) + url = forgejo_edit_url(space, "index") + assert url.endswith( + "/coulomb/coulomb-social/_edit/main/docs/space-fixtures/demo/pages/index.md" + ) + + +@pytest.mark.django_db +def test_refresh_clears_cache_and_refetches(client, settings): + clear_content_cache() + settings.DEBUG = True + settings.OIDC_ENABLED = False + client.post( + reverse("identity:dev_login"), + { + "subject": "writer", + "issuer": "https://local.dev/issuer", + "name": "Writer", + "tenant": "tenant:coulomb", + }, + ) + member = Member.objects.get(subject="writer") + 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", + ) + calls = {"n": 0} + + def fake_fetch(**kwargs): + calls["n"] += 1 + return FetchedFile( + path=kwargs["path"], + text=f"# Version {calls['n']}\n\nbody\n", + source="forgejo-raw", + ) + + with patch( + "coulomb_social.apps.spaces.content.fetch_raw_file", side_effect=fake_fetch + ): + r1 = client.get(reverse("spaces:detail", kwargs={"slug": "demo"})) + assert r1.status_code == 200 + assert b"Version 1" in r1.content + # cached — still v1 + r2 = client.get(reverse("spaces:detail", kwargs={"slug": "demo"})) + assert b"Version 1" in r2.content + assert calls["n"] == 1 + # refresh + r3 = client.post(reverse("spaces:refresh", kwargs={"slug": "demo"})) + assert r3.status_code == 302 + r4 = client.get(reverse("spaces:detail", kwargs={"slug": "demo"})) + assert b"Version 2" in r4.content + assert calls["n"] == 2 + + +@pytest.mark.django_db +def test_webhook_hmac_invalidates_repo_cache(client, settings): + clear_content_cache() + settings.FORGEJO_WEBHOOK_SECRET = "test-webhook-secret-at-least-16" + Space.objects.create( + tenant_id="tenant:coulomb", + slug="demo", + title="Demo", + forgejo_owner="coulomb", + forgejo_repo="coulomb-social", + content_root="pages", + ) + # prime cache + from coulomb_social.apps.spaces import content as content_mod + + key = ( + "coulomb", + "coulomb-social", + "main", + "pages/index.md", + settings.FORGEJO_BASE_URL, + ) + content_mod._FETCH_CACHE[key] = FetchedFile( + path="pages/index.md", text="# old", source="forgejo-raw" + ) + + body = json.dumps( + {"repository": {"full_name": "coulomb/coulomb-social", "name": "coulomb-social"}} + ).encode() + sig = hmac.new( + settings.FORGEJO_WEBHOOK_SECRET.encode(), body, hashlib.sha256 + ).hexdigest() + r = client.post( + reverse("spaces:forgejo_webhook"), + data=body, + content_type="application/json", + headers={"X-Gitea-Signature": sig}, + ) + assert r.status_code == 200 + data = r.json() + assert data["ok"] is True + assert data["cache_entries_cleared"] >= 1 + assert data["spaces_matched"] == 1 + assert key not in content_mod._FETCH_CACHE + + +@pytest.mark.django_db +def test_webhook_rejects_bad_secret(client, settings): + settings.FORGEJO_WEBHOOK_SECRET = "test-webhook-secret-at-least-16" + r = client.post( + reverse("spaces:forgejo_webhook"), + data=b"{}", + content_type="application/json", + headers={"X-Coulomb-Webhook-Secret": "wrong"}, + ) + assert r.status_code == 401 diff --git a/workplans/CSOC-WP-0004-app-shell-and-space-content.md b/workplans/CSOC-WP-0004-app-shell-and-space-content.md index 09f5af6..a45cb2f 100644 --- a/workplans/CSOC-WP-0004-app-shell-and-space-content.md +++ b/workplans/CSOC-WP-0004-app-shell-and-space-content.md @@ -145,7 +145,7 @@ command; env `FORGEJO_BASE_URL` / optional `FORGEJO_TOKEN`. ```task id: CSOC-WP-0004-T05 -status: todo +status: done priority: medium state_hub_task_id: "30ed5def-2718-4379-834c-920e751e4d0b" ``` @@ -160,6 +160,10 @@ Prefer the smaller path that keeps git as source of truth. **Done when:** a content change in Forgejo appears in the app without redeploy, and (if in-app write exists) a save produces a commit without secrets in git. +2026-08-12: **Edit in Forgejo** + **View source** + **Refresh content** on space +detail; repo-scoped cache invalidate; optional push webhook +`POST /app/spaces/hooks/forgejo/` (HMAC or shared secret). Docs: +`docs/spaces-content.md`. No in-app editor (git remains SoR). ## T06 — Seed and runbook for app.coulomb.social ```task