This commit is contained in:
2026-05-11 08:27:44 +02:00
parent ab5a0c7697
commit 8261a281c4
96 changed files with 271838 additions and 778 deletions

View File

@@ -6,6 +6,7 @@ Geraeteverwaltung, Kanaele, Praxis-Federation.
Alle Daten practice-scoped. Backend ist die einzige Wahrheit.
"""
import base64
import hashlib
import hmac
import json
@@ -14,6 +15,7 @@ import os
import re
from collections import defaultdict
import secrets
import tempfile
import time
import unicodedata
import uuid
@@ -691,24 +693,54 @@ def _extract_client_ip(request: Request) -> str:
EMPFANG_MESSAGE_RETENTION_DAYS = 14
def _utc_now_iso_z() -> str:
"""UTC-Zeitstempel fuer Chat-Nachrichten, ISO-8601 mit Z (eindeutig UTC)."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_msg_instant_utc_ts(raw: str) -> float:
"""Parse eines gespeicherten Chat-Zeitstempels zu POSIX-Sekunden (UTC).
Naive Legacy-Strings ('YYYY-MM-DD HH:MM:SS' / '...T...' ohne TZ) werden
wie in Produktion ueblich als UTC interpretiert (Server/VPS typisch)."""
s = (raw or "").strip()
if not s:
return 0.0
s_norm = s.replace(" ", "T", 1)
try:
if s_norm.endswith("Z"):
dt = datetime.fromisoformat(s_norm.replace("Z", "+00:00"))
return dt.timestamp()
dt = datetime.fromisoformat(s_norm)
if dt.tzinfo is not None:
return dt.timestamp()
return dt.replace(tzinfo=timezone.utc).timestamp()
except Exception:
return 0.0
def _msg_timestamp_for_retention(m: dict) -> str:
return (m.get("empfangen") or m.get("zeitstempel") or "").strip()
def _message_within_retention(m: dict, cutoff_str: str) -> bool:
"""Behalten wenn Zeitstempel unbekannt oder >= cutoff (ISO-artige Strings)."""
def _msg_chrono_sort_key(m: dict) -> tuple[float, str]:
raw = _msg_timestamp_for_retention(m)
return (_parse_msg_instant_utc_ts(raw), str(m.get("id") or ""))
def _message_within_retention(m: dict, cutoff_ts: float) -> bool:
"""Behalten wenn Zeitstempel unbekannt oder Augenblick >= cutoff (UTC-Sekunden)."""
t = _msg_timestamp_for_retention(m)
if not t:
return True
return t >= cutoff_str
ts = _parse_msg_instant_utc_ts(t)
if ts <= 0:
return True
return ts >= cutoff_ts
def _prune_messages_by_retention(messages: list[dict]) -> tuple[list[dict], int]:
cutoff_str = time.strftime(
"%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - EMPFANG_MESSAGE_RETENTION_DAYS * 86400),
)
kept = [m for m in messages if _message_within_retention(m, cutoff_str)]
cutoff_ts = time.time() - EMPFANG_MESSAGE_RETENTION_DAYS * 86400
kept = [m for m in messages if _message_within_retention(m, cutoff_ts)]
return kept, len(messages) - len(kept)
@@ -1225,15 +1257,54 @@ async def auth_provision(request: Request):
pid = request.headers.get("X-Practice-Id", "").strip()
pid = pid or (body.get("practice_id") or "").strip()
if not pid and email:
try:
from stripe_routes import lookup_practice_id_for_license_email
resolved_from_license = False
resolved_practice_name = ""
lp = lookup_practice_id_for_license_email(email)
if lp:
pid = lp.strip()
if not pid:
license_key_in = (body.get("license_key") or "").strip()
cand_list: list[str] = []
seen_c: set[str] = set()
try:
from stripe_routes import (
list_distinct_practice_ids_for_license_email,
list_distinct_practice_ids_for_license_key,
)
for p in list_distinct_practice_ids_for_license_email(email):
p = (p or "").strip()
if p and p not in seen_c:
seen_c.add(p)
cand_list.append(p)
for p in list_distinct_practice_ids_for_license_key(license_key_in):
p = (p or "").strip()
if p and p not in seen_c:
seen_c.add(p)
cand_list.append(p)
except Exception as exc:
print(f"[EMPFANG] lookup_practice_id_for_license_email: {exc}")
print(f"[EMPFANG] license practice resolution: {exc}")
if len(cand_list) > 1:
practices = _load_practices()
candidates_payload = []
for p in cand_list:
pdata = practices.get(p) or {}
pname = (pdata.get("name") or "").strip() or p
candidates_payload.append({"practice_id": p, "practice_name": pname})
return JSONResponse(content={
"success": False,
"step": "choose_practice",
"message": (
"Mehrere Praxen passen zu dieser Lizenz bzw. E-Mail. "
"Bitte waehlen Sie die Praxis aus, der dieses Geraet zugehoeren soll."
),
"candidates": candidates_payload,
})
if len(cand_list) == 1:
pid = cand_list[0]
resolved_from_license = True
practices = _load_practices()
pdata = practices.get(pid) or {}
resolved_practice_name = (pdata.get("name") or "").strip()
if not pid:
practices = _load_practices()
@@ -1290,12 +1361,17 @@ async def auth_provision(request: Request):
if not has_admin:
target["role"] = "admin"
_save_accounts(accounts)
return JSONResponse(content={
body_out = {
"success": True, "user_id": target["user_id"],
"display_name": target["display_name"], "role": target["role"],
"practice_id": pid,
"action": "updated",
})
}
if resolved_from_license:
body_out["resolved_existing_practice"] = True
if resolved_practice_name:
body_out["resolved_practice_name"] = resolved_practice_name
return JSONResponse(content=body_out)
has_admin = any(a.get("role") == "admin" and a.get("practice_id") == pid
for a in accounts.values())
@@ -1316,12 +1392,17 @@ async def auth_provision(request: Request):
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
}
_save_accounts(accounts)
return JSONResponse(content={
body_created = {
"success": True, "user_id": uid,
"display_name": name, "role": role,
"practice_id": pid,
"action": "created",
})
}
if resolved_from_license:
body_created["resolved_existing_practice"] = True
if resolved_practice_name:
body_created["resolved_practice_name"] = resolved_practice_name
return JSONResponse(content=body_created)
@router.post("/auth/forgot_password")
@@ -2572,6 +2653,7 @@ async def empfang_send(msg: EmpfangMessage, request: Request):
else:
thread_id = reply_to
_ts = _utc_now_iso_z()
entry = {
"id": msg_id,
"thread_id": thread_id,
@@ -2582,8 +2664,8 @@ async def empfang_send(msg: EmpfangMessage, request: Request):
"kommentar": msg.kommentar.strip(),
"patient": msg.patient.strip(),
"absender": absender,
"zeitstempel": msg.zeitstempel.strip() or time.strftime("%Y-%m-%d %H:%M:%S"),
"empfangen": time.strftime("%Y-%m-%d %H:%M:%S"),
"zeitstempel": _ts,
"empfangen": _ts,
"status": "offen",
"user_id": s["user_id"] if s else "",
}
@@ -2661,10 +2743,13 @@ def _pulse_get(practice_id: str) -> dict:
# Clients nach Server-Restart nicht alle "neue Nachricht!" denken.
msgs = _filter_by_practice(_load_messages(), practice_id)
latest = ""
latest_ts = 0.0
for m in msgs:
t = (m.get("empfangen") or m.get("zeitstempel") or "")
if t > latest:
latest = t
t_raw = (m.get("empfangen") or m.get("zeitstempel") or "").strip()
ts = _parse_msg_instant_utc_ts(t_raw)
if ts >= latest_ts and t_raw:
latest_ts = ts
latest = t_raw
p = {"tick": 1, "ts": time.time(), "last_sender": "", "boot": latest}
_PRACTICE_PULSE[practice_id] = p
return p
@@ -3401,7 +3486,7 @@ def _conversation_for_audience(
if _thread_requires_broadcast_exclusion(messages, pid, tid):
continue
out.append(m)
out.sort(key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""))
out.sort(key=_msg_chrono_sort_key)
return out
# --- Gruppen-Chat ---
@@ -3424,7 +3509,7 @@ def _conversation_for_audience(
sn = _norm_name(_sender_core(m.get("absender", "")))
if sn and sn in participants:
out.append(m)
out.sort(key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""))
out.sort(key=_msg_chrono_sort_key)
return out
# --- 1:1 Direktverlauf ---
@@ -3440,7 +3525,7 @@ def _conversation_for_audience(
aud_raw,
)
out.extend(dm_list)
out.sort(key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""))
out.sort(key=_msg_chrono_sort_key)
return out
@@ -3670,7 +3755,7 @@ def _dm_v2_load_for_pair(pid: str, uid_a: str, uid_b: str) -> tuple[list[dict],
ex = m.get("extras") or {}
if str(ex.get("direct_conv_key") or "").strip() == conv_key:
out.append(m)
out.sort(key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""))
out.sort(key=_msg_chrono_sort_key)
return out, conv_key
@@ -3717,7 +3802,7 @@ async def empfang_dm_send(payload: DmSendIn, request: Request):
conv_key = _direct_conv_key(pid, sender_uid, recipient_uid)
msg_id = uuid.uuid4().hex[:12]
now = time.strftime("%Y-%m-%d %H:%M:%S")
now = _utc_now_iso_z()
extras = {
"audience": "direct",
"rcpt_broadcast": False,
@@ -3854,7 +3939,7 @@ async def empfang_thread(thread_id: str, request: Request,
messages = _load_messages()
thread = [m for m in messages
if m.get("thread_id") == thread_id and _msg_practice(m) == pid]
thread.sort(key=lambda m: m.get("empfangen", ""))
thread.sort(key=_msg_chrono_sort_key)
return JSONResponse(content={"success": True, "messages": thread})
@@ -3877,6 +3962,228 @@ async def empfang_done(msg_id: str):
return JSONResponse(content={"success": True})
EMPFANG_MSG_TRANSCRIBE_MAX_BYTES = 2 * 1024 * 1024
def _attachment_is_audio_dict(a: object) -> bool:
if not isinstance(a, dict):
return False
if str(a.get("kind") or "").strip().lower() == "audio":
return True
mt = str(a.get("mime") or "").strip().lower()
if mt.startswith("audio/"):
return True
n = str(a.get("name") or "").strip().lower()
return bool(
re.search(r"\.(webm|ogg|opus|wav|mp3|m4a)$", n, re.I)
)
def _audio_suffix_for_attachment(att: dict) -> str:
"""Endung fuer die Tempfile abgeleitet aus dem MIME-Typ.
Wichtig: ``audio/webm`` und ``audio/webm;codecs=opus`` muessen ``.webm`` werden,
weil die Bytes ein WebM-Container sind (Edge/Chrome MediaRecorder). Erst danach
werden echte OGG-/Opus-Faelle wie ``audio/ogg`` oder ``audio/opus`` als ``.ogg``
bzw. ``.opus`` behandelt. Falsche Container-/Endungs-Kombination ist ein
bekannter Ausloeser fuer OpenAI BadRequestError.
"""
mt = str(att.get("mime") or "").lower()
if "webm" in mt:
return ".webm"
if "wav" in mt:
return ".wav"
if "mp4" in mt or "m4a" in mt or "aac" in mt:
return ".m4a"
if "ogg" in mt:
return ".ogg"
if "opus" in mt:
return ".opus"
if "mpeg" in mt or "mp3" in mt:
return ".mp3"
n = str(att.get("name") or "").lower()
if n.endswith(".wav"):
return ".wav"
if n.endswith(".m4a"):
return ".m4a"
if n.endswith(".ogg"):
return ".ogg"
if n.endswith(".opus"):
return ".opus"
if n.endswith(".mp3"):
return ".mp3"
return ".webm"
def _empfang_transcribe_openai_from_bytes(
audio_bytes: bytes,
*,
filename_suffix: str = ".webm",
) -> str:
"""Eine Audiodatei transkribieren (OpenAI wie backend /v1/transcribe). Tempfile wird geloescht."""
import backend_main as bm
tmp_path: Optional[str] = None
try:
client = bm._get_openai()
with tempfile.NamedTemporaryFile(
prefix="aza_ef_tr_",
suffix=filename_suffix,
delete=False,
) as tmp:
tmp.write(audio_bytes)
tmp_path = tmp.name
with open(tmp_path, "rb") as f:
is_gpt = "gpt-" in bm.TRANSCRIBE_MODEL
params: dict = dict(model=bm.TRANSCRIBE_MODEL, file=f, language="de")
dom = "medical"
chosen = (
bm.WHISPER_GENERAL_PROMPT
if dom == "general"
else bm.WHISPER_MEDICAL_PROMPT
)
if is_gpt:
params["prompt"] = bm.GPT_TRANSCRIBE_SHORT_PROMPT
else:
params["prompt"] = chosen
params["temperature"] = 0.0
resp = client.audio.transcriptions.create(**params)
text = getattr(resp, "text", "") or ""
if not text:
try:
if hasattr(resp, "model_dump"):
dd = resp.model_dump()
if isinstance(dd, dict):
text = dd.get("text", "") or ""
except Exception:
pass
t_stripped = text.lstrip()
if t_stripped.startswith(bm.WHISPER_PROMPT_PREFIX):
text = t_stripped[len(bm.WHISPER_PROMPT_PREFIX) :].lstrip(" :\t\r\n-")
text = (text or "").replace("ß", "ss")
text = bm.apply_medical_corrections(text, "")
text = bm.apply_medical_post_corrections(text)
text = bm.apply_medication_fuzzy_corrections(text)
return (text or "").strip()
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
@router.post("/messages/{msg_id}/transcribe-audio")
async def empfang_message_transcribe_audio(msg_id: str, request: Request):
"""Transkribiert das erste Audio-Attachment einer Nachricht; speichert ``transcript`` am Attachment."""
_require_session(request)
pid = _require_practice_id(request)
messages = _load_messages()
target = next((m for m in messages if m.get("id") == msg_id), None)
if not target:
raise HTTPException(status_code=404, detail="Nachricht nicht gefunden")
if _msg_practice(target) != pid:
raise HTTPException(status_code=403, detail="Kein Zugriff")
ex = dict(target.get("extras") or {})
attachments = ex.get("attachments")
if not isinstance(attachments, list):
attachments = []
idx: Optional[int] = None
att: Optional[dict] = None
for i, raw_a in enumerate(attachments):
if isinstance(raw_a, dict) and _attachment_is_audio_dict(raw_a):
idx = i
att = raw_a
break
if idx is None or not att:
raise HTTPException(status_code=400, detail="Keine Audiodatei in dieser Nachricht")
existing = str(att.get("transcript") or "").strip()
if existing:
mid_short = (msg_id or "")[:12]
_log.info(
"EMPFANG_TRANSCRIBE_CACHE msg=%s att=%s",
mid_short,
idx,
)
return JSONResponse(
content={
"success": True,
"transcript": existing,
"cached": True,
"attachment_index": idx,
}
)
b64 = str(att.get("data") or "").strip()
if not b64:
raise HTTPException(status_code=400, detail="Audiodaten fehlen")
try:
raw = base64.b64decode(b64, validate=False)
except Exception:
raise HTTPException(status_code=400, detail="Audiodaten ungueltig")
nbytes = len(raw)
if nbytes < 1 or nbytes > EMPFANG_MSG_TRANSCRIBE_MAX_BYTES:
raise HTTPException(status_code=413, detail="Audiodatei zu gross")
suffix = _audio_suffix_for_attachment(att)
try:
transcript = _empfang_transcribe_openai_from_bytes(raw, filename_suffix=suffix)
except HTTPException:
raise
except Exception as exc:
mid_short = (msg_id or "")[:12]
err_type = type(exc).__name__
try:
err_short = str(exc)
except Exception:
err_short = ""
err_short = (err_short or "").strip()
if len(err_short) > 200:
err_short = err_short[:200]
if err_short:
err_short = err_short.replace("\n", " ").replace("\r", " ")
_log.warning(
"EMPFANG_TRANSCRIBE_FAIL msg=%s bytes=%s suffix=%s err=%s short=%s",
mid_short,
nbytes,
suffix,
err_type,
err_short,
)
raise HTTPException(
status_code=503,
detail=f"OpenAI: {err_type}",
) from exc
if not transcript:
mid_short = (msg_id or "")[:12]
_log.info("EMPFANG_TRANSCRIBE_EMPTY msg=%s bytes=%s", mid_short, nbytes)
raise HTTPException(status_code=502, detail="Leeres Transkript")
att_new = dict(att)
att_new["transcript"] = transcript
att_new["transcript_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
attachments = list(attachments)
attachments[idx] = att_new
ex["attachments"] = attachments
target["extras"] = ex
_save_messages(messages)
try:
_pulse_bump(pid, sender="")
except Exception:
pass
mid_short = (msg_id or "")[:12]
_log.info(
"EMPFANG_TRANSCRIBE_OK msg=%s bytes=%s",
mid_short,
nbytes,
)
return JSONResponse(
content={
"success": True,
"transcript": transcript,
"cached": False,
"attachment_index": idx,
}
)
@router.post("/messages/{msg_id}/chat-ack")
async def empfang_message_chat_ack(msg_id: str, request: Request):
"""OK/Kenntnisnahme pro Nachricht: nur ``extras.chat_ack`` (kein Thread-Status)."""
@@ -4302,17 +4609,18 @@ async def empfang_cleanup(request: Request):
pid = (body.get("practice_id") or "").strip() or _resolve_practice_id(request)
if not pid:
return JSONResponse(content={"success": True, "removed": 0, "remaining": 0})
cutoff = time.strftime(
"%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - max_days * 86400),
)
cutoff_ts = time.time() - max_days * 86400
messages = _load_messages()
before = len(messages)
kept = [
m for m in messages
if _msg_practice(m) != pid
or (m.get("empfangen") or m.get("zeitstempel", "")) >= cutoff
]
kept = []
for m in messages:
if _msg_practice(m) != pid:
kept.append(m)
continue
raw_t = (m.get("empfangen") or m.get("zeitstempel") or "").strip()
ts = _parse_msg_instant_utc_ts(raw_t)
if ts <= 0 or ts >= cutoff_ts:
kept.append(m)
removed = before - len(kept)
if removed > 0:
_save_messages(kept)
@@ -4338,6 +4646,7 @@ async def empfang_practice_info(request: Request):
result = {
"practice_id": pid,
"practice_name": p.get("name", ""),
"practice_timezone": str(p.get("timezone") or "").strip(),
"user_count": len(users),
"message_count": len(messages),
"open_count": open_count,
@@ -4744,14 +5053,20 @@ async def empfang_shell_consume(request: Request):
async def empfang_shell_launch(
request: Request,
token: str = Query("", description="kurzlebiger Shell-Token (einmaliger Verbrauch)"),
target: str = Query("", description="optional: 'empfang_chat_shell' fuer Empfang-Huelle"),
):
"""Web-Huelle: Token per GET einloesen — setzt Cookie, Redirect ohne Token in URL.
Einmal-Verbrauch. Kein MEDWORK_API-Token hier (Browser/WebView ohne Desktop-Secret).
target=empfang_chat_shell markiert die separate Empfang-Chat-Huelle (kein Arzt-Desktop).
"""
sess_token, _pub = _consume_shell_token_core(request, token)
loc = "/empfang/?desktop_shell=1&shell_source=aza_desktop"
t = (target or "").strip().lower()
if t == "empfang_chat_shell":
loc = "/empfang/?empfang_chat_shell=1&shell_source=empfang_chat_shell"
else:
loc = "/empfang/?desktop_shell=1&shell_source=aza_desktop"
resp = RedirectResponse(url=loc, status_code=302)
resp.set_cookie(
"aza_session",
@@ -4763,6 +5078,187 @@ async def empfang_shell_launch(
return resp
# =====================================================================
# Empfang-Chat-Huelle: Browser->native-Huelle Handoff
# =====================================================================
#
# Ziel: Eingeloggter Browser-Empfang kann die separat installierte
# native Empfang-Chat-Huelle (AZA_EmpfangShell.exe) mit derselben Praxis-/
# Chat-Session verbinden, OHNE Arztlizenz / X-API-Token.
#
# Sicherheitsmodell:
# - /empfang/handoff/create erfordert eingeloggte Empfang-Session (Cookie).
# - Erzeugt einen kurzlebigen shell_token (gleiches _shell_store wie Desktop-
# Launch) plus einen kurzen, lesbaren Verbindungscode XXXX-XXXX, der nur
# auf den shell_token verweist (eigener Store, einmalig konsumiert).
# - /empfang/handoff/lookup loest den Verbindungscode in den shell_token auf.
# Kein Login noetig - der Code IST das Geheimnis. Einmal-Verbrauch.
# - Der shell_token selbst wird wie gewohnt durch /empfang/shell/launch
# einmal verbraucht (HttpOnly Cookie in der nativen Huelle gesetzt).
# - Keine Chatdaten werden im Handoff transportiert.
_HANDOFF_TTL_DEFAULT = 300 # 5 Minuten
_HANDOFF_PURPOSE = "empfang_chat_shell_handoff"
_handoff_short_codes: dict[str, dict] = {}
def _handoff_short_codes_cleanup() -> None:
now = time.time()
stale = [
c for c, rec in _handoff_short_codes.items()
if isinstance(rec, dict) and float(rec.get("expires_at", 0)) < now
]
for c in stale:
try:
del _handoff_short_codes[c]
except KeyError:
pass
def _normalize_short_code(raw: str) -> str:
"""Vergleichbar machen: Grossbuchstaben, Bindestrich-Varianten -> '-', Leerzeichen weg."""
s = (raw or "").strip().upper().replace(" ", "")
for ch in ("\u2011", "\u2013", "\u2014", "\u2212", "_"):
s = s.replace(ch, "-")
return s
def _generate_short_handoff_code() -> str:
"""8 Zeichen lesbar, ohne 0/O/1/I-Verwechslungen, im Format XXXX-XXXX."""
import random
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
a = "".join(random.choices(chars, k=4))
b = "".join(random.choices(chars, k=4))
return f"{a}-{b}"
@router.post("/handoff/create")
async def empfang_handoff_create(request: Request):
"""Browser-Empfang -> Empfang-Chat-Huelle: kurzlebiger Handoff-Token + Verbindungscode.
Erfordert eine bestehende Empfang-Session (HttpOnly-Cookie).
Keine Arzt-Lizenz / kein X-API-Token. Kein Chat-Inhalt im Token.
"""
_shell_cleanup_expired()
_handoff_short_codes_cleanup()
s = _require_session(request)
pid = (s.get("practice_id") or "").strip()
uid = (s.get("user_id") or "").strip()
if not pid or not uid:
raise HTTPException(status_code=400, detail="Session unvollstaendig")
accounts = _load_accounts()
acc = accounts.get(uid) or {}
display_name = (acc.get("display_name")
or s.get("display_name") or "").strip() or "Benutzer"
role = (acc.get("role") or s.get("role") or "mpa").strip()
if (acc.get("status") or "active") != "active":
raise HTTPException(status_code=403, detail="Benutzerkonto nicht aktiv")
now = time.time()
expires_at = now + float(_HANDOFF_TTL_DEFAULT)
shell_token = secrets.token_urlsafe(32)
# Eindeutigen kurzen Verbindungscode erzeugen
short_code = _generate_short_handoff_code()
tries = 0
while short_code in _handoff_short_codes and tries < 8:
short_code = _generate_short_handoff_code()
tries += 1
_shell_store[shell_token] = {
"practice_id": pid,
"user_id": uid,
"display_name": display_name,
"role": role,
"purpose": _HANDOFF_PURPOSE,
"expires_at": expires_at,
"created_at": now,
}
_handoff_short_codes[short_code] = {
"shell_token": shell_token,
"expires_at": expires_at,
"practice_id": pid,
"user_id": uid,
}
expires_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(expires_at))
launch_path = (
f"/empfang/shell/launch?token={shell_token}&target=empfang_chat_shell"
)
_log.info(
"AZA_EMPFANG_HANDOFF_CREATED practice=%s user=%s purpose=%s ttl=%s",
pid, uid, _HANDOFF_PURPOSE, int(_HANDOFF_TTL_DEFAULT),
)
return JSONResponse(content={
"success": True,
"short_code": short_code,
"launch_path": launch_path,
"expires_at": expires_iso,
"expires_at_unix": int(expires_at),
"ttl_seconds": int(_HANDOFF_TTL_DEFAULT),
"purpose": _HANDOFF_PURPOSE,
})
@router.get("/handoff/lookup")
async def empfang_handoff_lookup(
request: Request,
code: str = Query("", description="kurzer Verbindungscode XXXX-XXXX"),
):
"""Empfang-Chat-Huelle: Verbindungscode in shell_token aufloesen.
Kein Cookie/Session noetig — der Code IST das Geheimnis. Einmal-Verbrauch
der Code->Token-Zuordnung. Der shell_token wird erst spaeter durch
/empfang/shell/launch eingeloest.
"""
_shell_cleanup_expired()
_handoff_short_codes_cleanup()
c = _normalize_short_code(code)
if not c:
raise HTTPException(status_code=400, detail="code fehlt")
rec = _handoff_short_codes.get(c)
if not rec:
raise HTTPException(
status_code=404,
detail="Verbindungscode ungueltig oder bereits eingeloest",
)
if time.time() > float(rec.get("expires_at", 0)):
try:
del _handoff_short_codes[c]
except KeyError:
pass
raise HTTPException(status_code=410, detail="Verbindungscode abgelaufen")
shell_token = (rec.get("shell_token") or "").strip()
try:
del _handoff_short_codes[c]
except KeyError:
pass
if not shell_token or shell_token not in _shell_store:
raise HTTPException(
status_code=410,
detail="Verbindungscode war gueltig, aber zugehoerige Session abgelaufen",
)
launch_path = (
f"/empfang/shell/launch?token={shell_token}&target=empfang_chat_shell"
)
_log.info("AZA_EMPFANG_HANDOFF_REDEEMED purpose=%s", _HANDOFF_PURPOSE)
return JSONResponse(content={
"success": True,
"launch_path": launch_path,
"purpose": _HANDOFF_PURPOSE,
})
# =====================================================================
# HTML PAGE
# =====================================================================