Complete Forgejo discussion MVP
This commit is contained in:
parent
d84a885fdb
commit
51706d2d11
17 changed files with 1708 additions and 127 deletions
224
app.py
224
app.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue