Sanad Lite d9b2d5427f Voice fidelity, Live Gemini tab, and dashboard fixes
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.
2026-09-02 22:56:18 +04:00

213 lines
7.5 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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")}