robot-u-site/app.py

314 lines
12 KiB
Python

from __future__ import annotations
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.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 Settings, get_settings
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "frontend" / "dist"
def create_app() -> FastAPI:
app = FastAPI(title="Robot U Community Prototype")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health() -> JSONResponse:
return JSONResponse({"status": "ok"})
@app.get("/api/prototype")
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"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str) -> FileResponse:
candidate = DIST_DIR / full_path
if candidate.is_file():
return FileResponse(str(candidate))
response = FileResponse(str(DIST_DIR / "index.html"))
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
return app
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", ""),
}