From e6e81c59e70b02cf4d9d22c0ad6ab5dc656c59d8 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 04:46:17 +0800 Subject: [PATCH] Phase 2: Shared calendars backend core + QA fixes Router: invite/accept/reject flow, membership CRUD, event locking (timed + permanent), sync endpoint, local color override. Services: permission hierarchy, atomic lock acquisition, disconnect cascade. Events: shared calendar scoping, permission/lock enforcement, updated_by tracking. Admin: sharing-stats endpoint. nginx: rate limits for invite + sync. QA fixes: C-01 (read-only invite gate), C-02 (updated_by in this_and_future), W-01 (pre-commit response build), W-02 (owned calendar short-circuit), W-03 (sync calendar_ids cap), W-04 (N+1 owner name batch fetch). Co-Authored-By: Claude Opus 4.6 --- backend/app/main.py | 5 +- backend/app/routers/admin.py | 52 ++ backend/app/routers/connections.py | 4 + backend/app/routers/events.py | 57 +- backend/app/routers/shared_calendars.py | 849 +++++++++++++++++++++++ backend/app/services/calendar_sharing.py | 205 ++++++ frontend/nginx.conf | 16 + 7 files changed, 1178 insertions(+), 10 deletions(-) create mode 100644 backend/app/routers/shared_calendars.py create mode 100644 backend/app/services/calendar_sharing.py diff --git a/backend/app/main.py b/backend/app/main.py index 768e19c..ae8f094 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates -from app.routers import totp, admin, notifications as notifications_router, connections as connections_router +from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -20,6 +20,8 @@ from app.models import audit_log as _audit_log_model # noqa: F401 from app.models import notification as _notification_model # noqa: F401 from app.models import connection_request as _connection_request_model # noqa: F401 from app.models import user_connection as _user_connection_model # noqa: F401 +from app.models import calendar_member as _calendar_member_model # noqa: F401 +from app.models import event_lock as _event_lock_model # noqa: F401 # --------------------------------------------------------------------------- @@ -134,6 +136,7 @@ app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"]) +app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"]) @app.get("/") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 56ead05..d1357f5 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -22,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.audit_log import AuditLog +from app.models.calendar import Calendar +from app.models.calendar_member import CalendarMember from app.models.backup_code import BackupCode from app.models.session import UserSession from app.models.settings import Settings @@ -618,6 +620,56 @@ async def list_user_sessions( } +# --------------------------------------------------------------------------- +# GET /users/{user_id}/sharing-stats +# --------------------------------------------------------------------------- + +@router.get("/users/{user_id}/sharing-stats") +async def get_user_sharing_stats( + user_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Return sharing statistics for a user.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="User not found") + + # Calendars owned that are shared + shared_owned = await db.scalar( + sa.select(sa.func.count()) + .select_from(Calendar) + .where(Calendar.user_id == user_id, Calendar.is_shared == True) + ) or 0 + + # Calendars the user is a member of (accepted) + member_of = await db.scalar( + sa.select(sa.func.count()) + .select_from(CalendarMember) + .where(CalendarMember.user_id == user_id, CalendarMember.status == "accepted") + ) or 0 + + # Pending invites sent by this user + pending_sent = await db.scalar( + sa.select(sa.func.count()) + .select_from(CalendarMember) + .where(CalendarMember.invited_by == user_id, CalendarMember.status == "pending") + ) or 0 + + # Pending invites received by this user + pending_received = await db.scalar( + sa.select(sa.func.count()) + .select_from(CalendarMember) + .where(CalendarMember.user_id == user_id, CalendarMember.status == "pending") + ) or 0 + + return { + "shared_calendars_owned": shared_owned, + "calendars_member_of": member_of, + "pending_invites_sent": pending_sent, + "pending_invites_received": pending_received, + } + # --------------------------------------------------------------------------- # GET /config # --------------------------------------------------------------------------- diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index 26b72bd..e3ded30 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -49,6 +49,7 @@ from app.services.connection import ( resolve_shared_profile, send_connection_ntfy, ) +from app.services.calendar_sharing import cascade_on_disconnect from app.services.notification import create_notification router = APIRouter() @@ -823,6 +824,9 @@ async def remove_connection( if reverse_conn: await db.delete(reverse_conn) + # Cascade: remove calendar memberships and event locks between these users + await cascade_on_disconnect(db, current_user.id, counterpart_id) + await log_audit_event( db, action="connection.removed", diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index c0a3c2e..df83bb0 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -18,7 +18,9 @@ from app.schemas.calendar_event import ( ) from app.routers.auth import get_current_user from app.models.user import User +from app.models.calendar_member import CalendarMember from app.services.recurrence import generate_occurrences +from app.services.calendar_sharing import check_lock_for_edit, require_permission router = APIRouter() @@ -142,13 +144,18 @@ async def get_events( recurrence_rule IS NOT NULL) are excluded — their materialised children are what get displayed on the calendar. """ - # Scope events through calendar ownership + # Scope events through calendar ownership + shared memberships user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + shared_calendar_ids = select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + all_calendar_ids = user_calendar_ids.union(shared_calendar_ids) query = ( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.calendar_id.in_(user_calendar_ids)) + .where(CalendarEvent.calendar_id.in_(all_calendar_ids)) ) # Exclude parent template rows — they are not directly rendered @@ -219,8 +226,13 @@ async def create_event( if not data.get("calendar_id"): data["calendar_id"] = await _get_default_calendar_id(db, current_user.id) else: - # SEC-04: verify the target calendar belongs to the requesting user - await _verify_calendar_ownership(db, data["calendar_id"], current_user.id) + # SEC-04: verify ownership OR shared calendar permission + cal_ownership_result = await db.execute( + select(Calendar).where(Calendar.id == data["calendar_id"], Calendar.user_id == current_user.id) + ) + if not cal_ownership_result.scalar_one_or_none(): + # Not owned — check shared calendar permission + await require_permission(db, data["calendar_id"], current_user.id, "create_modify") # Serialize RecurrenceRule object to JSON string for DB storage # Exclude None values so defaults in recurrence service work correctly @@ -229,7 +241,7 @@ async def create_event( if rule_json: # Parent template: is_recurring=True, no parent_event_id - parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True) + parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True, updated_by=current_user.id) db.add(parent) await db.flush() # assign parent.id before generating children @@ -258,7 +270,7 @@ async def create_event( return result.scalar_one() else: - new_event = CalendarEvent(**data, recurrence_rule=None) + new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id) db.add(new_event) await db.commit() @@ -277,13 +289,18 @@ async def get_event( current_user: User = Depends(get_current_user), ): user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + shared_calendar_ids = select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + all_calendar_ids = user_calendar_ids.union(shared_calendar_ids) result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) .where( CalendarEvent.id == event_id, - CalendarEvent.calendar_id.in_(user_calendar_ids), + CalendarEvent.calendar_id.in_(all_calendar_ids), ) ) event = result.scalar_one_or_none() @@ -302,13 +319,18 @@ async def update_event( current_user: User = Depends(get_current_user), ): user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + shared_calendar_ids = select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + all_calendar_ids = user_calendar_ids.union(shared_calendar_ids) result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) .where( CalendarEvent.id == event_id, - CalendarEvent.calendar_id.in_(user_calendar_ids), + CalendarEvent.calendar_id.in_(all_calendar_ids), ) ) event = result.scalar_one_or_none() @@ -316,6 +338,10 @@ async def update_event( if not event: raise HTTPException(status_code=404, detail="Calendar event not found") + # Shared calendar: require create_modify+ and check lock + await require_permission(db, event.calendar_id, current_user.id, "create_modify") + await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id) + update_data = event_update.model_dump(exclude_unset=True) # Extract scope before applying fields to the model @@ -342,6 +368,7 @@ async def update_event( # Detach from parent so it's an independent event going forward event.parent_event_id = None event.is_recurring = False + event.updated_by = current_user.id await db.commit() elif scope == "this_and_future": @@ -371,6 +398,7 @@ async def update_event( event.parent_event_id = None event.is_recurring = True event.original_start = None + event.updated_by = current_user.id # Inherit parent's recurrence_rule if none was provided in update if not event.recurrence_rule and parent_rule: @@ -386,6 +414,7 @@ async def update_event( # This IS a parent — update it and regenerate all children for key, value in update_data.items(): setattr(event, key, value) + event.updated_by = current_user.id # Delete all existing children and regenerate if event.recurrence_rule: @@ -405,6 +434,7 @@ async def update_event( # No scope — plain update (non-recurring events or full-series metadata) for key, value in update_data.items(): setattr(event, key, value) + event.updated_by = current_user.id await db.commit() result = await db.execute( @@ -427,11 +457,16 @@ async def delete_event( current_user: User = Depends(get_current_user), ): user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + shared_calendar_ids = select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + all_calendar_ids = user_calendar_ids.union(shared_calendar_ids) result = await db.execute( select(CalendarEvent).where( CalendarEvent.id == event_id, - CalendarEvent.calendar_id.in_(user_calendar_ids), + CalendarEvent.calendar_id.in_(all_calendar_ids), ) ) event = result.scalar_one_or_none() @@ -439,6 +474,10 @@ async def delete_event( if not event: raise HTTPException(status_code=404, detail="Calendar event not found") + # Shared calendar: require full_access+ and check lock + await require_permission(db, event.calendar_id, current_user.id, "full_access") + await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id) + if scope == "this": # Delete just this one occurrence await db.delete(event) diff --git a/backend/app/routers/shared_calendars.py b/backend/app/routers/shared_calendars.py new file mode 100644 index 0000000..047e68e --- /dev/null +++ b/backend/app/routers/shared_calendars.py @@ -0,0 +1,849 @@ +""" +Shared calendars router — invites, membership, locks, sync. + +All endpoints live under /api/shared-calendars. +""" +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request +from sqlalchemy import delete, func, select, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.calendar import Calendar +from app.models.calendar_event import CalendarEvent +from app.models.calendar_member import CalendarMember +from app.models.event_lock import EventLock +from app.models.settings import Settings +from app.models.user import User +from app.models.user_connection import UserConnection +from app.routers.auth import get_current_user +from app.schemas.shared_calendar import ( + CalendarInviteResponse, + CalendarMemberResponse, + InviteMemberRequest, + LockStatusResponse, + RespondInviteRequest, + SyncResponse, + UpdateLocalColorRequest, + UpdateMemberRequest, +) +from app.services.audit import get_client_ip, log_audit_event +from app.services.calendar_sharing import ( + PERMISSION_RANK, + acquire_lock, + get_user_permission, + release_lock, + require_permission, +) +from app.services.notification import create_notification + +router = APIRouter() +logger = logging.getLogger(__name__) + +PENDING_INVITE_CAP = 10 + + +# -- Helpers --------------------------------------------------------------- + +async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None: + result = await db.execute(select(Settings).where(Settings.user_id == user_id)) + return result.scalar_one_or_none() + + +def _build_member_response(member: CalendarMember) -> dict: + return { + "id": member.id, + "calendar_id": member.calendar_id, + "user_id": member.user_id, + "umbral_name": member.user.umbral_name if member.user else "", + "preferred_name": None, + "permission": member.permission, + "can_add_others": member.can_add_others, + "local_color": member.local_color, + "status": member.status, + "invited_at": member.invited_at, + "accepted_at": member.accepted_at, + } + + +# -- GET / — List accepted memberships ------------------------------------ + +@router.get("/") +async def list_shared_calendars( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List calendars the current user has accepted membership in.""" + result = await db.execute( + select(CalendarMember) + .where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + .options(selectinload(CalendarMember.calendar)) + .order_by(CalendarMember.accepted_at.desc()) + ) + members = result.scalars().all() + + return [ + { + "id": m.id, + "calendar_id": m.calendar_id, + "calendar_name": m.calendar.name if m.calendar else "", + "calendar_color": m.calendar.color if m.calendar else "", + "local_color": m.local_color, + "permission": m.permission, + "can_add_others": m.can_add_others, + "is_owner": False, + } + for m in members + ] + + +# -- POST /{cal_id}/invite — Invite via connection_id --------------------- + +@router.post("/{cal_id}/invite", status_code=201) +async def invite_member( + body: InviteMemberRequest, + request: Request, + cal_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Invite a connected user to a shared calendar.""" + cal_result = await db.execute( + select(Calendar).where(Calendar.id == cal_id) + ) + calendar = cal_result.scalar_one_or_none() + if not calendar: + raise HTTPException(status_code=404, detail="Calendar not found") + + is_owner = calendar.user_id == current_user.id + inviter_perm = "owner" if is_owner else None + + if not is_owner: + member_result = await db.execute( + select(CalendarMember).where( + CalendarMember.calendar_id == cal_id, + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + ) + member = member_result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Calendar not found") + if not member.can_add_others: + raise HTTPException(status_code=403, detail="You do not have permission to invite others") + if PERMISSION_RANK.get(member.permission, 0) < PERMISSION_RANK.get("create_modify", 0): + raise HTTPException(status_code=403, detail="Read-only members cannot invite others") + inviter_perm = member.permission + + # Permission ceiling + if inviter_perm != "owner": + if PERMISSION_RANK.get(body.permission, 0) > PERMISSION_RANK.get(inviter_perm, 0): + raise HTTPException( + status_code=403, + detail="Cannot grant a permission level higher than your own", + ) + + # Resolve connection_id -> connected user + conn_result = await db.execute( + select(UserConnection).where( + UserConnection.id == body.connection_id, + UserConnection.user_id == current_user.id, + ) + ) + connection = conn_result.scalar_one_or_none() + if not connection: + raise HTTPException(status_code=404, detail="Connection not found") + + target_user_id = connection.connected_user_id + + if target_user_id == calendar.user_id: + raise HTTPException(status_code=400, detail="Cannot invite the calendar owner") + + target_result = await db.execute( + select(User).where(User.id == target_user_id) + ) + target = target_result.scalar_one_or_none() + if not target or not target.is_active: + raise HTTPException(status_code=404, detail="Target user not found or inactive") + + existing = await db.execute( + select(CalendarMember).where( + CalendarMember.calendar_id == cal_id, + CalendarMember.user_id == target_user_id, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="User already invited or is a member") + + pending_count = await db.scalar( + select(func.count()) + .select_from(CalendarMember) + .where( + CalendarMember.calendar_id == cal_id, + CalendarMember.status == "pending", + ) + ) or 0 + if pending_count >= PENDING_INVITE_CAP: + raise HTTPException(status_code=429, detail="Too many pending invites for this calendar") + + if not calendar.is_shared: + calendar.is_shared = True + + new_member = CalendarMember( + calendar_id=cal_id, + user_id=target_user_id, + invited_by=current_user.id, + permission=body.permission, + can_add_others=body.can_add_others, + status="pending", + ) + db.add(new_member) + await db.flush() + + inviter_settings = await _get_settings_for_user(db, current_user.id) + inviter_display = (inviter_settings.preferred_name if inviter_settings else None) or current_user.umbral_name + + cal_name = calendar.name + await create_notification( + db, + user_id=target_user_id, + type="calendar_invite", + title="Calendar Invite", + message=f"{inviter_display} invited you to '{cal_name}'", + data={"calendar_id": cal_id, "calendar_name": cal_name}, + source_type="calendar_invite", + source_id=new_member.id, + ) + + await log_audit_event( + db, + action="calendar.invite_sent", + actor_id=current_user.id, + target_id=target_user_id, + detail={ + "calendar_id": cal_id, + "calendar_name": cal_name, + "permission": body.permission, + }, + ip=get_client_ip(request), + ) + + response = { + "message": "Invite sent", + "member_id": new_member.id, + "calendar_id": cal_id, + } + + await db.commit() + return response + + +# -- PUT /invites/{id}/respond — Accept or reject ------------------------- + +@router.put("/invites/{invite_id}/respond") +async def respond_to_invite( + body: RespondInviteRequest, + request: Request, + invite_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Accept or reject a calendar invite.""" + result = await db.execute( + select(CalendarMember) + .where( + CalendarMember.id == invite_id, + CalendarMember.user_id == current_user.id, + CalendarMember.status == "pending", + ) + .options(selectinload(CalendarMember.calendar)) + ) + invite = result.scalar_one_or_none() + if not invite: + raise HTTPException(status_code=404, detail="Invite not found or already resolved") + + calendar_name = invite.calendar.name if invite.calendar else "Unknown" + calendar_owner_id = invite.calendar.user_id if invite.calendar else None + inviter_id = invite.invited_by + + if body.action == "accept": + invite.status = "accepted" + invite.accepted_at = datetime.now() + + notify_user_id = inviter_id or calendar_owner_id + if notify_user_id: + user_settings = await _get_settings_for_user(db, current_user.id) + display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name + await create_notification( + db, + user_id=notify_user_id, + type="calendar_invite_accepted", + title="Invite Accepted", + message=f"{display} accepted your invite to '{calendar_name}'", + data={"calendar_id": invite.calendar_id}, + source_type="calendar_invite", + source_id=invite.id, + ) + + await log_audit_event( + db, + action="calendar.invite_accepted", + actor_id=current_user.id, + detail={"calendar_id": invite.calendar_id, "calendar_name": calendar_name}, + ip=get_client_ip(request), + ) + + await db.commit() + return {"message": "Invite accepted"} + + else: + member_id = invite.id + calendar_id = invite.calendar_id + + notify_user_id = inviter_id or calendar_owner_id + if notify_user_id: + user_settings = await _get_settings_for_user(db, current_user.id) + display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name + await create_notification( + db, + user_id=notify_user_id, + type="calendar_invite_rejected", + title="Invite Rejected", + message=f"{display} declined your invite to '{calendar_name}'", + data={"calendar_id": calendar_id}, + source_type="calendar_invite", + source_id=member_id, + ) + + await log_audit_event( + db, + action="calendar.invite_rejected", + actor_id=current_user.id, + detail={"calendar_id": calendar_id, "calendar_name": calendar_name}, + ip=get_client_ip(request), + ) + + await db.delete(invite) + await db.commit() + return {"message": "Invite rejected"} + + +# -- GET /invites/incoming — Pending invites ------------------------------- + +@router.get("/invites/incoming", response_model=list[CalendarInviteResponse]) +async def get_incoming_invites( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List pending calendar invites for the current user.""" + offset = (page - 1) * per_page + result = await db.execute( + select(CalendarMember) + .where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "pending", + ) + .options( + selectinload(CalendarMember.calendar), + selectinload(CalendarMember.inviter), + ) + .order_by(CalendarMember.invited_at.desc()) + .offset(offset) + .limit(per_page) + ) + invites = result.scalars().all() + + # Batch-fetch owner names to avoid N+1 + owner_ids = list({inv.calendar.user_id for inv in invites if inv.calendar}) + if owner_ids: + owner_result = await db.execute( + select(User.id, User.umbral_name).where(User.id.in_(owner_ids)) + ) + owner_names = {row.id: row.umbral_name for row in owner_result.all()} + else: + owner_names = {} + + responses = [] + for inv in invites: + owner_name = owner_names.get(inv.calendar.user_id, "") if inv.calendar else "" + + responses.append(CalendarInviteResponse( + id=inv.id, + calendar_id=inv.calendar_id, + calendar_name=inv.calendar.name if inv.calendar else "", + calendar_color=inv.calendar.color if inv.calendar else "", + owner_umbral_name=owner_name, + inviter_umbral_name=inv.inviter.umbral_name if inv.inviter else "", + permission=inv.permission, + invited_at=inv.invited_at, + )) + + return responses + + +# -- GET /{cal_id}/members — Member list ----------------------------------- + +@router.get("/{cal_id}/members", response_model=list[CalendarMemberResponse]) +async def list_members( + cal_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all members of a shared calendar. Requires membership or ownership.""" + perm = await get_user_permission(db, cal_id, current_user.id) + if perm is None: + raise HTTPException(status_code=404, detail="Calendar not found") + + result = await db.execute( + select(CalendarMember) + .where(CalendarMember.calendar_id == cal_id) + .options(selectinload(CalendarMember.user)) + .order_by(CalendarMember.invited_at.asc()) + ) + members = result.scalars().all() + + user_ids = [m.user_id for m in members] + if user_ids: + settings_result = await db.execute( + select(Settings.user_id, Settings.preferred_name) + .where(Settings.user_id.in_(user_ids)) + ) + pref_names = {row.user_id: row.preferred_name for row in settings_result.all()} + else: + pref_names = {} + + responses = [] + for m in members: + resp = _build_member_response(m) + resp["preferred_name"] = pref_names.get(m.user_id) + responses.append(resp) + + return responses + + +# -- PUT /{cal_id}/members/{mid} — Update permission (owner only) ---------- + +@router.put("/{cal_id}/members/{member_id}") +async def update_member( + body: UpdateMemberRequest, + request: Request, + cal_id: int = Path(ge=1, le=2147483647), + member_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update a member permission or can_add_others. Owner only.""" + cal_result = await db.execute( + select(Calendar).where(Calendar.id == cal_id, Calendar.user_id == current_user.id) + ) + if not cal_result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Only the calendar owner can update members") + + member_result = await db.execute( + select(CalendarMember).where( + CalendarMember.id == member_id, + CalendarMember.calendar_id == cal_id, + ) + ) + member = member_result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Member not found") + + update_data = body.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + for key, value in update_data.items(): + setattr(member, key, value) + + await log_audit_event( + db, + action="calendar.member_updated", + actor_id=current_user.id, + target_id=member.user_id, + detail={"calendar_id": cal_id, "member_id": member_id, "changes": update_data}, + ip=get_client_ip(request), + ) + + await db.commit() + return {"message": "Member updated"} + + +# -- DELETE /{cal_id}/members/{mid} — Remove member or leave --------------- + +@router.delete("/{cal_id}/members/{member_id}", status_code=204) +async def remove_member( + request: Request, + cal_id: int = Path(ge=1, le=2147483647), + member_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove a member or leave a shared calendar.""" + member_result = await db.execute( + select(CalendarMember).where( + CalendarMember.id == member_id, + CalendarMember.calendar_id == cal_id, + ) + ) + member = member_result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Member not found") + + cal_result = await db.execute( + select(Calendar).where(Calendar.id == cal_id) + ) + calendar = cal_result.scalar_one_or_none() + + is_self = member.user_id == current_user.id + is_owner = calendar and calendar.user_id == current_user.id + + if not is_self and not is_owner: + raise HTTPException(status_code=403, detail="Only the calendar owner can remove other members") + + target_user_id = member.user_id + + await db.execute( + delete(EventLock).where( + EventLock.locked_by == target_user_id, + EventLock.event_id.in_( + select(CalendarEvent.id).where(CalendarEvent.calendar_id == cal_id) + ), + ) + ) + + await db.delete(member) + + remaining = await db.execute( + select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1) + ) + if not remaining.scalar_one_or_none() and calendar: + calendar.is_shared = False + + action = "calendar.member_left" if is_self else "calendar.member_removed" + await log_audit_event( + db, + action=action, + actor_id=current_user.id, + target_id=target_user_id, + detail={"calendar_id": cal_id, "member_id": member_id}, + ip=get_client_ip(request), + ) + + await db.commit() + return None + + +# -- PUT /{cal_id}/members/me/color — Update local color ------------------- + +@router.put("/{cal_id}/members/me/color") +async def update_local_color( + body: UpdateLocalColorRequest, + cal_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update the current user local color for a shared calendar.""" + member_result = await db.execute( + select(CalendarMember).where( + CalendarMember.calendar_id == cal_id, + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + ) + member = member_result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Membership not found") + + member.local_color = body.local_color + await db.commit() + return {"message": "Color updated"} + + +# -- GET /sync — Sync endpoint --------------------------------------------- + +@router.get("/sync", response_model=SyncResponse) +async def sync_shared_calendars( + since: datetime = Query(...), + calendar_ids: Optional[str] = Query(None, description="Comma-separated calendar IDs"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Sync events and member changes since a given timestamp. Cap 500 events.""" + MAX_EVENTS = 500 + + cal_id_list: list[int] = [] + if calendar_ids: + for part in calendar_ids.split(","): + part = part.strip() + if part.isdigit(): + cal_id_list.append(int(part)) + cal_id_list = cal_id_list[:50] # Cap to prevent unbounded IN clause + + owned_ids_result = await db.execute( + select(Calendar.id).where(Calendar.user_id == current_user.id) + ) + owned_ids = {row[0] for row in owned_ids_result.all()} + + member_ids_result = await db.execute( + select(CalendarMember.calendar_id).where( + CalendarMember.user_id == current_user.id, + CalendarMember.status == "accepted", + ) + ) + member_ids = {row[0] for row in member_ids_result.all()} + + accessible = owned_ids | member_ids + if cal_id_list: + accessible = accessible & set(cal_id_list) + + if not accessible: + return SyncResponse(events=[], member_changes=[], server_time=datetime.now()) + + accessible_list = list(accessible) + + events_result = await db.execute( + select(CalendarEvent) + .where( + CalendarEvent.calendar_id.in_(accessible_list), + CalendarEvent.updated_at >= since, + ) + .options(selectinload(CalendarEvent.calendar)) + .order_by(CalendarEvent.updated_at.desc()) + .limit(MAX_EVENTS + 1) + ) + events = events_result.scalars().all() + truncated = len(events) > MAX_EVENTS + events = events[:MAX_EVENTS] + + event_dicts = [] + for e in events: + event_dicts.append({ + "id": e.id, + "title": e.title, + "start_datetime": e.start_datetime.isoformat() if e.start_datetime else None, + "end_datetime": e.end_datetime.isoformat() if e.end_datetime else None, + "all_day": e.all_day, + "calendar_id": e.calendar_id, + "calendar_name": e.calendar.name if e.calendar else "", + "updated_at": e.updated_at.isoformat() if e.updated_at else None, + "updated_by": e.updated_by, + }) + + members_result = await db.execute( + select(CalendarMember) + .where( + CalendarMember.calendar_id.in_(accessible_list), + (CalendarMember.invited_at >= since) | (CalendarMember.accepted_at >= since), + ) + .options(selectinload(CalendarMember.user)) + ) + member_changes = members_result.scalars().all() + + member_dicts = [] + for m in member_changes: + member_dicts.append({ + "id": m.id, + "calendar_id": m.calendar_id, + "user_id": m.user_id, + "umbral_name": m.user.umbral_name if m.user else "", + "permission": m.permission, + "status": m.status, + "invited_at": m.invited_at.isoformat() if m.invited_at else None, + "accepted_at": m.accepted_at.isoformat() if m.accepted_at else None, + }) + + return SyncResponse( + events=event_dicts, + member_changes=member_dicts, + server_time=datetime.now(), + truncated=truncated, + ) + + +# -- Event Lock Endpoints -------------------------------------------------- + +@router.post("/events/{event_id}/lock") +async def lock_event( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Acquire a 5-minute editing lock on an event.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + await require_permission(db, event.calendar_id, current_user.id, "create_modify") + + lock = await acquire_lock(db, event_id, current_user.id) + + # Build response BEFORE commit — ORM objects expire after commit + response = { + "locked": True, + "locked_by_name": current_user.umbral_name, + "expires_at": lock.expires_at, + "is_permanent": lock.is_permanent, + } + + await db.commit() + return response + + +@router.delete("/events/{event_id}/lock", status_code=204) +async def unlock_event( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Release a lock. Only the holder or calendar owner can release.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + lock_result = await db.execute( + select(EventLock).where(EventLock.event_id == event_id) + ) + lock = lock_result.scalar_one_or_none() + if not lock: + return None + + cal_result = await db.execute( + select(Calendar).where(Calendar.id == event.calendar_id) + ) + calendar = cal_result.scalar_one_or_none() + is_owner = calendar and calendar.user_id == current_user.id + + if lock.locked_by != current_user.id and not is_owner: + raise HTTPException(status_code=403, detail="Only the lock holder or calendar owner can release") + + await db.delete(lock) + await db.commit() + return None + + +@router.get("/events/{event_id}/lock", response_model=LockStatusResponse) +async def get_lock_status( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get the lock status of an event.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + perm = await get_user_permission(db, event.calendar_id, current_user.id) + if perm is None: + raise HTTPException(status_code=404, detail="Event not found") + + lock_result = await db.execute( + select(EventLock) + .where(EventLock.event_id == event_id) + .options(selectinload(EventLock.holder)) + ) + lock = lock_result.scalar_one_or_none() + + if not lock: + return LockStatusResponse(locked=False) + + now = datetime.now() + if not lock.is_permanent and lock.expires_at and lock.expires_at < now: + await db.delete(lock) + await db.commit() + return LockStatusResponse(locked=False) + + return LockStatusResponse( + locked=True, + locked_by_name=lock.holder.umbral_name if lock.holder else None, + expires_at=lock.expires_at, + is_permanent=lock.is_permanent, + ) + + +@router.post("/events/{event_id}/owner-lock") +async def set_permanent_lock( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Set a permanent lock on an event. Calendar owner only.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + cal_result = await db.execute( + select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id) + ) + if not cal_result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Only the calendar owner can set permanent locks") + + now = datetime.now() + await db.execute( + text(""" + INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent) + VALUES (:event_id, :user_id, :now, NULL, true) + ON CONFLICT (event_id) + DO UPDATE SET + locked_by = :user_id, + locked_at = :now, + expires_at = NULL, + is_permanent = true + """), + {"event_id": event_id, "user_id": current_user.id, "now": now}, + ) + + await db.commit() + return {"message": "Permanent lock set", "is_permanent": True} + + +@router.delete("/events/{event_id}/owner-lock", status_code=204) +async def remove_permanent_lock( + event_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove a permanent lock. Calendar owner only.""" + event_result = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + cal_result = await db.execute( + select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id) + ) + if not cal_result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="Only the calendar owner can remove permanent locks") + + await db.execute( + delete(EventLock).where( + EventLock.event_id == event_id, + EventLock.is_permanent == True, + ) + ) + + await db.commit() + return None diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py new file mode 100644 index 0000000..9be24d0 --- /dev/null +++ b/backend/app/services/calendar_sharing.py @@ -0,0 +1,205 @@ +""" +Calendar sharing service — permission checks, lock management, disconnect cascade. + +All functions accept an AsyncSession and do NOT commit — callers manage transactions. +""" +import logging +from datetime import datetime, timedelta + +from fastapi import HTTPException +from sqlalchemy import delete, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.calendar import Calendar +from app.models.calendar_member import CalendarMember +from app.models.event_lock import EventLock + +logger = logging.getLogger(__name__) + +PERMISSION_RANK = {"read_only": 1, "create_modify": 2, "full_access": 3} +LOCK_DURATION_MINUTES = 5 + + +async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None: + """ + Returns "owner" if the user owns the calendar, the permission string + if they are an accepted member, or None if they have no access. + """ + cal = await db.execute( + select(Calendar).where(Calendar.id == calendar_id) + ) + calendar = cal.scalar_one_or_none() + if not calendar: + return None + if calendar.user_id == user_id: + return "owner" + + member = await db.execute( + select(CalendarMember).where( + CalendarMember.calendar_id == calendar_id, + CalendarMember.user_id == user_id, + CalendarMember.status == "accepted", + ) + ) + row = member.scalar_one_or_none() + return row.permission if row else None + + +async def require_permission( + db: AsyncSession, calendar_id: int, user_id: int, min_level: str +) -> str: + """ + Raises 403 if the user lacks at least min_level permission. + Returns the actual permission string (or "owner"). + """ + perm = await get_user_permission(db, calendar_id, user_id) + if perm is None: + raise HTTPException(status_code=404, detail="Calendar not found") + if perm == "owner": + return "owner" + if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0): + raise HTTPException(status_code=403, detail="Insufficient permission on this calendar") + return perm + + +async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLock: + """ + Atomic INSERT ON CONFLICT — acquires a 5-minute lock on the event. + Only succeeds if no unexpired lock exists or the existing lock is held by the same user. + Returns the lock or raises 423 Locked. + """ + now = datetime.now() + expires = now + timedelta(minutes=LOCK_DURATION_MINUTES) + + result = await db.execute( + text(""" + INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent) + VALUES (:event_id, :user_id, :now, :expires, false) + ON CONFLICT (event_id) + DO UPDATE SET + locked_by = :user_id, + locked_at = :now, + expires_at = :expires, + is_permanent = false + WHERE event_locks.expires_at < :now + OR event_locks.locked_by = :user_id + RETURNING id, event_id, locked_by, locked_at, expires_at, is_permanent + """), + {"event_id": event_id, "user_id": user_id, "now": now, "expires": expires}, + ) + row = result.first() + if not row: + raise HTTPException(status_code=423, detail="Event is locked by another user") + + lock_result = await db.execute( + select(EventLock).where(EventLock.id == row.id) + ) + return lock_result.scalar_one() + + +async def release_lock(db: AsyncSession, event_id: int, user_id: int) -> None: + """Delete the lock only if held by this user.""" + await db.execute( + delete(EventLock).where( + EventLock.event_id == event_id, + EventLock.locked_by == user_id, + ) + ) + + +async def check_lock_for_edit( + db: AsyncSession, event_id: int, user_id: int, calendar_id: int +) -> None: + """ + For shared calendars: verify no active lock by another user blocks this edit. + For personal (non-shared) calendars: no-op. + """ + cal_result = await db.execute( + select(Calendar.is_shared).where(Calendar.id == calendar_id) + ) + is_shared = cal_result.scalar_one_or_none() + if not is_shared: + return + + lock_result = await db.execute( + select(EventLock).where(EventLock.event_id == event_id) + ) + lock = lock_result.scalar_one_or_none() + if not lock: + return + now = datetime.now() + if lock.is_permanent and lock.locked_by != user_id: + raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner") + if lock.locked_by != user_id and (lock.expires_at is None or lock.expires_at > now): + raise HTTPException(status_code=423, detail="Event is locked by another user") + + +async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int) -> None: + """ + When a connection is severed: + 1. Delete CalendarMember rows where one user is a member of the other's calendars + 2. Delete EventLock rows held by the disconnected user on affected calendars + 3. Reset is_shared=False on calendars with no remaining members + """ + # Find calendars owned by each user + a_cal_ids_result = await db.execute( + select(Calendar.id).where(Calendar.user_id == user_a_id) + ) + a_cal_ids = [row[0] for row in a_cal_ids_result.all()] + + b_cal_ids_result = await db.execute( + select(Calendar.id).where(Calendar.user_id == user_b_id) + ) + b_cal_ids = [row[0] for row in b_cal_ids_result.all()] + + # Delete user_b's memberships on user_a's calendars + locks + if a_cal_ids: + await db.execute( + delete(CalendarMember).where( + CalendarMember.calendar_id.in_(a_cal_ids), + CalendarMember.user_id == user_b_id, + ) + ) + await db.execute( + text(""" + DELETE FROM event_locks + WHERE locked_by = :user_id + AND event_id IN ( + SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids) + ) + """), + {"user_id": user_b_id, "cal_ids": a_cal_ids}, + ) + + # Delete user_a's memberships on user_b's calendars + locks + if b_cal_ids: + await db.execute( + delete(CalendarMember).where( + CalendarMember.calendar_id.in_(b_cal_ids), + CalendarMember.user_id == user_a_id, + ) + ) + await db.execute( + text(""" + DELETE FROM event_locks + WHERE locked_by = :user_id + AND event_id IN ( + SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids) + ) + """), + {"user_id": user_a_id, "cal_ids": b_cal_ids}, + ) + + # Reset is_shared on calendars with no remaining members + all_cal_ids = a_cal_ids + b_cal_ids + for cal_id in all_cal_ids: + remaining = await db.execute( + select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1) + ) + if not remaining.scalar_one_or_none(): + cal_result = await db.execute( + select(Calendar).where(Calendar.id == cal_id) + ) + cal = cal_result.scalar_one_or_none() + if cal: + cal.is_shared = False diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 52429a0..c3936cc 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,9 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m; # Admin API — generous for legitimate use but still guards against scraping/brute-force limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m; +# Calendar sharing endpoints +limit_req_zone $binary_remote_addr zone=cal_invite_limit:10m rate=5r/m; +limit_req_zone $binary_remote_addr zone=cal_sync_limit:10m rate=15r/m; # Connection endpoints — prevent search enumeration and request spam limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m; @@ -99,6 +102,19 @@ server { include /etc/nginx/proxy-params.conf; } + # Calendar invite — rate-limited to prevent invite spam + location ~ /api/shared-calendars/\d+/invite { + limit_req zone=cal_invite_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + + # Calendar sync — rate-limited to prevent excessive polling + location /api/shared-calendars/sync { + limit_req zone=cal_sync_limit burst=5 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } # Admin API — rate-limited separately from general /api traffic location /api/admin/ { limit_req zone=admin_limit burst=10 nodelay;