add trips permissions
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_firebase_token, require_service_key
|
||||
from app.internal.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
_CODE_TTL_MINUTES = 15
|
||||
|
||||
|
||||
def _gen_code() -> str:
|
||||
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web: generate a short-lived linking code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/link/generate")
|
||||
async def generate_link_code(decoded: dict = Depends(require_firebase_token)):
|
||||
"""Authenticated Firebase user generates a code to paste into Discord /link."""
|
||||
firebase_uid = decoded["uid"]
|
||||
|
||||
# Check if already linked
|
||||
existing = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||
if existing and existing.get("discord_user_id"):
|
||||
return {
|
||||
"already_linked": True,
|
||||
"discord_user_id": existing["discord_user_id"],
|
||||
}
|
||||
|
||||
code = _gen_code()
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat()
|
||||
await fstore.doc_set("link_codes", code, {
|
||||
"firebase_uid": firebase_uid,
|
||||
"expires_at": expires_at,
|
||||
}, merge=False)
|
||||
|
||||
return {"code": code, "expires_minutes": _CODE_TTL_MINUTES}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discord bot: resolve a code and store the link
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LinkResolveBody(BaseModel):
|
||||
code: str
|
||||
discord_user_id: str
|
||||
discord_username: str = ""
|
||||
|
||||
|
||||
@router.post("/link")
|
||||
async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)):
|
||||
"""Discord bot resolves a linking code and permanently links the accounts."""
|
||||
doc = await fstore.doc_get("link_codes", body.code.upper().strip())
|
||||
if not doc:
|
||||
raise HTTPException(404, "Invalid or expired code.")
|
||||
|
||||
expires_at = datetime.fromisoformat(doc["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires_at:
|
||||
await fstore.doc_delete("link_codes", body.code)
|
||||
raise HTTPException(410, "Code has expired. Generate a new one from the web app.")
|
||||
|
||||
firebase_uid = doc["firebase_uid"]
|
||||
|
||||
# Check if this Discord account is already linked to a different Firebase UID
|
||||
existing = await fstore.doc_get("discord_links", body.discord_user_id)
|
||||
if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid:
|
||||
raise HTTPException(409, "This Discord account is already linked to a different account.")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Store both directions
|
||||
await fstore.doc_set("discord_links", body.discord_user_id, {
|
||||
"firebase_uid": firebase_uid,
|
||||
"discord_username": body.discord_username,
|
||||
"linked_at": now,
|
||||
}, merge=False)
|
||||
|
||||
await fstore.doc_set("firebase_discord_links", firebase_uid, {
|
||||
"discord_user_id": body.discord_user_id,
|
||||
"discord_username": body.discord_username,
|
||||
"linked_at": now,
|
||||
}, merge=False)
|
||||
|
||||
# Clean up the code
|
||||
await fstore.doc_delete("link_codes", body.code)
|
||||
|
||||
logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}")
|
||||
return {"ok": True, "firebase_uid": firebase_uid}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web: check current link status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/link/status")
|
||||
async def link_status(decoded: dict = Depends(require_firebase_token)):
|
||||
firebase_uid = decoded["uid"]
|
||||
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||
if link and link.get("discord_user_id"):
|
||||
return {
|
||||
"linked": True,
|
||||
"discord_user_id": link["discord_user_id"],
|
||||
"discord_username": link.get("discord_username", ""),
|
||||
"linked_at": link.get("linked_at"),
|
||||
}
|
||||
return {"linked": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web: unlink
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/link")
|
||||
async def unlink(decoded: dict = Depends(require_firebase_token)):
|
||||
firebase_uid = decoded["uid"]
|
||||
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||
if not link or not link.get("discord_user_id"):
|
||||
raise HTTPException(404, "No linked Discord account.")
|
||||
discord_user_id = link["discord_user_id"]
|
||||
await fstore.doc_delete("discord_links", discord_user_id)
|
||||
await fstore.doc_delete("firebase_discord_links", firebase_uid)
|
||||
return {"ok": True}
|
||||
@@ -18,6 +18,32 @@ from app.internal.auth import (
|
||||
|
||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Access control helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
|
||||
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||
return (link or {}).get("discord_user_id")
|
||||
|
||||
|
||||
def _trip_is_accessible(trip: dict, *, is_service: bool, firebase_uid: Optional[str], discord_id: Optional[str]) -> bool:
|
||||
"""Return True if the caller may read this trip."""
|
||||
if is_service:
|
||||
return True # bot sees all; it filters client-side per-user
|
||||
if trip.get("visibility", "public") == "public":
|
||||
return True
|
||||
if not firebase_uid:
|
||||
return False
|
||||
# attendees keyed by discord_id — check linked discord_id
|
||||
if discord_id:
|
||||
if discord_id in trip.get("attendees", {}):
|
||||
return True
|
||||
if discord_id in trip.get("invited_discord_ids", []):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI assistant — tool definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -189,8 +215,12 @@ class ChatRequest(BaseModel):
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_trips():
|
||||
return await fstore.collection_list("trips")
|
||||
async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)):
|
||||
trips = await fstore.collection_list("trips")
|
||||
is_service = bool(decoded.get("service"))
|
||||
firebase_uid = decoded.get("uid")
|
||||
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
|
||||
return [t for t in trips if _trip_is_accessible(t, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id)]
|
||||
|
||||
|
||||
@router.post("")
|
||||
@@ -209,6 +239,8 @@ async def create_trip(body: TripCreate):
|
||||
"attendees": {}, # {discord_user_id: discord_username}
|
||||
"available_tags": body.available_tags,
|
||||
"overlap_tags": body.overlap_tags,
|
||||
"visibility": body.visibility if body.visibility in ("public", "private") else "public",
|
||||
"invited_discord_ids": body.invited_discord_ids,
|
||||
"created_at": now,
|
||||
}
|
||||
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
||||
@@ -216,12 +248,17 @@ async def create_trip(body: TripCreate):
|
||||
|
||||
|
||||
@router.get("/{trip_id}")
|
||||
async def get_trip(trip_id: str):
|
||||
async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
is_service = bool(decoded.get("service"))
|
||||
firebase_uid = decoded.get("uid")
|
||||
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
|
||||
if not _trip_is_accessible(trip, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id):
|
||||
raise HTTPException(403, "This trip is private.")
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
events.sort(key=lambda e: (e["date"], e.get("time") or ""))
|
||||
events.sort(key=lambda e: (e["date"], e.get("start_time") or ""))
|
||||
return {**trip, "events": events}
|
||||
|
||||
|
||||
@@ -259,12 +296,49 @@ async def join_trip(
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
if trip.get("visibility", "public") == "private":
|
||||
invited = trip.get("invited_discord_ids", [])
|
||||
attendees_existing = trip.get("attendees", {})
|
||||
if body.discord_user_id not in invited and body.discord_user_id not in attendees_existing:
|
||||
raise HTTPException(403, "This trip is private. You need an invite to join.")
|
||||
attendees = trip.get("attendees", {})
|
||||
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
||||
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
||||
return {"ok": True, "attendees": attendees}
|
||||
|
||||
|
||||
@router.put("/{trip_id}/visibility")
|
||||
async def set_visibility(trip_id: str, body: dict, _: dict = Depends(require_service_key_or_admin)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
visibility = body.get("visibility", "public")
|
||||
if visibility not in ("public", "private"):
|
||||
raise HTTPException(400, "visibility must be 'public' or 'private'.")
|
||||
await fstore.doc_update("trips", trip_id, {"visibility": visibility})
|
||||
return {"visibility": visibility}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/invite/{discord_user_id}")
|
||||
async def invite_user(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
invited = list(set(trip.get("invited_discord_ids", []) + [discord_user_id]))
|
||||
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
|
||||
return {"ok": True, "invited_discord_ids": invited}
|
||||
|
||||
|
||||
@router.delete("/{trip_id}/invite/{discord_user_id}")
|
||||
async def revoke_invite(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
invited = [u for u in trip.get("invited_discord_ids", []) if u != discord_user_id]
|
||||
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
|
||||
return {"ok": True, "invited_discord_ids": invited}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/leave")
|
||||
async def leave_trip(
|
||||
trip_id: str,
|
||||
|
||||
Reference in New Issue
Block a user