Files
Cortex-Inara/cortex/routers/ui.py
Scott Idem 62fde62653 feat: persona-specific favicon + fix favicon.ico 404
app.js updates the <link rel="icon"> to the active persona's emoji on
load (CORTEX_EMOJI is already injected server-side). /favicon.ico route
added as a fallback for login/settings/help pages that don't have
persona context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:45:36 -04:00

292 lines
10 KiB
Python

"""
UI router — serves the web interface and handles login/logout.
Routes:
GET / → redirect to /{user}/{persona} if logged in, else /login
GET /login → login page
POST /login → validate credentials, set cookie, redirect
POST /logout → clear cookie, redirect to /login
GET /{user}/{persona} → serve index.html with CORTEX_CONFIG injected
GET /{user}/{persona}/ → same (trailing slash)
"""
import logging
import re
from pathlib import Path
import jwt
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from auth_utils import COOKIE_NAME, check_credentials, create_token, decode_token
from persona import list_users, list_user_personas, validate as validate_persona, persona_path
logger = logging.getLogger(__name__)
router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_session_user(request: Request) -> str | None:
"""Return the authenticated username from the session cookie, or None."""
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
return decode_token(token)
except jwt.InvalidTokenError:
return None
def _set_cookie(response: Response, username: str) -> None:
from auth_utils import create_token
from config import settings
token = create_token(username)
response.set_cookie(
COOKIE_NAME,
token,
max_age=settings.jwt_expire_days * 86400,
httponly=True,
samesite="lax",
secure=False, # set True in production behind HTTPS
)
def _first_persona(username: str) -> str | None:
"""Return the first available persona for a user, or None."""
names = list_user_personas(username)
return names[0] if names else None
# ---------------------------------------------------------------------------
# Favicon — default sparkle; persona pages override via JS
# ---------------------------------------------------------------------------
_FAVICON_SVG = (
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>"
"<text y='.9em' font-size='90'>✨</text></svg>"
)
@router.get("/favicon.ico", include_in_schema=False)
async def favicon():
return Response(content=_FAVICON_SVG, media_type="image/svg+xml")
# ---------------------------------------------------------------------------
# Root redirect
# ---------------------------------------------------------------------------
@router.get("/", include_in_schema=False)
async def root(request: Request):
user = _get_session_user(request)
if not user:
return RedirectResponse("/login", status_code=302)
persona = _first_persona(user)
if not persona:
return HTMLResponse("<h1>No personas configured for your account.</h1>", status_code=500)
return RedirectResponse(f"/{user}/{persona}", status_code=302)
# ---------------------------------------------------------------------------
# Login / logout
# ---------------------------------------------------------------------------
@router.get("/login", include_in_schema=False)
async def login_page(request: Request):
user = _get_session_user(request)
if user:
# Already logged in — redirect home
persona = _first_persona(user)
if persona:
return RedirectResponse(f"/{user}/{persona}", status_code=302)
return HTMLResponse((_STATIC / "login.html").read_text())
@router.post("/login", include_in_schema=False)
async def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
):
if not check_credentials(username, password):
logger.warning("failed login attempt for user: %s", username)
html = (_STATIC / "login.html").read_text().replace(
"<!-- ERROR -->",
'<p class="error">Invalid username or password.</p>',
)
return HTMLResponse(html, status_code=401)
persona = _first_persona(username)
if not persona:
return HTMLResponse("<h1>No personas configured for your account.</h1>", status_code=500)
logger.info("login: %s", username)
resp = RedirectResponse(f"/{username}/{persona}", status_code=302)
_set_cookie(resp, username)
return resp
@router.post("/logout", include_in_schema=False)
async def logout():
resp = RedirectResponse("/login", status_code=302)
resp.delete_cookie(COOKIE_NAME)
return resp
# ---------------------------------------------------------------------------
# User landing — /{username} → persona picker
# ---------------------------------------------------------------------------
@router.get("/{username}", include_in_schema=False)
async def user_landing(username: str, request: Request):
session_user = _get_session_user(request)
if not session_user:
return RedirectResponse("/login", status_code=302)
if session_user != username:
return RedirectResponse(f"/{session_user}", status_code=302)
personas = list_user_personas(username)
if not personas:
return HTMLResponse("<h1>No personas configured.</h1>", status_code=404)
cards_html = ""
for p in personas:
emoji = ""
identity_path = persona_path(username, p) / "IDENTITY.md"
if identity_path.exists():
m = re.search(r"\|\s*Emoji\s*\|\s*(.+?)\s*\|", identity_path.read_text())
if m:
emoji = m.group(1).strip()
cards_html += (
f'<a href="/{username}/{p}" class="persona-card">'
f'<span class="p-emoji">{emoji}</span>'
f'<span class="p-name">{p.capitalize()}</span>'
f'</a>\n'
)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — {username}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #1a1228;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-weight: 450;
-webkit-font-smoothing: antialiased;
color: #e8e0f0;
padding: 2rem 1.5rem;
}}
.card {{
background: #221840;
border: 1px solid #3a2852;
border-radius: 14px;
padding: 2.5rem 2rem;
width: 100%;
max-width: 400px;
text-align: center;
}}
h1 {{ font-size: 1.3rem; font-weight: 700; color: #c4935a; margin-bottom: 0.4rem; }}
.sub {{ font-size: 0.82rem; color: #b0a2c8; margin-bottom: 2rem; }}
.personas {{ display: flex; flex-direction: column; gap: 0.75rem; }}
.persona-card {{
display: flex;
align-items: center;
gap: 1rem;
padding: 0.85rem 1.2rem;
background: #1a1228;
border: 1px solid #3a2852;
border-radius: 10px;
color: #e8e0f0;
text-decoration: none;
font-size: 1rem;
font-weight: 500;
transition: border-color 0.15s, background 0.15s;
}}
.persona-card:hover {{ border-color: #c4935a; background: #261d42; }}
.p-emoji {{ font-size: 1.6rem; line-height: 1; }}
.p-name {{ color: #c4935a; font-weight: 600; }}
.settings-link {{
display: inline-block;
margin-top: 1.5rem;
font-size: 0.78rem;
color: #b0a2c8;
text-decoration: none;
}}
.settings-link:hover {{ color: #e8e0f0; }}
</style>
</head>
<body>
<div class="card">
<h1>Cortex</h1>
<p class="sub">Signed in as <strong>{username}</strong> — choose a persona</p>
<div class="personas">
{cards_html} </div>
<a href="/settings" class="settings-link">Account settings</a>
</div>
</body>
</html>"""
return HTMLResponse(html)
# ---------------------------------------------------------------------------
# Main UI — /{username}/{persona}
# ---------------------------------------------------------------------------
@router.get("/api/personas", tags=["ui"])
async def api_personas(request: Request) -> dict:
"""Return the list of personas for the current session user."""
user = _get_session_user(request)
if not user:
from fastapi import HTTPException
raise HTTPException(status_code=401, detail="Not authenticated")
return {"user": user, "personas": list_user_personas(user)}
@router.get("/{username}/{persona}", include_in_schema=False)
@router.get("/{username}/{persona}/", include_in_schema=False)
async def serve_ui(username: str, persona: str, request: Request):
# Auth check
session_user = _get_session_user(request)
if not session_user:
return RedirectResponse("/login", status_code=302)
if session_user != username:
return RedirectResponse(f"/{session_user}/{_first_persona(session_user) or ''}", status_code=302)
# Validate persona exists
try:
validate_persona(username, persona)
except ValueError:
return RedirectResponse(f"/{username}/{_first_persona(username) or ''}", status_code=302)
# Read emoji from IDENTITY.md (| Emoji | <value> | line)
emoji = ""
identity_path = persona_path(username, persona) / "IDENTITY.md"
if identity_path.exists():
m = re.search(r"\|\s*Emoji\s*\|\s*(.+?)\s*\|", identity_path.read_text())
if m:
emoji = m.group(1).strip()
# Serve index.html with user/persona/emoji injected
html = (_STATIC / "index.html").read_text()
config_tag = (
f'<script>window.CORTEX_CONFIG = '
f'{{user: "{username}", persona: "{persona}", emoji: "{emoji}"}};</script>'
)
html = html.replace("</head>", f"{config_tag}\n</head>", 1)
return HTMLResponse(html)