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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from .approval import cleanup_expired, generate_token, pop_action, queue_action, verify_token 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}") @app.get("/approve/{token}")
async def approve_action(token: str): async def approve_action(token: str):
"""Public endpoint — called when user clicks Approve in Discord. Executes the queued action.""" """Public endpoint — called when user clicks Approve in Discord. Executes the queued action."""
@@ -191,38 +228,47 @@ async def approve_action(token: str):
try: try:
action_id, purpose = verify_token(token) action_id, purpose = verify_token(token)
except ValueError as e: except ValueError as e:
return JSONResponse( return _html_page("Error", "⚠️", "Token Invalid",
{"error": str(e), "message": "Approval failed — token may be expired or already used."}, f"{e}. The link may have expired (15 min) or already been used.",
status_code=400, color="#f85149")
)
if purpose != "approve": 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) action = pop_action(action_id)
if not action: if not action:
return JSONResponse( return _html_page("Not Found", "🔍", "Action Not Found",
{"error": "action not found", "message": "Action not found — it may have already been executed or expired."}, "This action has already been executed, rejected, or expired.",
status_code=404, color="#d29922")
)
logger.info(f"approved: {action.action_type}{action.description}") logger.info(f"approved: {action.action_type}{action.description}")
try: try:
if action.action_type == "docker_rebuild": if action.action_type == "docker_rebuild":
result = await call_qyburn_rebuild(action.params["stack_name"]) result = await call_qyburn_rebuild(action.params["stack_name"])
return JSONResponse({ ok = result.get("ok", False)
"ok": True, if ok:
"message": f" Rebuilding {action.params['stack_name']}. Check container logs for progress.", return _html_page("Approved", "", "Rebuilding",
"result": result, 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": elif action.action_type == "docker_restart":
result = await call_qyburn_restart(action.params["stack_name"]) result = await call_qyburn_restart(action.params["stack_name"])
return JSONResponse({ ok = result.get("ok", False)
"ok": True, if ok:
"message": f" Restarting {action.params['stack_name']}.", return _html_page("Approved", "", "Restarted",
"result": result, 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": elif action.action_type == "file_write":
path = action.params.get("path", "") path = action.params.get("path", "")
content = action.params.get("content", "") content = action.params.get("content", "")
@@ -230,18 +276,17 @@ async def approve_action(token: str):
target = SITES_DIR / safe_path target = SITES_DIR / safe_path
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content) target.write_text(content)
return JSONResponse({ return _html_page("Approved", "", "File Written",
"ok": True, f"<strong>/opt/sites/{safe_path}</strong> has been updated.")
"message": f"✅ File written: /opt/sites/{safe_path}",
})
else: else:
return JSONResponse( return _html_page("Error", "⚠️", "Unknown Action",
{"error": f"Unknown action type: {action.action_type}"}, f"Action type '{action.action_type}' is not supported.",
status_code=400, color="#f85149")
)
except Exception as e: except Exception as e:
logger.error(f"action execution failed: {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}") @app.get("/reject/{token}")
@@ -251,26 +296,25 @@ async def reject_action(token: str):
try: try:
action_id, purpose = verify_token(token) action_id, purpose = verify_token(token)
except ValueError as e: except ValueError as e:
return JSONResponse( return _html_page("Error", "⚠️", "Token Invalid",
{"error": str(e), "message": "Rejection failed — token may be expired or already used."}, f"{e}. The link may have expired (15 min) or already been used.",
status_code=400, color="#f85149")
)
if purpose != "reject": 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) action = pop_action(action_id)
if not action: if not action:
return JSONResponse( return _html_page("Not Found", "🔍", "Already Handled",
{"error": "action not found", "message": "Action not found — it may have already been handled."}, "This action has already been executed, rejected, or expired.",
status_code=404, color="#d29922")
)
logger.info(f"rejected: {action.action_type}{action.description}") logger.info(f"rejected: {action.action_type}{action.description}")
return JSONResponse({ return _html_page("Rejected", "", "Action Cancelled",
"ok": True, f"<strong>{action.description}</strong> was rejected and will not be executed.",
"message": f"❌ Action rejected: {action.description}", color="#d29922")
})
@app.post("/internal/queue-action") @app.post("/internal/queue-action")