Add Forgejo edit/refresh sync path for space content (T05)

Keep git as source of truth: deep-link to Forgejo editor, manual refresh to
drop cache, and optional signed push webhook for automatic invalidation.
This commit is contained in:
tegwick 2026-08-12 03:29:26 +02:00
parent 9c10037f34
commit 3f30bcd340
9 changed files with 458 additions and 5 deletions

View file

@ -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}"

View file

@ -5,5 +5,11 @@ from . import views
app_name = "spaces"
urlpatterns = [
path(
"hooks/forgejo/",
views.forgejo_content_webhook,
name="forgejo_webhook",
),
path("<slug:slug>/refresh/", views.space_refresh, name="refresh"),
path("<slug:slug>/", views.space_detail, name="detail"),
]

View file

@ -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)

View file

@ -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: <root>/<space-slug>/<content_root>/index.md
SPACE_CONTENT_FIXTURE_ROOT = config("SPACE_CONTENT_FIXTURE_ROOT", default="")

View file

@ -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; }
</style>
{% endblock %}
{% block content %}
@ -39,6 +47,25 @@
<p class="muted">{{ space.description }}</p>
{% endif %}
{% if space.has_content_binding %}
<div class="content-actions">
{% if forgejo_edit_url %}
<a class="btn small" href="{{ forgejo_edit_url }}" target="_blank" rel="noopener">Edit in Forgejo</a>
{% endif %}
{% if forgejo_blob_url %}
<a class="btn secondary small" href="{{ forgejo_blob_url }}" target="_blank" rel="noopener">View source</a>
{% endif %}
<form method="post" action="{% url 'spaces:refresh' space.slug %}">
{% csrf_token %}
<input type="hidden" name="page" value="{{ page.slug|default:'index' }}">
<button type="submit" class="btn secondary small">Refresh content</button>
</form>
</div>
<p class="muted content-meta" style="margin-top:0.5rem;">
Git is the source of truth. Edit in Forgejo, then refresh (or wait for webhook).
</p>
{% endif %}
{% if page.error %}
<div class="card content-error">
<h2 style="margin-top:0;">Content unavailable</h2>

View file

@ -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/`

63
docs/spaces-content.md Normal file
View file

@ -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>``{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 spaces `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: <hex hmac-sha256 of body>` (Forgejo default when secret set), or
- `X-Coulomb-Webhook-Secret: <shared 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`

143
tests/test_content_sync.py Normal file
View file

@ -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

View file

@ -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