Invitees no longer see the event owner's calendar name/color, preventing minor information disclosure (CWE-200). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
556 lines
21 KiB
Python
556 lines
21 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,
|
|
) -> dict:
|
|
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
|
|
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": "Invited" if is_invited else (event.calendar.name if event.calendar else ""),
|
|
"calendar_color": "#6B7280" if is_invited else (event.calendar.color if event.calendar else ""),
|
|
"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,
|
|
}
|
|
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]] = {} # event_id -> (status, invitation_id)
|
|
if invited_event_ids:
|
|
inv_result = await db.execute(
|
|
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.id).where(
|
|
EventInvitation.user_id == current_user.id,
|
|
EventInvitation.event_id.in_(invited_event_ids),
|
|
)
|
|
)
|
|
for eid, status, inv_id in inv_result.all():
|
|
invitation_map[eid] = (status, inv_id)
|
|
|
|
# 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)
|
|
|
|
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
|
|
if is_invited and parent_id in invitation_map:
|
|
inv_status, inv_id = invitation_map[parent_id]
|
|
# Check for per-occurrence override
|
|
if e.id in override_map:
|
|
inv_status = override_map[e.id]
|
|
response.append(_event_to_dict(e, is_invited=is_invited, invitation_status=inv_status, invitation_id=inv_id))
|
|
|
|
# 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.
|
|
# Do not add invited_event_ids to this query.
|
|
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
|
|
|
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:
|
|
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
|
|
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
|
|
|
|
# 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
|