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:
parent
9c10037f34
commit
3f30bcd340
9 changed files with 458 additions and 5 deletions
|
|
@ -189,3 +189,56 @@ _FETCH_CACHE: dict[tuple[str, str, str, str, str], FetchedFile] = {}
|
||||||
|
|
||||||
def clear_content_cache() -> None:
|
def clear_content_cache() -> None:
|
||||||
_FETCH_CACHE.clear()
|
_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}"
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,11 @@ from . import views
|
||||||
app_name = "spaces"
|
app_name = "spaces"
|
||||||
|
|
||||||
urlpatterns = [
|
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"),
|
path("<slug:slug>/", views.space_detail, name="detail"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, HttpResponseNotFound
|
from django.http import (
|
||||||
from django.shortcuts import render
|
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 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
|
from .services import get_space_for_member, spaces_for_member
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
|
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"],
|
"display_name": principal["display_name"],
|
||||||
"space": space,
|
"space": space,
|
||||||
"page": rendered,
|
"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:
|
def list_spaces_context(member) -> dict:
|
||||||
return {"spaces": list(spaces_for_member(member))}
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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_BASE_URL = config("FORGEJO_BASE_URL", default="https://forgejo.coulomb.social")
|
||||||
FORGEJO_TOKEN = config("FORGEJO_TOKEN", default="") # optional; public raw needs none
|
FORGEJO_TOKEN = config("FORGEJO_TOKEN", default="") # optional; public raw needs none
|
||||||
FORGEJO_TIMEOUT_SECONDS = config("FORGEJO_TIMEOUT_SECONDS", default=10.0, cast=float)
|
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
|
# Optional local root for offline tests: <root>/<space-slug>/<content_root>/index.md
|
||||||
SPACE_CONTENT_FIXTURE_ROOT = config("SPACE_CONTENT_FIXTURE_ROOT", default="")
|
SPACE_CONTENT_FIXTURE_ROOT = config("SPACE_CONTENT_FIXTURE_ROOT", default="")
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,19 @@
|
||||||
border-left: 3px solid var(--color-primary);
|
border-left: 3px solid var(--color-primary);
|
||||||
color: var(--color-muted);
|
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 {
|
.content-error {
|
||||||
border-color: #fcd34d;
|
border-color: #fcd34d;
|
||||||
background: #fffbeb;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -39,6 +47,25 @@
|
||||||
<p class="muted">{{ space.description }}</p>
|
<p class="muted">{{ space.description }}</p>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if page.error %}
|
||||||
<div class="card content-error">
|
<div class="card content-error">
|
||||||
<h2 style="margin-top:0;">Content unavailable</h2>
|
<h2 style="margin-top:0;">Content unavailable</h2>
|
||||||
|
|
|
||||||
|
|
@ -77,3 +77,20 @@ When Bubble can retire:
|
||||||
make test
|
make test
|
||||||
make run # offline or local OIDC redirect
|
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
63
docs/spaces-content.md
Normal 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 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: <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
143
tests/test_content_sync.py
Normal 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
|
||||||
|
|
@ -145,7 +145,7 @@ command; env `FORGEJO_BASE_URL` / optional `FORGEJO_TOKEN`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CSOC-WP-0004-T05
|
id: CSOC-WP-0004-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "30ed5def-2718-4379-834c-920e751e4d0b"
|
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,
|
**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.
|
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
|
## T06 — Seed and runbook for app.coulomb.social
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue