- session_store: store sessions under home/{user}/persona/{name}/session_data/
instead of the shared cortex/data/sessions/ bucket
- chat endpoints: add user/persona query params to /sessions, /history/*,
/sessions/*, /note so they resolve the correct persona context
- files router: add user/persona query params to /files and /files/{name}
so the file browser loads the right persona's files
- app.js: pass user/persona on all session, history, and file fetches;
move _fileParams to top-level scope so it is available everywhere
- onboarding: fix FastAPI route ordering — register /persona before /{token}
so the literal path wins and does not get captured as a token value
- ui.py: read Emoji field from IDENTITY.md and inject into CORTEX_CONFIG
so the header icon reflects each persona's chosen emoji
- .gitignore: exclude home/**/session_data/ (runtime state)
- migrate scott/inara sessions from cortex/data/sessions/ to session_data/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
5.9 KiB
Python
172 lines
5.9 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|