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:
+86
-42
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user