UMBRA/backend/app/routers/events.py
Kyle Pope 925c9caf91 Fix QA and pentest findings for event invitations
C-01: Use func.count() for invitation cap instead of loading all rows
C-02: Remove unused display_calendar_id from EventInvitationResponse
F-01: Add field allowlist for invited editors (blocks is_starred,
      recurrence_rule, calendar_id mutations)
W-02: Memoize existingInviteeIds Set in EventDetailPanel
W-03: Block per-occurrence overrides on declined/pending invitations
S-01: Make can_modify non-optional in EventInvitation TypeScript type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:28:01 +08:00

678 lines
27 KiB
Python

import json
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import false as sa_false, select, delete, or_
from sqlalchemy.orm import selectinload
from typing import Optional, List, Any, Literal
from datetime import date, datetime, timedelta
from app.database import get_db
from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.person import Person
from app.schemas.calendar_event import (
CalendarEventCreate,
CalendarEventUpdate,
CalendarEventResponse,
)
from app.routers.auth import get_current_user
from app.models.user import User
from app.services.recurrence import generate_occurrences
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, get_accessible_event_scope, require_permission
from app.services.event_invitation import get_invited_event_ids, get_invitation_overrides_for_user
from app.models.event_invitation import EventInvitation
router = APIRouter()
def _event_to_dict(
event: CalendarEvent,
is_invited: bool = False,
invitation_status: str | None = None,
invitation_id: int | None = None,
display_calendar_id: int | None = None,
display_calendar_name: str | None = None,
display_calendar_color: str | None = None,
can_modify: bool = False,
has_active_invitees: bool = False,
) -> dict:
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
# For invited events: use display calendar if set, otherwise fallback to "Invited"/gray
if is_invited:
if display_calendar_name:
cal_name = display_calendar_name
cal_color = display_calendar_color or "#6B7280"
else:
cal_name = "Invited"
cal_color = "#6B7280"
else:
cal_name = event.calendar.name if event.calendar else ""
cal_color = event.calendar.color if event.calendar else ""
d = {
"id": event.id,
"title": event.title,
"description": event.description,
"start_datetime": event.start_datetime,
"end_datetime": event.end_datetime,
"all_day": event.all_day,
"color": event.color,
"location_id": event.location_id,
"recurrence_rule": event.recurrence_rule,
"is_starred": event.is_starred,
"calendar_id": event.calendar_id,
"calendar_name": cal_name,
"calendar_color": cal_color,
"is_virtual": False,
"parent_event_id": event.parent_event_id,
"is_recurring": event.is_recurring,
"original_start": event.original_start,
"created_at": event.created_at,
"updated_at": event.updated_at,
"is_invited": is_invited,
"invitation_status": invitation_status,
"invitation_id": invitation_id,
"display_calendar_id": display_calendar_id,
"can_modify": can_modify,
"has_active_invitees": has_active_invitees,
}
return d
def _birthday_events_for_range(
people: list,
bday_calendar_id: int,
bday_calendar_color: str,
start: Optional[date],
end: Optional[date],
) -> list[dict]:
"""Generate virtual all-day birthday events for each person within the date range."""
virtual_events = []
now = datetime.now()
range_start = start or date(now.year - 1, 1, 1)
range_end = end or date(now.year + 2, 12, 31)
for person in people:
if not person.birthday:
continue
bday: date = person.birthday
for year in range(range_start.year, range_end.year + 1):
try:
bday_this_year = bday.replace(year=year)
except ValueError:
# Feb 29 on non-leap year — skip
continue
if bday_this_year < range_start or bday_this_year > range_end:
continue
start_dt = datetime(bday_this_year.year, bday_this_year.month, bday_this_year.day, 0, 0, 0)
end_dt = start_dt + timedelta(days=1)
age = year - bday.year
virtual_events.append({
"id": f"bday-{person.id}-{year}",
"title": f"{person.name}'s Birthday" + (f" ({age})" if age > 0 else ""),
"description": None,
"start_datetime": start_dt,
"end_datetime": end_dt,
"all_day": True,
"color": bday_calendar_color,
"location_id": None,
"recurrence_rule": None,
"is_starred": False,
"calendar_id": bday_calendar_id,
"calendar_name": "Birthdays",
"calendar_color": bday_calendar_color,
"is_virtual": True,
"parent_event_id": None,
"is_recurring": False,
"original_start": None,
"created_at": start_dt,
"updated_at": start_dt,
})
return virtual_events
async def _get_default_calendar_id(db: AsyncSession, user_id: int) -> int:
"""Return the id of the user's default calendar, raising 500 if not found."""
result = await db.execute(
select(Calendar).where(
Calendar.user_id == user_id,
Calendar.is_default == True,
)
)
default = result.scalar_one_or_none()
if not default:
raise HTTPException(status_code=500, detail="No default calendar configured")
return default.id
async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id: int) -> None:
"""Raise 404 if calendar_id does not belong to user_id (SEC-04)."""
result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == user_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Calendar not found")
@router.get("/", response_model=None)
async def get_events(
start: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
end: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> List[Any]:
"""
Get all calendar events with optional date range filtering.
Parent template rows (is_recurring=True, parent_event_id IS NULL,
recurrence_rule IS NOT NULL) are excluded — their materialised children
are what get displayed on the calendar.
"""
# Scope events through calendar ownership + shared memberships + invitations
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
query = (
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
or_(
CalendarEvent.calendar_id.in_(all_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
)
)
)
# Exclude parent template rows — they are not directly rendered
query = query.where(
~(
(CalendarEvent.is_recurring == True)
& (CalendarEvent.parent_event_id == None)
& (CalendarEvent.recurrence_rule != None)
)
)
if start:
query = query.where(CalendarEvent.end_datetime >= start)
if end:
query = query.where(CalendarEvent.start_datetime <= end)
query = query.order_by(CalendarEvent.start_datetime.asc()).limit(2000)
result = await db.execute(query)
events = result.scalars().all()
# Build invitation lookup for the current user
invited_event_id_set = set(invited_event_ids)
invitation_map: dict[int, tuple[str, int, int | None, bool]] = {} # event_id -> (status, invitation_id, display_calendar_id, can_modify)
if invited_event_ids:
inv_result = await db.execute(
select(
EventInvitation.event_id,
EventInvitation.status,
EventInvitation.id,
EventInvitation.display_calendar_id,
EventInvitation.can_modify,
).where(
EventInvitation.user_id == current_user.id,
EventInvitation.event_id.in_(invited_event_ids),
)
)
for eid, status, inv_id, disp_cal_id, cm in inv_result.all():
invitation_map[eid] = (status, inv_id, disp_cal_id, cm)
# Batch-fetch display calendars for invited events
display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None}
display_cal_map: dict[int, dict] = {} # cal_id -> {name, color}
if display_cal_ids:
cal_result = await db.execute(
select(Calendar.id, Calendar.name, Calendar.color).where(
Calendar.id.in_(display_cal_ids),
Calendar.id.in_(all_calendar_ids),
)
)
for cal_id, cal_name, cal_color in cal_result.all():
display_cal_map[cal_id] = {"name": cal_name, "color": cal_color}
# Get per-occurrence overrides for invited events
all_event_ids = [e.id for e in events]
override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids)
# Batch-fetch event IDs that have accepted/tentative invitees (for owner's shared icon)
active_invitee_set: set[int] = set()
if all_event_ids:
active_inv_result = await db.execute(
select(EventInvitation.event_id).where(
EventInvitation.event_id.in_(all_event_ids),
EventInvitation.status.in_(["accepted", "tentative"]),
).distinct()
)
active_invitee_set = {r[0] for r in active_inv_result.all()}
# Also mark parent events: if a parent has active invitees, all its children should show the icon
parent_ids = {e.parent_event_id for e in events if e.parent_event_id and e.parent_event_id in active_invitee_set}
if parent_ids:
active_invitee_set.update(e.id for e in events if e.parent_event_id in active_invitee_set)
response: List[dict] = []
for e in events:
# Determine if this event is from an invitation
parent_id = e.parent_event_id or e.id
is_invited = parent_id in invited_event_id_set
inv_status = None
inv_id = None
disp_cal_id = None
disp_cal_name = None
disp_cal_color = None
inv_can_modify = False
if is_invited and parent_id in invitation_map:
inv_status, inv_id, disp_cal_id, inv_can_modify = invitation_map[parent_id]
# Check for per-occurrence override
if e.id in override_map:
inv_status = override_map[e.id]
# Resolve display calendar info
if disp_cal_id and disp_cal_id in display_cal_map:
disp_cal_name = display_cal_map[disp_cal_id]["name"]
disp_cal_color = display_cal_map[disp_cal_id]["color"]
response.append(_event_to_dict(
e,
is_invited=is_invited,
invitation_status=inv_status,
invitation_id=inv_id,
display_calendar_id=disp_cal_id,
display_calendar_name=disp_cal_name,
display_calendar_color=disp_cal_color,
can_modify=inv_can_modify,
has_active_invitees=(parent_id in active_invitee_set or e.id in active_invitee_set),
))
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
bday_result = await db.execute(
select(Calendar).where(
Calendar.user_id == current_user.id,
Calendar.name == "Birthdays",
Calendar.is_system == True,
)
)
bday_calendar = bday_result.scalar_one_or_none()
if bday_calendar and bday_calendar.is_visible:
# Scope birthday people to this user
people_result = await db.execute(
select(Person).where(
Person.user_id == current_user.id,
Person.birthday.isnot(None),
)
)
people = people_result.scalars().all()
virtual = _birthday_events_for_range(
people=people,
bday_calendar_id=bday_calendar.id,
bday_calendar_color=bday_calendar.color,
start=start,
end=end,
)
response.extend(virtual)
return response
@router.post("/", response_model=CalendarEventResponse, status_code=201)
async def create_event(
event: CalendarEventCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if event.end_datetime < event.start_datetime:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
data = event.model_dump()
# Resolve calendar_id to user's default if not provided
if not data.get("calendar_id"):
data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
else:
# 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
rule_obj = data.pop("recurrence_rule", None)
rule_json: Optional[str] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
if rule_json:
# Parent template: is_recurring=True, no parent_event_id
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
children = generate_occurrences(parent)
db.add_all(children)
await db.commit()
# Return the first child so the caller sees a concrete occurrence
# (The parent template is intentionally hidden from list views)
if children:
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == children[0].id)
)
return result.scalar_one()
# Fallback: no children generated (e.g. bad rule), return parent
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == parent.id)
)
return result.scalar_one()
else:
new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id)
db.add(new_event)
await db.commit()
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == new_event.id)
)
return result.scalar_one()
@router.get("/{event_id}", response_model=CalendarEventResponse)
async def get_event(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
invited_set = set(invited_event_ids)
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
CalendarEvent.id == event_id,
or_(
CalendarEvent.calendar_id.in_(all_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
),
)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
return event
@router.put("/{event_id}", response_model=CalendarEventResponse)
async def update_event(
event_id: int = Path(ge=1, le=2147483647),
event_update: CalendarEventUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
# Event invitees can VIEW events but must NOT be able to edit them
# UNLESS they have can_modify=True (checked in fallback path below).
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
is_invited_editor = False
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(all_calendar_ids),
)
)
event = result.scalar_one_or_none()
if not event:
# Fallback: check if user has can_modify invitation for this event
# Must check both event_id (direct) and parent_event_id (recurring child)
# because invitations are stored against the parent event
target_event_result = await db.execute(
select(CalendarEvent.parent_event_id).where(CalendarEvent.id == event_id)
)
target_row = target_event_result.one_or_none()
if not target_row:
raise HTTPException(status_code=404, detail="Calendar event not found")
candidate_ids = [event_id]
if target_row[0] is not None:
candidate_ids.append(target_row[0])
inv_result = await db.execute(
select(EventInvitation).where(
EventInvitation.event_id.in_(candidate_ids),
EventInvitation.user_id == current_user.id,
EventInvitation.can_modify == True,
EventInvitation.status.in_(["accepted", "tentative"]),
)
)
inv = inv_result.scalar_one_or_none()
if not inv:
raise HTTPException(status_code=404, detail="Calendar event not found")
# Load the event directly (bypassing calendar filter)
event_result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
is_invited_editor = True
update_data = event_update.model_dump(exclude_unset=True)
if is_invited_editor:
# Invited editor restrictions — enforce BEFORE any data mutation
# Field allowlist: invited editors can only modify event content, not structure
INVITED_EDITOR_ALLOWED = {"title", "description", "start_datetime", "end_datetime", "all_day", "color", "edit_scope", "location_id"}
disallowed = set(update_data.keys()) - INVITED_EDITOR_ALLOWED
if disallowed:
raise HTTPException(status_code=403, detail="Invited editors cannot modify: " + ", ".join(sorted(disallowed)))
scope_peek = update_data.get("edit_scope")
# Block all bulk-scope edits on recurring events (C-01/F-01)
if event.is_recurring and scope_peek != "this":
raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences")
else:
# Standard calendar-access path: require create_modify+ permission
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
# Lock check applies to both paths (uses owner's calendar_id)
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
# Extract scope before applying fields to the model
scope: Optional[str] = update_data.pop("edit_scope", None)
# Serialize RecurrenceRule → JSON string if present in update payload
rule_obj = update_data.pop("recurrence_rule", None)
if rule_obj is not None:
update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
if not is_invited_editor:
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
# Only verify ownership when the calendar is actually changing — members submitting
# an unchanged calendar_id must not be rejected just because they aren't the owner.
if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id:
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
# M-01: Block non-owners from moving events off shared calendars
if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
source_cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id)
)
source_cal = source_cal_result.scalar_one_or_none()
if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Only the calendar owner can move events between calendars",
)
start = update_data.get("start_datetime", event.start_datetime)
end_dt = update_data.get("end_datetime", event.end_datetime)
if end_dt is not None and end_dt < start:
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
if scope == "this":
# Update only this occurrence and detach it from the series
for key, value in update_data.items():
setattr(event, key, value)
# 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":
# Delete all future siblings (same parent, original_start >= this event's original_start)
parent_id = event.parent_event_id
this_original_start = event.original_start or event.start_datetime
if parent_id is not None:
# Fetch the parent's recurrence_rule before deleting siblings
parent_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == parent_id)
)
parent_event = parent_result.scalar_one_or_none()
parent_rule = parent_event.recurrence_rule if parent_event else None
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id,
CalendarEvent.original_start >= this_original_start,
CalendarEvent.id != event_id,
)
)
# Update this event with the new data, making it a new parent
for key, value in update_data.items():
setattr(event, key, value)
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:
event.recurrence_rule = parent_rule
# Regenerate children from this point
if event.recurrence_rule:
await db.flush()
children = generate_occurrences(event)
db.add_all(children)
else:
# 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:
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == event.id
)
)
await db.flush()
children = generate_occurrences(event)
db.add_all(children)
await db.commit()
else:
# 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(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
updated = result.scalar_one_or_none()
if not updated:
# Event was deleted as part of this_and_future; 404 is appropriate
raise HTTPException(status_code=404, detail="Event no longer exists after update")
return updated
@router.delete("/{event_id}", status_code=204)
async def delete_event(
event_id: int = Path(ge=1, le=2147483647),
scope: Optional[Literal["this", "this_and_future"]] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
# Event invitees can VIEW events but must NOT be able to delete them.
# Invitees use DELETE /api/event-invitations/{id} to leave instead.
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
result = await db.execute(
select(CalendarEvent).where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(all_calendar_ids),
)
)
event = result.scalar_one_or_none()
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)
elif scope == "this_and_future":
parent_id = event.parent_event_id
this_original_start = event.original_start or event.start_datetime
if parent_id is not None:
# Delete this + all future siblings (original_start is always set on children)
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id,
CalendarEvent.original_start >= this_original_start,
)
)
else:
# This event IS the parent — delete it and all children (CASCADE handles children)
await db.delete(event)
else:
# Plain delete (non-recurring; or delete entire series if parent)
await db.delete(event)
await db.commit()
return None