Complete Forgejo discussion MVP

This commit is contained in:
kacper 2026-04-13 18:19:50 -04:00
parent d84a885fdb
commit 51706d2d11
17 changed files with 1708 additions and 127 deletions

224
app.py
View file

@ -1,12 +1,14 @@
from __future__ import annotations
import hmac
from hashlib import sha256
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from fastapi import Body, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from auth import (
@ -19,8 +21,10 @@ from auth import (
resolve_forgejo_token,
)
from forgejo_client import ForgejoClient, ForgejoClientError
from live_prototype import build_live_prototype_payload
from live_prototype import discussion_card_from_issue
from prototype_cache import PrototypePayloadCache
from settings import Settings, get_settings
from update_events import UpdateBroker, stream_sse_events
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "frontend" / "dist"
@ -28,6 +32,8 @@ DIST_DIR = BASE_DIR / "frontend" / "dist"
def create_app() -> FastAPI:
app = FastAPI(title="Robot U Community Prototype")
prototype_cache = PrototypePayloadCache()
update_broker = UpdateBroker()
app.add_middleware(
CORSMiddleware,
@ -45,14 +51,14 @@ def create_app() -> FastAPI:
settings = get_settings()
session_user = current_session_user(request, settings)
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
return JSONResponse(
await build_live_prototype_payload(
settings,
forgejo_token=forgejo_token,
auth_source=auth_source,
session_user=session_user,
),
payload = await prototype_cache.get(settings)
payload["auth"] = await _auth_payload_for_request(
settings,
forgejo_token=forgejo_token,
auth_source=auth_source,
session_user=session_user,
)
return JSONResponse(payload)
@app.get("/api/auth/session")
async def auth_session(request: Request) -> JSONResponse:
@ -73,6 +79,28 @@ def create_app() -> FastAPI:
return JSONResponse(_auth_payload(user, auth_source))
@app.get("/api/events/stream")
async def events_stream() -> StreamingResponse:
return StreamingResponse(
stream_sse_events(update_broker),
media_type="text/event-stream",
headers={
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
)
@app.post("/api/forgejo/webhook")
async def forgejo_webhook(request: Request) -> JSONResponse:
settings = get_settings()
body = await request.body()
if not _valid_webhook_signature(request, settings, body):
raise HTTPException(status_code=401, detail="Invalid Forgejo webhook signature.")
prototype_cache.invalidate()
await update_broker.publish("content-updated", {"reason": "forgejo-webhook"})
return JSONResponse({"status": "accepted"})
@app.get("/api/auth/forgejo/start")
async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse:
settings = get_settings()
@ -140,6 +168,31 @@ def create_app() -> FastAPI:
clear_login_session(request, response)
return response
@app.get("/api/discussions/{owner}/{repo}/{issue_number}")
async def discussion_detail(owner: str, repo: str, issue_number: int) -> JSONResponse:
if issue_number < 1:
raise HTTPException(status_code=400, detail="issue_number must be positive.")
settings = get_settings()
async with ForgejoClient(settings, forgejo_token=settings.forgejo_token) as client:
try:
repo_payload = await client.fetch_repository(owner, repo)
if repo_payload.get("private"):
raise HTTPException(
status_code=403,
detail="This site only reads public Forgejo repositories.",
)
issue = await client.fetch_issue(owner, repo, issue_number)
comments = [
_discussion_reply(comment)
for comment in await client.list_issue_comments(owner, repo, issue_number)
]
except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
issue["repository"] = _issue_repository_payload(repo_payload, owner, repo)
return JSONResponse(discussion_card_from_issue(issue, comments=comments))
@app.post("/api/discussions/replies")
async def create_discussion_reply(
request: Request,
@ -169,8 +222,59 @@ def create_app() -> FastAPI:
except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
prototype_cache.invalidate()
await update_broker.publish(
"content-updated",
{"reason": "discussion-reply", "repo": f"{owner}/{repo}", "number": issue_number},
)
return JSONResponse(_discussion_reply(comment))
@app.post("/api/discussions")
async def create_discussion(
request: Request,
payload: dict[str, object] = Body(...),
) -> JSONResponse:
title = _required_string(payload, "title")
body = _required_string(payload, "body")
owner, repo = _discussion_target(payload, get_settings())
context_url = _optional_string(payload, "context_url")
context_title = _optional_string(payload, "context_title")
context_path = _optional_string(payload, "context_path")
issue_body = _discussion_issue_body(
body,
context_url=context_url,
context_title=context_title,
context_path=context_path,
)
settings = get_settings()
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
if not forgejo_token or auth_source == "server":
raise HTTPException(
status_code=401,
detail="Sign in or send an Authorization token before starting a discussion.",
)
async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
try:
repo_payload = await client.fetch_repository(owner, repo)
if repo_payload.get("private"):
raise HTTPException(
status_code=403,
detail="This site only writes to public Forgejo repositories.",
)
issue = await client.create_issue(owner, repo, title, issue_body)
except ForgejoClientError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
issue["repository"] = _issue_repository_payload(repo_payload, owner, repo)
prototype_cache.invalidate()
await update_broker.publish(
"content-updated",
{"reason": "discussion-created", "repo": f"{owner}/{repo}"},
)
return JSONResponse(discussion_card_from_issue(issue, comments=[]))
if DIST_DIR.exists():
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
@ -201,6 +305,16 @@ def _required_string(payload: dict[str, object], key: str) -> str:
return value.strip()
def _optional_string(payload: dict[str, object], key: str) -> str | None:
value = payload.get(key)
if value is None:
return None
if not isinstance(value, str):
raise HTTPException(status_code=400, detail=f"{key} must be a string.")
stripped = value.strip()
return stripped or None
def _required_positive_int(payload: dict[str, object], key: str) -> int:
value = payload.get(key)
if isinstance(value, bool):
@ -218,6 +332,62 @@ def _required_positive_int(payload: dict[str, object], key: str) -> int:
return parsed
def _discussion_target(payload: dict[str, object], settings: Settings) -> tuple[str, str]:
owner = _optional_string(payload, "owner")
repo = _optional_string(payload, "repo")
if owner and repo:
return owner, repo
if owner or repo:
raise HTTPException(status_code=400, detail="owner and repo must be provided together.")
configured_repo = settings.forgejo_general_discussion_repo
if configured_repo:
parts = [part for part in configured_repo.strip().split("/", 1) if part]
if len(parts) == 2:
return parts[0], parts[1]
raise HTTPException(
status_code=400,
detail="General discussion repo is not configured.",
)
def _discussion_issue_body(
body: str,
*,
context_url: str | None,
context_title: str | None,
context_path: str | None,
) -> str:
related_lines: list[str] = []
if context_title:
related_lines.append(f"Related content: {context_title}")
if context_url:
related_lines.append(f"Canonical URL: {context_url}")
if context_path:
related_lines.append(f"Source path: {context_path}")
if not related_lines:
return body
return f"{body}\n\n---\n" + "\n".join(related_lines)
def _issue_repository_payload(
repo_payload: dict[str, Any],
owner: str,
repo: str,
) -> dict[str, object]:
repo_owner = repo_payload.get("owner") or {}
owner_login = repo_owner.get("login") if isinstance(repo_owner, dict) else owner
return {
"owner": owner_login or owner,
"name": repo_payload.get("name") or repo,
"full_name": repo_payload.get("full_name") or f"{owner}/{repo}",
"private": False,
}
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
author = comment.get("user") or {}
body = str(comment.get("body", "") or "").strip()
@ -254,6 +424,42 @@ def _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object]
}
async def _auth_payload_for_request(
settings: Settings,
*,
forgejo_token: str | None,
auth_source: str,
session_user: dict[str, Any] | None,
) -> dict[str, object]:
if session_user:
return _auth_payload(session_user, "session")
if not forgejo_token or auth_source == "server":
return _auth_payload(None, "none")
async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
try:
user = await client.fetch_current_user()
except ForgejoClientError as error:
raise HTTPException(status_code=401, detail=str(error)) from error
return _auth_payload(user, auth_source)
def _valid_webhook_signature(request: Request, settings: Settings, body: bytes) -> bool:
secret = settings.forgejo_webhook_secret
if not secret:
return True
expected = hmac.new(secret.encode("utf-8"), body, sha256).hexdigest()
candidates = [
request.headers.get("x-forgejo-signature", ""),
request.headers.get("x-gitea-signature", ""),
request.headers.get("x-hub-signature-256", "").removeprefix("sha256="),
]
return any(hmac.compare_digest(expected, candidate.strip()) for candidate in candidates)
def _oauth_configured(settings: Settings) -> bool:
return bool(
settings.auth_secret_key