diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index 42ffc59..1f7b809 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop from app.internal.recorrelation_sweep import recorrelation_loop from app.config import settings from app.internal.auth import require_firebase_token, require_service_or_firebase_token -from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places +from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links from app.internal import firestore as fstore @@ -72,6 +72,7 @@ app.include_router(trips.router, dependencies=[Depends(require_service_or_fi app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(upload.router) # auth is per-node, handled inline app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) +app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key) @app.get("/health") diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index 8eed24c..acbbf89 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -148,6 +148,8 @@ class TripCreate(BaseModel): end_date: str # YYYY-MM-DD available_tags: List[str] = [] # tag labels configured for this trip overlap_tags: List[str] = [] # subset of available_tags that allow time overlap + visibility: str = "public" # "public" | "private" + invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips class TripEventCreate(BaseModel): diff --git a/drb-c2-core/app/routers/links.py b/drb-c2-core/app/routers/links.py new file mode 100644 index 0000000..7d9e466 --- /dev/null +++ b/drb-c2-core/app/routers/links.py @@ -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} diff --git a/drb-c2-core/app/routers/trips.py b/drb-c2-core/app/routers/trips.py index 2410c2e..59787c5 100644 --- a/drb-c2-core/app/routers/trips.py +++ b/drb-c2-core/app/routers/trips.py @@ -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, diff --git a/drb-frontend/app/trips/[id]/page.tsx b/drb-frontend/app/trips/[id]/page.tsx index c6f7917..47d9d76 100644 --- a/drb-frontend/app/trips/[id]/page.tsx +++ b/drb-frontend/app/trips/[id]/page.tsx @@ -908,6 +908,7 @@ export default function TripDetailPage() { const [editEvent, setEditEvent] = useState(null); const [driveTimes, setDriveTimes] = useState>({}); const [tagInput, setTagInput] = useState(""); + const [inviteInput, setInviteInput] = useState(""); const load = useCallback(async () => { try { @@ -1024,6 +1025,28 @@ export default function TripDetailPage() { setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev); } + async function handleToggleVisibility() { + if (!trip) return; + const next = trip.visibility === "private" ? "public" : "private"; + await c2api.setTripVisibility(trip.trip_id, next); + setTrip((prev) => prev ? { ...prev, visibility: next } : prev); + } + + async function handleInvite() { + const discordId = inviteInput.trim(); + if (!trip || !discordId) return; + if ((trip.invited_discord_ids ?? []).includes(discordId)) { setInviteInput(""); return; } + await c2api.inviteToTrip(trip.trip_id, discordId); + setTrip((prev) => prev ? { ...prev, invited_discord_ids: [...(prev.invited_discord_ids ?? []), discordId] } : prev); + setInviteInput(""); + } + + async function handleRevokeInvite(discordId: string) { + if (!trip) return; + await c2api.revokeInvite(trip.trip_id, discordId); + setTrip((prev) => prev ? { ...prev, invited_discord_ids: (prev.invited_discord_ids ?? []).filter((id) => id !== discordId) } : prev); + } + async function handleToggleOverlap(tag: string) { if (!trip) return; const current = trip.overlap_tags ?? []; @@ -1074,7 +1097,13 @@ export default function TripDetailPage() { -
+
+ {/* Visibility badge */} + {trip.visibility === "private" && ( + + 🔒 private + + )} {isAdmin && ( <> + + + ))} +
+ setInviteInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())} + placeholder="Discord user ID…" + className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36" + /> + +
+
+
+ )} {/* Two-column layout */} diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 808d894..8ae4312 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -144,6 +144,18 @@ export const c2api = { request(`/trips/${id}`, { method: "DELETE" }), updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) => request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }), + setTripVisibility: (id: string, visibility: "public" | "private") => + request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }), + inviteToTrip: (id: string, discord_user_id: string) => + request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }), + revokeInvite: (id: string, discord_user_id: string) => + request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }), + generateLinkCode: () => + request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }), + getLinkStatus: () => + request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"), + unlinkDiscord: () => + request("/auth/link", { method: "DELETE" }), createTripEvent: (tripId: string, body: object) => request(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }), updateTripEvent: (tripId: string, eventId: string, body: object) => diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 05241d7..ba0ceef 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -135,6 +135,8 @@ export interface TripRecord { attendees: Record; available_tags: string[]; overlap_tags: string[]; + visibility: "public" | "private"; + invited_discord_ids: string[]; created_at: string; events?: TripEvent[]; } diff --git a/drb-server-discord-bot/app/commands/trips.py b/drb-server-discord-bot/app/commands/trips.py index f7ff076..0797eb3 100644 --- a/drb-server-discord-bot/app/commands/trips.py +++ b/drb-server-discord-bot/app/commands/trips.py @@ -62,6 +62,16 @@ def _date_range(start_iso: str, end_iso: str): # Cog # --------------------------------------------------------------------------- +def _user_can_see_trip(trip: dict, discord_user_id: str) -> bool: + if trip.get("visibility", "public") == "public": + return True + if discord_user_id in trip.get("attendees", {}): + return True + if discord_user_id in trip.get("invited_discord_ids", []): + return True + return False + + class TripCommands(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot @@ -79,10 +89,11 @@ class TripCommands(commands.Cog): self, interaction: discord.Interaction, current: str ) -> list[app_commands.Choice[str]]: trips = await c2.get_trips() + user_id = str(interaction.user.id) return [ app_commands.Choice(name=t["name"], value=t["trip_id"]) for t in trips - if current.lower() in t["name"].lower() + if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id) ][:25] async def event_autocomplete( @@ -172,6 +183,9 @@ class TripCommands(commands.Cog): today = date.today().strftime("%Y-%m-%d") trips.sort(key=lambda t: t.get("start_date", "")) + user_id = str(interaction.user.id) + trips = [t for t in trips if _user_can_see_trip(t, user_id)] + embed = discord.Embed(title="Trips", color=0x2b2d31) for t in trips[:10]: upcoming = t.get("start_date", "") >= today @@ -204,6 +218,9 @@ class TripCommands(commands.Cog): if not data: await interaction.followup.send("Trip not found.") return + if not _user_can_see_trip(data, str(interaction.user.id)): + await interaction.followup.send("This trip is private.", ephemeral=True) + return attendee_names = list(data.get("attendees", {}).values()) desc_lines = [ @@ -302,9 +319,11 @@ class TripCommands(commands.Cog): @app_commands.autocomplete(trip=trip_autocomplete) async def trip_join(self, interaction: discord.Interaction, trip: str): await interaction.response.defer(ephemeral=True) - ok = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name) - if ok: + result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name) + if result is True: await interaction.followup.send("You're on the trip!") + elif result == "private": + await interaction.followup.send("This trip is private — you need an invite to join.") else: await interaction.followup.send("Failed to join trip.") @@ -433,5 +452,64 @@ class TripCommands(commands.Cog): await interaction.followup.send("Failed to leave event.") + # ------------------------------------------------------------------ + # /trip invite + # ------------------------------------------------------------------ + + @trip_group.command(name="invite", description="Invite a Discord user to a private trip.") + @app_commands.describe(trip="The trip.", user="The user to invite.") + @app_commands.autocomplete(trip=trip_autocomplete) + async def trip_invite(self, interaction: discord.Interaction, trip: str, user: discord.Member): + await interaction.response.defer(ephemeral=True) + ok = await c2.invite_to_trip(trip, str(user.id)) + if ok: + await interaction.followup.send(f"Invited {user.display_name} to the trip.") + else: + await interaction.followup.send("Failed to send invite.") + + # ------------------------------------------------------------------ + # /trip privacy + # ------------------------------------------------------------------ + + @trip_group.command(name="privacy", description="Set a trip to public or private.") + @app_commands.describe(trip="The trip.", visibility="public or private") + @app_commands.autocomplete(trip=trip_autocomplete) + @app_commands.choices(visibility=[ + app_commands.Choice(name="Public — anyone can see and join", value="public"), + app_commands.Choice(name="Private — invite only", value="private"), + ]) + async def trip_privacy(self, interaction: discord.Interaction, trip: str, visibility: str): + await interaction.response.defer(ephemeral=True) + ok = await c2.set_trip_visibility(trip, visibility) + if ok: + await interaction.followup.send(f"Trip is now **{visibility}**.") + else: + await interaction.followup.send("Failed to update trip privacy.") + + # ------------------------------------------------------------------ + # /link + # ------------------------------------------------------------------ + + @app_commands.command(name="link", description="Link your Discord account to your DRB web account.") + @app_commands.describe(code="The 6-character code from the web app (Settings → Link Discord).") + async def link_account(self, interaction: discord.Interaction, code: str): + await interaction.response.defer(ephemeral=True) + result = await c2.link_discord_account( + code.upper().strip(), + str(interaction.user.id), + interaction.user.display_name, + ) + if "error" in result: + msgs = { + "invalid_code": "Invalid code. Generate a new one from the web app.", + "expired": "Code has expired. Generate a new one from the web app.", + "already_linked": "This Discord account is already linked to a different web account.", + "failed": "Something went wrong. Try again.", + } + await interaction.followup.send(msgs.get(result["error"], "Failed to link account.")) + else: + await interaction.followup.send("Your Discord account is now linked to your DRB web account.") + + async def setup(bot: commands.Bot): await bot.add_cog(TripCommands(bot)) diff --git a/drb-server-discord-bot/app/internal/c2_client.py b/drb-server-discord-bot/app/internal/c2_client.py index 1e8eea0..f63ed38 100644 --- a/drb-server-discord-bot/app/internal/c2_client.py +++ b/drb-server-discord-bot/app/internal/c2_client.py @@ -112,7 +112,55 @@ class C2Client: logger.error(f"C2 delete_trip failed: {e}") return False - async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool: + async def invite_to_trip(self, trip_id: str, discord_user_id: str) -> bool: + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + f"{self.base}/trips/{trip_id}/invite/{discord_user_id}", + headers=self._headers(), + ) + r.raise_for_status() + return True + except Exception as e: + logger.error(f"C2 invite_to_trip failed: {e}") + return False + + async def set_trip_visibility(self, trip_id: str, visibility: str) -> bool: + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.put( + f"{self.base}/trips/{trip_id}/visibility", + json={"visibility": visibility}, + headers=self._headers(), + ) + r.raise_for_status() + return True + except Exception as e: + logger.error(f"C2 set_trip_visibility failed: {e}") + return False + + async def link_discord_account(self, code: str, discord_user_id: str, discord_username: str) -> dict: + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + f"{self.base}/auth/link", + json={"code": code, "discord_user_id": discord_user_id, "discord_username": discord_username}, + headers=self._headers(), + ) + if r.status_code == 404: + return {"error": "invalid_code"} + if r.status_code == 410: + return {"error": "expired"} + if r.status_code == 409: + return {"error": "already_linked"} + r.raise_for_status() + return r.json() + except Exception as e: + logger.error(f"C2 link_discord_account failed: {e}") + return {"error": "failed"} + + async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool | str: + """Returns True on success, 'private' on 403, False on other errors.""" try: async with httpx.AsyncClient(timeout=10) as client: r = await client.post( @@ -120,6 +168,8 @@ class C2Client: json={"discord_user_id": user_id, "discord_username": username}, headers=self._headers(), ) + if r.status_code == 403: + return "private" r.raise_for_status() return True except Exception as e: