Build Forgejo-backed community prototype
This commit is contained in:
parent
797ae5ea35
commit
6671a01d26
16 changed files with 2485 additions and 293 deletions
269
app.py
269
app.py
|
|
@ -1,14 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from auth import (
|
||||
OAuthStateRecord,
|
||||
clear_login_session,
|
||||
consume_oauth_state,
|
||||
create_login_session,
|
||||
create_oauth_state,
|
||||
current_session_user,
|
||||
resolve_forgejo_token,
|
||||
)
|
||||
from forgejo_client import ForgejoClient, ForgejoClientError
|
||||
from live_prototype import build_live_prototype_payload
|
||||
from settings import get_settings
|
||||
from settings import Settings, get_settings
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DIST_DIR = BASE_DIR / "frontend" / "dist"
|
||||
|
|
@ -29,8 +41,135 @@ def create_app() -> FastAPI:
|
|||
return JSONResponse({"status": "ok"})
|
||||
|
||||
@app.get("/api/prototype")
|
||||
async def prototype() -> JSONResponse:
|
||||
return JSONResponse(await build_live_prototype_payload(get_settings()))
|
||||
async def prototype(request: Request) -> JSONResponse:
|
||||
settings = get_settings()
|
||||
session_user = current_session_user(request)
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
@app.get("/api/auth/session")
|
||||
async def auth_session(request: Request) -> JSONResponse:
|
||||
session_user = current_session_user(request)
|
||||
if session_user:
|
||||
return JSONResponse(_auth_payload(session_user, "session"))
|
||||
|
||||
settings = get_settings()
|
||||
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
|
||||
if not forgejo_token or auth_source == "server":
|
||||
return JSONResponse(_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 JSONResponse(_auth_payload(user, auth_source))
|
||||
|
||||
@app.get("/api/auth/forgejo/start")
|
||||
async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse:
|
||||
settings = get_settings()
|
||||
if not _oauth_configured(settings):
|
||||
return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.")
|
||||
|
||||
redirect_uri = _oauth_redirect_uri(request, settings)
|
||||
state, code_challenge = create_oauth_state(redirect_uri, return_to)
|
||||
|
||||
async with ForgejoClient(settings) as client:
|
||||
try:
|
||||
oidc = await client.fetch_openid_configuration()
|
||||
except ForgejoClientError as error:
|
||||
return _signin_error_redirect(str(error))
|
||||
|
||||
authorization_endpoint = str(oidc.get("authorization_endpoint") or "")
|
||||
if not authorization_endpoint:
|
||||
return _signin_error_redirect("Forgejo did not return an OAuth authorization endpoint.")
|
||||
|
||||
query = urlencode(
|
||||
{
|
||||
"client_id": settings.forgejo_oauth_client_id or "",
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(settings.forgejo_oauth_scopes),
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
return RedirectResponse(f"{authorization_endpoint}?{query}", status_code=303)
|
||||
|
||||
@app.get("/api/auth/forgejo/callback")
|
||||
async def forgejo_auth_callback(
|
||||
code: str | None = None,
|
||||
state: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> RedirectResponse:
|
||||
if error:
|
||||
return _signin_error_redirect(f"Forgejo sign-in failed: {error}")
|
||||
if not code or not state:
|
||||
return _signin_error_redirect("Forgejo did not return the expected sign-in data.")
|
||||
|
||||
settings = get_settings()
|
||||
if not _oauth_configured(settings):
|
||||
return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.")
|
||||
|
||||
oauth_state = consume_oauth_state(state)
|
||||
if oauth_state is None:
|
||||
return _signin_error_redirect("The Forgejo sign-in request expired. Try again.")
|
||||
|
||||
try:
|
||||
access_token = await _exchange_forgejo_code(settings, code, oauth_state)
|
||||
user = await _fetch_forgejo_oidc_user(settings, access_token)
|
||||
except ForgejoClientError as exchange_error:
|
||||
return _signin_error_redirect(str(exchange_error))
|
||||
|
||||
response = RedirectResponse(oauth_state.return_to, status_code=303)
|
||||
create_login_session(response, access_token, user)
|
||||
return response
|
||||
|
||||
@app.delete("/api/auth/session")
|
||||
async def delete_auth_session(request: Request) -> JSONResponse:
|
||||
response = JSONResponse(_auth_payload(None, "none"))
|
||||
clear_login_session(request, response)
|
||||
return response
|
||||
|
||||
@app.post("/api/discussions/replies")
|
||||
async def create_discussion_reply(
|
||||
request: Request,
|
||||
payload: dict[str, object] = Body(...),
|
||||
) -> JSONResponse:
|
||||
owner = _required_string(payload, "owner")
|
||||
repo = _required_string(payload, "repo")
|
||||
body = _required_string(payload, "body")
|
||||
issue_number = _required_positive_int(payload, "number")
|
||||
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 replying.",
|
||||
)
|
||||
|
||||
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.",
|
||||
)
|
||||
comment = await client.create_issue_comment(owner, repo, issue_number, body)
|
||||
except ForgejoClientError as error:
|
||||
raise HTTPException(status_code=502, detail=str(error)) from error
|
||||
|
||||
return JSONResponse(_discussion_reply(comment))
|
||||
|
||||
if DIST_DIR.exists():
|
||||
assets_dir = DIST_DIR / "assets"
|
||||
|
|
@ -53,3 +192,123 @@ def create_app() -> FastAPI:
|
|||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _required_string(payload: dict[str, object], key: str) -> str:
|
||||
value = payload.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise HTTPException(status_code=400, detail=f"{key} is required.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _required_positive_int(payload: dict[str, object], key: str) -> int:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, bool):
|
||||
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
|
||||
|
||||
if isinstance(value, int):
|
||||
parsed = value
|
||||
elif isinstance(value, str) and value.isdigit():
|
||||
parsed = int(value)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
|
||||
|
||||
if parsed < 1:
|
||||
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
|
||||
return parsed
|
||||
|
||||
|
||||
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
|
||||
author = comment.get("user") or {}
|
||||
body = str(comment.get("body", "") or "").strip()
|
||||
if not body:
|
||||
body = "No comment body provided."
|
||||
|
||||
return {
|
||||
"id": int(comment.get("id", 0)),
|
||||
"author": author.get("login", "Unknown author"),
|
||||
"avatar_url": author.get("avatar_url", ""),
|
||||
"body": body,
|
||||
"created_at": comment.get("created_at", ""),
|
||||
"html_url": comment.get("html_url", ""),
|
||||
}
|
||||
|
||||
|
||||
def _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object]:
|
||||
oauth_configured = _oauth_configured(get_settings())
|
||||
if not user:
|
||||
return {
|
||||
"authenticated": False,
|
||||
"login": None,
|
||||
"source": source,
|
||||
"can_reply": source in {"authorization", "session"},
|
||||
"oauth_configured": oauth_configured,
|
||||
}
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"login": user.get("login", "Unknown user"),
|
||||
"source": source,
|
||||
"can_reply": source in {"authorization", "session"},
|
||||
"oauth_configured": oauth_configured,
|
||||
}
|
||||
|
||||
|
||||
def _oauth_configured(settings: Settings) -> bool:
|
||||
return bool(settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret)
|
||||
|
||||
|
||||
def _oauth_redirect_uri(request: Request, settings: Settings) -> str:
|
||||
if settings.app_base_url:
|
||||
return f"{settings.app_base_url}/api/auth/forgejo/callback"
|
||||
return str(request.url_for("forgejo_auth_callback"))
|
||||
|
||||
|
||||
def _signin_error_redirect(message: str) -> RedirectResponse:
|
||||
return RedirectResponse(f"/signin?{urlencode({'error': message})}", status_code=303)
|
||||
|
||||
|
||||
async def _exchange_forgejo_code(
|
||||
settings: Settings,
|
||||
code: str,
|
||||
oauth_state: OAuthStateRecord,
|
||||
) -> str:
|
||||
async with ForgejoClient(settings) as client:
|
||||
oidc = await client.fetch_openid_configuration()
|
||||
token_endpoint = str(oidc.get("token_endpoint") or "")
|
||||
if not token_endpoint:
|
||||
raise ForgejoClientError("Forgejo did not return an OAuth token endpoint.")
|
||||
|
||||
token_payload = await client.exchange_oauth_code(
|
||||
token_endpoint=token_endpoint,
|
||||
client_id=settings.forgejo_oauth_client_id or "",
|
||||
client_secret=settings.forgejo_oauth_client_secret or "",
|
||||
code=code,
|
||||
redirect_uri=oauth_state.redirect_uri,
|
||||
code_verifier=oauth_state.code_verifier,
|
||||
)
|
||||
|
||||
access_token = token_payload.get("access_token")
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
raise ForgejoClientError("Forgejo did not return an access token.")
|
||||
return access_token
|
||||
|
||||
|
||||
async def _fetch_forgejo_oidc_user(settings: Settings, access_token: str) -> dict[str, Any]:
|
||||
async with ForgejoClient(settings) as client:
|
||||
oidc = await client.fetch_openid_configuration()
|
||||
userinfo_endpoint = str(oidc.get("userinfo_endpoint") or "")
|
||||
if not userinfo_endpoint:
|
||||
raise ForgejoClientError("Forgejo did not return an OIDC UserInfo endpoint.")
|
||||
|
||||
userinfo = await client.fetch_userinfo(userinfo_endpoint, access_token)
|
||||
|
||||
login = userinfo.get("preferred_username") or userinfo.get("name") or userinfo.get("sub")
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ForgejoClientError("Forgejo did not return a usable user identity.")
|
||||
|
||||
return {
|
||||
"login": login,
|
||||
"avatar_url": userinfo.get("picture", ""),
|
||||
"email": userinfo.get("email", ""),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue