glo/routes/calls.py
2026-11-03 00:22:48 +00:00

877 lines
29 KiB
Python

import time
from .common import (
CALLS_BASE,
CALL_HELPER_SHARED_SECRET,
CALL_TOKEN_TTL_SECONDS,
now_iso,
parse_iso,
utc_now,
csrf_valid,
require_user,
require_writable_user,
audit,
safe_json,
jsonify,
request,
get_db,
db_write,
sign_call_helper_token,
normalize_username,
get_room_by_slug,
can_access_room,
get_channel_by_slug,
can_access_channel,
validate_dm_access,
get_dm_other_user,
build_room_helper_call_id,
build_channel_helper_call_id,
get_or_create_group_call_session,
group_call_session_payload,
get_group_call_session_by_id,
upsert_group_call_participant,
heartbeat_group_call_participant,
mark_group_call_participant_left,
list_group_call_participants,
maybe_end_group_call_if_empty,
create_call_session,
call_payload,
get_call_with_dm,
append_call_signal,
list_call_signals,
end_call,
can_access_group_call_session,
can_manage_group_call_session,
group_call_scope_payload,
group_call_permissions_payload,
)
def register_call_routes(app):
@app.route("/api/group-calls/token", methods=["POST"])
def api_group_call_token():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
if not CALL_HELPER_SHARED_SECRET:
return jsonify({"error": "Call helper is not configured."}), 503
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
scope_kind = str(data.get("scope_kind", "")).strip().lower()
helper_call_id = None
scope_id = None
scope_slug = None
room_value = None
if scope_kind == "room":
room_slug = str(data.get("room_slug", "")).strip()
room = get_room_by_slug(room_slug)
if not room:
return jsonify({"error": "Room not found."}), 404
if not can_access_room(room, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
helper_call_id = build_room_helper_call_id(room)
scope_id = room["id"]
scope_slug = room["slug"]
room_value = room["slug"]
elif scope_kind == "channel":
team_slug = str(data.get("team_slug", "")).strip()
channel_slug = str(data.get("channel_slug", "")).strip()
channel = get_channel_by_slug(team_slug, channel_slug)
if not channel:
return jsonify({"error": "Channel not found."}), 404
if not can_access_channel(channel, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
helper_call_id = build_channel_helper_call_id(channel)
scope_id = channel["id"]
scope_slug = f"{channel['team_slug']}/{channel['slug']}"
room_value = scope_slug
else:
return jsonify({"error": "scope_kind must be room or channel."}), 400
session_row = get_or_create_group_call_session(
scope_kind=scope_kind,
scope_id=scope_id,
scope_slug=scope_slug,
helper_call_id=helper_call_id,
created_by_user_id=user["id"],
)
upsert_group_call_participant(session_row["id"], user["id"])
payload = {
"sub": f"user-{user['id']}",
"username": user["username"],
"room": room_value,
"call_id": helper_call_id,
"role": user["role"],
"can_call": True,
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
}
token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
audit(
"group_call_token_issued",
user["id"],
"group_call_session",
str(session_row["id"]),
helper_call_id,
)
return jsonify(
{
"token": token,
"calls_base": CALLS_BASE,
"call_id": helper_call_id,
"session": group_call_session_payload(session_row),
"participants": list_group_call_participants(session_row["id"]),
"permissions": group_call_permissions_payload(session_row, user),
"scope": group_call_scope_payload(session_row, user),
}
)
@app.route("/api/group-calls/invite", methods=["POST"])
def api_group_call_invite():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
if not CALL_HELPER_SHARED_SECRET:
return jsonify({"error": "Call helper is not configured."}), 503
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
session_id_raw = data.get("group_call_session_id")
invite_username = normalize_username(data.get("username"))
try:
session_id = int(session_id_raw)
except (TypeError, ValueError):
return jsonify({"error": "Invalid group_call_session_id."}), 400
if not invite_username:
return jsonify({"error": "username is required."}), 400
session_row = get_group_call_session_by_id(session_id)
if not session_row or session_row["status"] != "active":
return jsonify({"error": "Active group call not found."}), 404
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
is_active_participant = False
with get_db() as db:
is_active_participant = bool(
db.execute(
"""
SELECT 1
FROM group_call_participants
WHERE group_call_session_id = ?
AND user_id = ?
AND left_at IS NULL
""",
(session_row["id"], user["id"]),
).fetchone()
)
if not (is_active_participant or can_manage_group_call_session(session_row, user)):
return jsonify(
{
"error": "Only active participants or scope managers can invite users.",
"code": "forbidden",
}
), 403
with get_db() as db:
target = db.execute(
"""
SELECT
id,
username,
role,
status
FROM users
WHERE username = ?
""",
(invite_username,),
).fetchone()
if not target:
return jsonify({"error": "User not found."}), 404
if int(target["id"]) == int(user["id"]):
return jsonify({"error": "You are already in the call."}), 400
if target["status"] in {"disabled", "blacklisted"}:
return jsonify({"error": "User is not available."}), 400
payload = {
"sub": f"user-{target['id']}",
"username": target["username"],
"room": session_row["scope_slug"],
"call_id": session_row["helper_call_id"],
"role": target["role"],
"can_call": True,
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
}
helper_token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
expires_at = (utc_now() + app.timedelta(minutes=10)).isoformat()
cursor = db_write(
"""
INSERT INTO group_call_invites (
group_call_session_id,
inviter_user_id,
invitee_user_id,
helper_token,
status,
created_at,
expires_at,
responded_at
) VALUES (?, ?, ?, ?, 'pending', ?, ?, NULL)
""",
(
session_row["id"],
user["id"],
target["id"],
helper_token,
now_iso(),
expires_at,
),
)
audit(
"group_call_invite_created",
user["id"],
"group_call_session",
str(session_row["id"]),
f"invitee={target['username']}",
)
return jsonify(
{
"invite": {
"id": cursor.lastrowid,
"invitee_username": target["username"],
"group_call_session_id": session_row["id"],
"status": "pending",
"expires_at": expires_at,
}
}
), 201
@app.route("/api/group-calls/invites")
def api_list_group_call_invites():
user, error = require_user()
if error:
return error
with get_db() as db:
rows = db.execute(
"""
SELECT
i.id,
i.group_call_session_id,
i.status,
i.created_at,
i.expires_at,
i.responded_at,
s.scope_kind,
s.scope_slug,
s.helper_call_id,
s.status AS session_status,
inviter.username AS inviter_username
FROM group_call_invites AS i
JOIN group_call_sessions AS s
ON s.id = i.group_call_session_id
JOIN users AS inviter
ON inviter.id = i.inviter_user_id
WHERE i.invitee_user_id = ?
AND i.status = 'pending'
AND i.expires_at > ?
AND s.status = 'active'
ORDER BY i.id DESC
""",
(user["id"], now_iso()),
).fetchall()
return jsonify(
{
"invites": [
{
"id": row["id"],
"group_call_session_id": row["group_call_session_id"],
"scope_kind": row["scope_kind"],
"scope_slug": row["scope_slug"],
"helper_call_id": row["helper_call_id"],
"inviter_username": row["inviter_username"],
"status": row["status"],
"created_at": row["created_at"],
"expires_at": row["expires_at"],
}
for row in rows
]
}
)
@app.route("/api/group-calls/invites/<int:invite_id>/accept", methods=["POST"])
def api_accept_group_call_invite(invite_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
with get_db() as db:
row = db.execute(
"""
SELECT
i.id,
i.group_call_session_id,
i.invitee_user_id,
i.helper_token,
i.status,
i.expires_at,
s.helper_call_id
FROM group_call_invites AS i
JOIN group_call_sessions AS s
ON s.id = i.group_call_session_id
WHERE i.id = ?
AND i.invitee_user_id = ?
LIMIT 1
""",
(invite_id, user["id"]),
).fetchone()
if not row:
return jsonify({"error": "Invite not found."}), 404
if row["status"] != "pending" or parse_iso(row["expires_at"]) <= utc_now():
return jsonify({"error": "Invite expired or unavailable."}), 400
db_write(
"""
UPDATE group_call_invites
SET
status = 'accepted',
responded_at = ?
WHERE id = ?
""",
(now_iso(), invite_id),
)
upsert_group_call_participant(row["group_call_session_id"], user["id"])
audit(
"group_call_invite_accepted",
user["id"],
"group_call_invite",
str(invite_id),
row["helper_call_id"],
)
session_row = get_group_call_session_by_id(row["group_call_session_id"])
return jsonify(
{
"token": row["helper_token"],
"calls_base": CALLS_BASE,
"call_id": row["helper_call_id"],
"session": group_call_session_payload(session_row),
"participants": list_group_call_participants(row["group_call_session_id"]),
"permissions": group_call_permissions_payload(session_row, user),
"scope": group_call_scope_payload(session_row, user),
}
)
@app.route("/api/group-calls/invites/<int:invite_id>/dismiss", methods=["POST"])
def api_dismiss_group_call_invite(invite_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_user()
if error:
return error
with get_db() as db:
row = db.execute(
"""
SELECT
id,
group_call_session_id
FROM group_call_invites
WHERE id = ?
AND invitee_user_id = ?
LIMIT 1
""",
(invite_id, user["id"]),
).fetchone()
if not row:
return jsonify({"error": "Invite not found."}), 404
db_write(
"""
UPDATE group_call_invites
SET
status = 'dismissed',
responded_at = ?
WHERE id = ?
AND invitee_user_id = ?
AND status = 'pending'
""",
(now_iso(), invite_id, user["id"]),
)
audit(
"group_call_invite_dismissed",
user["id"],
"group_call_invite",
str(invite_id),
)
return jsonify(
{
"status": "dismissed",
"group_call_session_id": row["group_call_session_id"],
}
)
@app.route("/api/group-calls/<int:session_id>")
def api_get_group_call_session(session_id: int):
user, error = require_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
return jsonify(
{
"session": group_call_session_payload(session_row),
"scope": group_call_scope_payload(session_row, user),
"participants": list_group_call_participants(session_id),
"permissions": group_call_permissions_payload(session_row, user),
}
)
@app.route("/api/group-calls/<int:session_id>/participants")
def api_get_group_call_participants(session_id: int):
user, error = require_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
return jsonify(
{
"session_id": session_id,
"participants": list_group_call_participants(session_id),
"permissions": group_call_permissions_payload(session_row, user),
"scope": group_call_scope_payload(session_row, user),
}
)
@app.route("/api/group-calls/<int:session_id>/join", methods=["POST"])
def api_join_group_call_session(session_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if session_row["status"] != "active":
return jsonify({"error": "Group call session is not active."}), 400
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
upsert_group_call_participant(session_id, user["id"])
audit(
"group_call_session_joined",
user["id"],
"group_call_session",
str(session_id),
session_row["helper_call_id"],
)
return jsonify(
{
"status": "joined",
"session": group_call_session_payload(session_row),
"participants": list_group_call_participants(session_id),
"permissions": group_call_permissions_payload(session_row, user),
"scope": group_call_scope_payload(session_row, user),
}
)
@app.route("/api/group-calls/<int:session_id>/heartbeat", methods=["POST"])
def api_heartbeat_group_call_session(session_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if session_row["status"] != "active":
return jsonify({"error": "Group call session is not active."}), 400
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
heartbeat_group_call_participant(session_id, user["id"])
return jsonify(
{
"status": "ok",
"session": group_call_session_payload(session_row),
"participants": list_group_call_participants(session_id),
}
)
@app.route("/api/group-calls/<int:session_id>/leave", methods=["POST"])
def api_leave_group_call_session(session_id: int):
"""
keep csrf behavior for normal requests, but allow same-origin unload-style cleanup
where the frontend keepalive/beacon path may not be able to send the custom header.
"""
user, error = require_user()
if error:
return error
if not csrf_valid():
origin = request.headers.get("Origin", "")
sec_fetch_site = request.headers.get("Sec-Fetch-Site", "")
same_origin_hint = (not origin) or origin.rstrip("/") == request.host_url.rstrip("/") or sec_fetch_site == "same-origin"
if not same_origin_hint:
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
mark_group_call_participant_left(session_id, user["id"])
maybe_end_group_call_if_empty(session_id)
refreshed = get_group_call_session_by_id(session_id)
audit(
"group_call_session_left",
user["id"],
"group_call_session",
str(session_id),
session_row["helper_call_id"],
)
return jsonify(
{
"status": "left",
"session": group_call_session_payload(refreshed) if refreshed else None,
"participants": list_group_call_participants(session_id),
}
)
@app.route("/api/group-calls/<int:session_id>/end", methods=["POST"])
def api_end_group_call_session(session_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if not can_manage_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
db_write(
"""
UPDATE group_call_sessions
SET
status = 'ended',
ended_at = ?
WHERE id = ?
""",
(now_iso(), session_id),
)
db_write(
"""
UPDATE group_call_participants
SET
updated_at = ?,
left_at = COALESCE(left_at, ?)
WHERE group_call_session_id = ?
AND left_at IS NULL
""",
(now_iso(), now_iso(), session_id),
)
db_write(
"""
UPDATE group_call_invites
SET
status = 'dismissed',
responded_at = COALESCE(responded_at, ?)
WHERE group_call_session_id = ?
AND status = 'pending'
""",
(now_iso(), session_id),
)
refreshed = get_group_call_session_by_id(session_id)
audit(
"group_call_session_ended",
user["id"],
"group_call_session",
str(session_id),
session_row["helper_call_id"],
)
return jsonify(
{
"status": "ended",
"session": group_call_session_payload(refreshed) if refreshed else None,
"participants": [],
}
)
@app.route("/api/call-token", methods=["POST"])
def api_call_token():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
if not CALL_HELPER_SHARED_SECRET:
return jsonify({"error": "Call helper is not configured."}), 503
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
room_slug = str(data.get("room", "")).strip()
dm_id_raw = data.get("dm_id")
call_id = None
room_value = None
if dm_id_raw is not None:
try:
dm_id = int(dm_id_raw)
except (TypeError, ValueError):
return jsonify({"error": "Invalid dm_id."}), 400
if not validate_dm_access(dm_id, user):
return jsonify({"error": "DM not found."}), 404
peer = get_dm_other_user(dm_id, user["id"])
if not peer:
return jsonify({"error": "Direct-message peer not found."}), 404
call_id = f"dm-{dm_id}"
room_value = call_id
elif room_slug:
room = get_room_by_slug(room_slug)
if not room:
return jsonify({"error": "Room not found."}), 404
if not can_access_room(room, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
call_id = f"room-{room['slug']}"
room_value = room["slug"]
else:
return jsonify({"error": "room or dm_id is required."}), 400
payload = {
"sub": f"user-{user['id']}",
"username": user["username"],
"room": room_value,
"call_id": call_id,
"role": user["role"],
"can_call": True,
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
}
token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
audit(
"call_helper_token_issued",
user["id"],
"call",
call_id,
f"room={room_value}",
)
return jsonify(
{
"token": token,
"calls_base": CALLS_BASE,
"call_id": call_id,
}
)
@app.route("/api/dms/<int:dm_id>/calls", methods=["POST"])
def api_start_dm_call(dm_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
if not validate_dm_access(dm_id, user):
return jsonify({"error": "DM not found."}), 404
call_id = create_call_session(dm_id, user["id"])
audit("call_started", user["id"], "call", str(call_id), str(dm_id))
return jsonify({"call": call_payload(call_id)}), 201
@app.route("/api/calls/<int:call_id>")
def api_get_call(call_id: int):
user, error = require_user()
if error:
return error
call = get_call_with_dm(call_id)
if not call:
return jsonify({"error": "Call not found."}), 404
if not validate_dm_access(call["direct_conversation_id"], user):
return jsonify({"error": "Call not found."}), 404
return jsonify({"call": call_payload(call_id)})
@app.route("/api/calls/<int:call_id>/signal", methods=["POST"])
def api_post_call_signal(call_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
call = get_call_with_dm(call_id)
if not call:
return jsonify({"error": "Call not found."}), 404
if not validate_dm_access(call["direct_conversation_id"], user):
return jsonify({"error": "Call not found."}), 404
if call["status"] != "active":
return jsonify({"error": "Call is not active."}), 400
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
signal_type = str(data.get("signal_type", "")).strip()[:40]
payload = data.get("payload", {})
if not signal_type:
return jsonify({"error": "signal_type is required."}), 400
if not isinstance(payload, dict):
return jsonify({"error": "payload must be an object."}), 400
signal_id = append_call_signal(call_id, user["id"], signal_type, payload)
return jsonify({"status": "ok", "signal_id": signal_id}), 201
@app.route("/api/calls/<int:call_id>/signals")
def api_get_call_signals(call_id: int):
user, error = require_user()
if error:
return error
call = get_call_with_dm(call_id)
if not call:
return jsonify({"error": "Call not found."}), 404
if not validate_dm_access(call["direct_conversation_id"], user):
return jsonify({"error": "Call not found."}), 404
after_raw = str(request.args.get("after_id", "")).strip()
after_id = 0
if after_raw:
try:
after_id = max(0, int(after_raw))
except ValueError:
return jsonify({"error": "after_id must be a non-negative integer."}), 400
signals = list_call_signals(call_id, after_id)
latest_id = signals[-1]["id"] if signals else after_id
return jsonify({"signals": signals, "latest_id": latest_id})
@app.route("/api/calls/<int:call_id>/end", methods=["POST"])
def api_end_call(call_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
call = get_call_with_dm(call_id)
if not call:
return jsonify({"error": "Call not found."}), 404
if not validate_dm_access(call["direct_conversation_id"], user):
return jsonify({"error": "Call not found."}), 404
end_call(call_id)
audit("call_ended", user["id"], "call", str(call_id))
return jsonify({"status": "ended", "call": call_payload(call_id)})