fix: approve/reject endpoints return HTML pages instead of raw JSON

Mobile-friendly dark-theme page with icon, heading, and description.
Green for success, yellow for cancelled/not-found, red for errors.
This commit is contained in:
2026-05-27 12:53:54 +00:00
parent 53b52a2337
commit bbf05089cd
+86 -42
View File
@@ -10,7 +10,7 @@ from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel
from .approval import cleanup_expired, generate_token, pop_action, queue_action, verify_token
@@ -184,6 +184,43 @@ async def list_models():
}
def _html_page(title: str, icon: str, heading: str, body: str, color: str = "#3fb950") -> HTMLResponse:
"""Render a simple mobile-friendly result page."""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} — Jon Snow</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0d1117; color: #c9d1d9; display: flex; align-items: center;
justify-content: center; min-height: 100vh; padding: 2rem; }}
.card {{ background: #161b22; border: 1px solid #30363d; border-radius: 12px;
padding: 2.5rem; max-width: 480px; width: 100%; text-align: center; }}
.icon {{ font-size: 3rem; margin-bottom: 1rem; }}
h1 {{ color: {color}; font-size: 1.5rem; margin-bottom: 0.75rem; }}
p {{ color: #8b949e; line-height: 1.6; font-size: 0.95rem; }}
.badge {{ display: inline-block; margin-top: 1.25rem; font-family: monospace;
font-size: 0.8rem; background: #21262d; color: #79c0ff;
padding: 0.3rem 0.75rem; border-radius: 4px; border: 1px solid #30363d; }}
.footer {{ margin-top: 2rem; color: #484f58; font-size: 0.78rem; }}
</style>
</head>
<body>
<div class="card">
<div class="icon">{icon}</div>
<h1>{heading}</h1>
<p>{body}</p>
<div class="badge">jon-snow · nxm.co.za</div>
<p class="footer">You can close this tab.</p>
</div>
</body>
</html>"""
return HTMLResponse(html)
@app.get("/approve/{token}")
async def approve_action(token: str):
"""Public endpoint — called when user clicks Approve in Discord. Executes the queued action."""
@@ -191,38 +228,47 @@ async def approve_action(token: str):
try:
action_id, purpose = verify_token(token)
except ValueError as e:
return JSONResponse(
{"error": str(e), "message": "Approval failed — token may be expired or already used."},
status_code=400,
)
return _html_page("Error", "⚠️", "Token Invalid",
f"{e}. The link may have expired (15 min) or already been used.",
color="#f85149")
if purpose != "approve":
return JSONResponse({"error": "wrong token purpose"}, status_code=400)
return _html_page("Error", "⚠️", "Wrong Link",
"This doesn't look like an approval link.",
color="#f85149")
action = pop_action(action_id)
if not action:
return JSONResponse(
{"error": "action not found", "message": "Action not found — it may have already been executed or expired."},
status_code=404,
)
return _html_page("Not Found", "🔍", "Action Not Found",
"This action has already been executed, rejected, or expired.",
color="#d29922")
logger.info(f"approved: {action.action_type}{action.description}")
try:
if action.action_type == "docker_rebuild":
result = await call_qyburn_rebuild(action.params["stack_name"])
return JSONResponse({
"ok": True,
"message": f" Rebuilding {action.params['stack_name']}. Check container logs for progress.",
"result": result,
})
ok = result.get("ok", False)
if ok:
return _html_page("Approved", "", "Rebuilding",
f"<strong>{action.params['stack_name']}</strong> is rebuilding. "
f"It will be back online in ~30 seconds.")
else:
return _html_page("Failed", "", "Rebuild Failed",
f"Could not rebuild {action.params['stack_name']}: "
f"{result.get('stderr','unknown error')[:200]}",
color="#f85149")
elif action.action_type == "docker_restart":
result = await call_qyburn_restart(action.params["stack_name"])
return JSONResponse({
"ok": True,
"message": f" Restarting {action.params['stack_name']}.",
"result": result,
})
ok = result.get("ok", False)
if ok:
return _html_page("Approved", "", "Restarted",
f"<strong>{action.params['stack_name']}</strong> has been restarted successfully.")
else:
return _html_page("Failed", "", "Restart Failed",
f"Could not restart {action.params['stack_name']}: "
f"{result.get('stderr','unknown error')[:200]}",
color="#f85149")
elif action.action_type == "file_write":
path = action.params.get("path", "")
content = action.params.get("content", "")
@@ -230,18 +276,17 @@ async def approve_action(token: str):
target = SITES_DIR / safe_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
return JSONResponse({
"ok": True,
"message": f"✅ File written: /opt/sites/{safe_path}",
})
return _html_page("Approved", "", "File Written",
f"<strong>/opt/sites/{safe_path}</strong> has been updated.")
else:
return JSONResponse(
{"error": f"Unknown action type: {action.action_type}"},
status_code=400,
)
return _html_page("Error", "⚠️", "Unknown Action",
f"Action type '{action.action_type}' is not supported.",
color="#f85149")
except Exception as e:
logger.error(f"action execution failed: {e}")
return JSONResponse({"error": f"Execution failed: {e}"}, status_code=500)
return _html_page("Error", "", "Execution Failed",
f"Something went wrong: {str(e)[:200]}",
color="#f85149")
@app.get("/reject/{token}")
@@ -251,26 +296,25 @@ async def reject_action(token: str):
try:
action_id, purpose = verify_token(token)
except ValueError as e:
return JSONResponse(
{"error": str(e), "message": "Rejection failed — token may be expired or already used."},
status_code=400,
)
return _html_page("Error", "⚠️", "Token Invalid",
f"{e}. The link may have expired (15 min) or already been used.",
color="#f85149")
if purpose != "reject":
return JSONResponse({"error": "wrong token purpose"}, status_code=400)
return _html_page("Error", "⚠️", "Wrong Link",
"This doesn't look like a rejection link.",
color="#f85149")
action = pop_action(action_id)
if not action:
return JSONResponse(
{"error": "action not found", "message": "Action not found — it may have already been handled."},
status_code=404,
)
return _html_page("Not Found", "🔍", "Already Handled",
"This action has already been executed, rejected, or expired.",
color="#d29922")
logger.info(f"rejected: {action.action_type}{action.description}")
return JSONResponse({
"ok": True,
"message": f"❌ Action rejected: {action.description}",
})
return _html_page("Rejected", "", "Action Cancelled",
f"<strong>{action.description}</strong> was rejected and will not be executed.",
color="#d29922")
@app.post("/internal/queue-action")