Replay now matches the robots and no longer cuts words: - read the turn to turnComplete, not generationComplete, and drain the socket before each send; breaking early truncated every sentence and left frames that the next turn mis-read as its own reply - accept a take only if the model's own transcript covers the text AND the audio is long enough to contain it (the transcript reports the full text even for a 0.8s clip) - pitch gate: reject an off-tone take and re-ask, per voice, using a pure-Python F0 estimator (no numpy on the host) - continuation: speak the words a voice skipped instead of retrying a line it stops on deterministically - fresh Live session per replay; delivery drifts as turns accumulate Live Gemini tab: browser talks to Gemini directly (the reverse proxy cannot upgrade a WebSocket), with a persona library - named personas, per-robot selection, built-ins that cannot be overwritten. Dashboard: records search + voice filter, log panel falls back to polling, sign-in history with CSV/JSON export, and JS errors now show on the page instead of silently blanking a tab.
213 lines
7.5 KiB
Python
213 lines
7.5 KiB
Python
"""Dashboard login — minimal cookie-session auth.
|
||
|
||
Credentials come from `core_config.json` → `auth.{username,password}`.
|
||
The session is signed by Starlette's SessionMiddleware (stateless cookie).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||
from pydantic import BaseModel
|
||
|
||
from Project.Sanad.config import BASE_DIR
|
||
from Project.Sanad.core.config_loader import section as _cfg_section
|
||
from Project.Sanad.core.logger import get_logger
|
||
|
||
router = APIRouter()
|
||
log = get_logger("dashboard.auth")
|
||
|
||
_AUTH_CFG = _cfg_section("core", "auth") or {}
|
||
USERNAME = _AUTH_CFG.get("username", "admin")
|
||
PASSWORD = _AUTH_CFG.get("password", "admin")
|
||
|
||
LOGIN_PAGE = BASE_DIR / "dashboard" / "static" / "login.html"
|
||
|
||
|
||
def is_authed(request: Request) -> bool:
|
||
return bool(request.session.get("user"))
|
||
|
||
|
||
# ── login history ────────────────────────────────────────────────────
|
||
# Kept in a small JSON file rather than only in the log, so the dashboard can
|
||
# show "last login, from which device" without parsing log text.
|
||
LOGIN_HISTORY = BASE_DIR / "data" / "logins.json"
|
||
_MAX_HISTORY = 200
|
||
|
||
|
||
def _client_ip(request: Request) -> str:
|
||
"""Real client IP. The site sits behind Cloudflare and then an Apache
|
||
reverse proxy, so request.client is always 127.0.0.1 — the forwarded
|
||
headers are the only source of the actual address."""
|
||
for header in ("cf-connecting-ip", "x-forwarded-for", "x-real-ip"):
|
||
value = request.headers.get(header, "")
|
||
if value:
|
||
return value.split(",")[0].strip()
|
||
return getattr(request.client, "host", "") or "?"
|
||
|
||
|
||
def _describe_device(request: Request) -> str:
|
||
"""Short human label for the User-Agent — "Chrome on Windows", not 200
|
||
characters of tokens."""
|
||
ua = request.headers.get("user-agent", "")
|
||
if not ua:
|
||
return "unknown device"
|
||
low = ua.lower()
|
||
if "android" in low:
|
||
platform = "Android"
|
||
elif "iphone" in low:
|
||
platform = "iPhone"
|
||
elif "ipad" in low:
|
||
platform = "iPad"
|
||
elif "windows" in low:
|
||
platform = "Windows"
|
||
elif "mac os" in low or "macintosh" in low:
|
||
platform = "Mac"
|
||
elif "linux" in low:
|
||
platform = "Linux"
|
||
else:
|
||
platform = "unknown OS"
|
||
# order matters: Edge/Opera also contain "chrome", Chrome contains "safari"
|
||
if "edg/" in low:
|
||
browser = "Edge"
|
||
elif "opr/" in low or "opera" in low:
|
||
browser = "Opera"
|
||
elif "firefox" in low:
|
||
browser = "Firefox"
|
||
elif "chrome" in low or "crios" in low:
|
||
browser = "Chrome"
|
||
elif "safari" in low:
|
||
browser = "Safari"
|
||
elif "curl" in low:
|
||
browser = "curl"
|
||
elif "powershell" in low or "winhttp" in low:
|
||
browser = "PowerShell"
|
||
else:
|
||
browser = "unknown browser"
|
||
return f"{browser} on {platform}"
|
||
|
||
|
||
def _load_logins() -> list:
|
||
try:
|
||
import json
|
||
return json.loads(LOGIN_HISTORY.read_text(encoding="utf-8")) or []
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _record_login(request: Request, username: str, ok: bool) -> None:
|
||
"""Append one sign-in attempt. Never raises: a logging problem must not
|
||
stop someone from signing in."""
|
||
try:
|
||
import json
|
||
from datetime import datetime
|
||
entries = _load_logins()
|
||
entries.append({
|
||
"at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"user": username,
|
||
"ok": bool(ok),
|
||
"device": _describe_device(request),
|
||
"ip": _client_ip(request),
|
||
"user_agent": request.headers.get("user-agent", "")[:300],
|
||
})
|
||
del entries[:-_MAX_HISTORY]
|
||
LOGIN_HISTORY.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = LOGIN_HISTORY.with_suffix(".json.tmp")
|
||
tmp.write_text(json.dumps(entries, ensure_ascii=False, indent=1),
|
||
encoding="utf-8")
|
||
tmp.replace(LOGIN_HISTORY)
|
||
except Exception:
|
||
log.exception("could not record login history")
|
||
|
||
|
||
class LoginPayload(BaseModel):
|
||
username: str
|
||
password: str
|
||
|
||
|
||
@router.get("/login", include_in_schema=False)
|
||
async def login_page(request: Request):
|
||
if is_authed(request):
|
||
return RedirectResponse("/", status_code=303)
|
||
if LOGIN_PAGE.exists():
|
||
return HTMLResponse(LOGIN_PAGE.read_text(encoding="utf-8"))
|
||
return HTMLResponse("<h1>Login page missing</h1>", status_code=500)
|
||
|
||
|
||
@router.post("/api/auth/login")
|
||
async def login(request: Request, payload: LoginPayload):
|
||
if payload.username == USERNAME and payload.password == PASSWORD:
|
||
request.session["user"] = payload.username
|
||
log.info("login OK: %s (%s)", payload.username, _describe_device(request))
|
||
_record_login(request, payload.username, True)
|
||
return {"ok": True, "user": payload.username}
|
||
log.warning("login FAILED: %s (%s)", payload.username, _describe_device(request))
|
||
_record_login(request, payload.username, False)
|
||
raise HTTPException(401, "Invalid username or password")
|
||
|
||
|
||
@router.get("/api/auth/logins/export")
|
||
async def export_logins(request: Request, format: str = "csv"):
|
||
"""Download the full sign-in history (not just the rows on screen).
|
||
|
||
CSV is written with a UTF-8 BOM so Excel opens it with the columns split
|
||
and any non-ASCII intact; without it Excel mangles both.
|
||
"""
|
||
if not is_authed(request):
|
||
raise HTTPException(401, "Not authenticated")
|
||
import csv
|
||
import io as _io
|
||
import json
|
||
from datetime import datetime
|
||
|
||
entries = list(reversed(_load_logins())) # newest first
|
||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
|
||
if format.lower() == "json":
|
||
return Response(
|
||
content=json.dumps(entries, ensure_ascii=False, indent=1),
|
||
media_type="application/json; charset=utf-8",
|
||
headers={"Content-Disposition":
|
||
f'attachment; filename="sanadlite-signins-{stamp}.json"'})
|
||
|
||
buf = _io.StringIO()
|
||
writer = csv.writer(buf, lineterminator="\n")
|
||
writer.writerow(["when", "user", "result", "device", "ip", "user_agent"])
|
||
for e in entries:
|
||
writer.writerow([e.get("at", ""), e.get("user", ""),
|
||
"ok" if e.get("ok") else "failed",
|
||
e.get("device", ""), e.get("ip", ""),
|
||
e.get("user_agent", "")])
|
||
return Response(
|
||
content="" + buf.getvalue(),
|
||
media_type="text/csv; charset=utf-8",
|
||
headers={"Content-Disposition":
|
||
f'attachment; filename="sanadlite-signins-{stamp}.csv"'})
|
||
|
||
|
||
@router.get("/api/auth/logins")
|
||
async def login_history(request: Request, limit: int = 20):
|
||
"""Recent sign-ins: when, from which device, and from which IP.
|
||
|
||
Failed attempts are included too — an unexplained failure from an unknown
|
||
device is the thing worth noticing on a dashboard that is open to the
|
||
internet.
|
||
"""
|
||
if not is_authed(request):
|
||
raise HTTPException(401, "Not authenticated")
|
||
entries = _load_logins()[-max(1, min(int(limit), 200)):]
|
||
entries.reverse()
|
||
return {"logins": entries, "total": len(_load_logins())}
|
||
|
||
|
||
@router.post("/api/auth/logout")
|
||
async def logout(request: Request):
|
||
user = request.session.pop("user", None)
|
||
log.info("logout: %s", user)
|
||
return {"ok": True}
|
||
|
||
|
||
@router.get("/api/auth/me")
|
||
async def whoami(request: Request):
|
||
return {"authenticated": is_authed(request), "user": request.session.get("user")}
|