"""Calendar management: ICS upload, subscription, groups, reminders. Every function is scoped to an explicit `user_id` so each user only ever sees/mutates their own data (defense in depth: rows are filtered by user_id even though they also join user-scoped groups/sources). """ import os import hashlib import tempfile from datetime import datetime, timedelta, date from backend.time_util import now as tz_now from flask import current_app from backend.models import get_db def _uid() -> int: """Resolve the current user for scheduler-thread calls.""" try: from backend.auth import current_user_id return current_user_id() or 0 except Exception: return 0 def _require_group_owner(db, user_id: int, group_id: int): """Return the group row only if it belongs to the user (else None). Prevents cross-user writes through unvalidated group ids.""" return db.execute( "SELECT id FROM calendar_groups WHERE id=? AND user_id=?", (group_id, user_id) ).fetchone() def get_groups(user_id: int = None): if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) groups = [dict(r) for r in db.execute( "SELECT * FROM calendar_groups WHERE user_id=? ORDER BY id", (user_id,) ).fetchall()] for g in groups: g["sources"] = [dict(r) for r in db.execute( "SELECT id, type, name, url, last_sync FROM calendar_sources " "WHERE group_id=? AND user_id=?", (g["id"], user_id) ).fetchall()] db.close() return groups def create_group(name: str, color: str = "#4A90D9", user_id: int = None) -> dict: if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) cur = db.execute( "INSERT INTO calendar_groups (user_id, name, color) VALUES (?, ?, ?)", (user_id, name, color) ) db.commit() gid = cur.lastrowid row = dict(db.execute("SELECT * FROM calendar_groups WHERE id=?", (gid,)).fetchone()) db.close() return row def update_group(group_id: int, name: str = None, color: str = None, enabled: bool = None, user_id: int = None): if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) fields = [] vals = [] if name is not None: fields.append("name=?") vals.append(name) if color is not None: fields.append("color=?") vals.append(color) if enabled is not None: fields.append("enabled=?") vals.append(1 if enabled else 0) ok = False if fields: vals.append(user_id) vals.append(group_id) cur = db.execute(f"UPDATE calendar_groups SET {', '.join(fields)} WHERE user_id=? AND id=?", vals) db.commit() ok = cur.rowcount > 0 db.close() return ok def delete_group(group_id: int, user_id: int = None) -> bool: if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id IN " "(SELECT id FROM calendar_sources WHERE user_id=? AND group_id=?)", (user_id, user_id, group_id)) db.execute("DELETE FROM calendar_sources WHERE user_id=? AND group_id=?", (user_id, group_id)) db.execute("DELETE FROM reminders WHERE user_id=? AND calendar_group_id=?", (user_id, group_id)) cur = db.execute("DELETE FROM calendar_groups WHERE user_id=? AND id=?", (user_id, group_id)) db.commit() db.close() return cur.rowcount > 0 def upload_ics(group_id: int, name: str, ics_content: str, user_id: int = None): if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) if not _require_group_owner(db, user_id, group_id): db.close() raise ValueError("分组不存在或无权访问") cur = db.execute( "INSERT INTO calendar_sources (user_id, group_id, type, name, ics_data) " "VALUES (?, ?, 'upload', ?, ?)", (user_id, group_id, name, ics_content) ) source_id = cur.lastrowid db.commit() _parse_and_store_events(db, user_id, source_id, ics_content) db.close() current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id) def subscribe_ics(group_id: int, name: str, url: str, user_id: int = None): if user_id is None: user_id = _uid() from backend.url_util import is_safe_public_url if not is_safe_public_url(url): raise ValueError("订阅地址无效或不安全(仅允许公网 http/https 地址)") db = get_db(current_app.config["DB_PATH"]) if not _require_group_owner(db, user_id, group_id): db.close() raise ValueError("分组不存在或无权访问") cur = db.execute( "INSERT INTO calendar_sources (user_id, group_id, type, name, url) " "VALUES (?, ?, 'subscription', ?, ?)", (user_id, group_id, name, url) ) source_id = cur.lastrowid db.commit() db.close() sync_subscription(source_id, user_id) def sync_subscription(source_id: int, user_id: int = None): """Pull and parse a remote ICS subscription.""" if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) row = db.execute("SELECT * FROM calendar_sources WHERE id=? AND user_id=?", (source_id, user_id)).fetchone() if not row or row["type"] != "subscription" or not row["url"]: db.close() return try: from backend.url_util import safe_http_get resp = safe_http_get(row["url"], timeout=15) if resp is None: current_app.logger.warning(f"Sync skipped: unsafe URL for source {source_id}") return ics_data = resp.text # Clear old events for this source db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?", (user_id, source_id)) _parse_and_store_events(db, user_id, source_id, ics_data) db.execute("UPDATE calendar_sources SET last_sync=datetime('now') WHERE id=? AND user_id=?", (source_id, user_id)) db.commit() current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id) except Exception as e: current_app.logger.error(f"Sync failed for source {source_id}: {e}") finally: db.close() def sync_all_subscriptions(user_id: int = None): """Sync all subscription sources for one user (or all users when user_id is None).""" if user_id is None: db = get_db(current_app.config["DB_PATH"]) rows = db.execute("SELECT user_id, id FROM calendar_sources WHERE type='subscription' AND url IS NOT NULL").fetchall() db.close() for r in rows: sync_subscription(r["id"], r["user_id"]) return db = get_db(current_app.config["DB_PATH"]) sources = db.execute( "SELECT id FROM calendar_sources WHERE user_id=? AND type='subscription' AND url IS NOT NULL", (user_id,) ).fetchall() db.close() for s in sources: sync_subscription(s["id"], user_id) def delete_source(source_id: int, user_id: int = None) -> bool: if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?", (user_id, source_id)) cur = db.execute("DELETE FROM calendar_sources WHERE user_id=? AND id=?", (user_id, source_id)) db.commit() db.close() return cur.rowcount > 0 def get_events(group_ids: list = None, days: int = 7, user_id: int = None): """Get events for enabled groups, limited to N days ahead.""" if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) now = tz_now().strftime("%Y%m%dT000000") end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959") if group_ids: placeholders = ','.join('?' * len(group_ids)) rows = db.execute(f""" SELECT e.*, s.group_id FROM calendar_events e JOIN calendar_sources s ON e.source_id = s.id JOIN calendar_groups g ON s.group_id = g.id WHERE e.user_id=? AND g.user_id=? AND g.enabled=1 AND s.group_id IN ({placeholders}) AND e.dtend >= ? AND e.dtstart <= ? ORDER BY e.dtstart """, (user_id, user_id, *group_ids, now, end)).fetchall() else: rows = db.execute(""" SELECT e.*, s.group_id FROM calendar_events e JOIN calendar_sources s ON e.source_id = s.id JOIN calendar_groups g ON s.group_id = g.id WHERE e.user_id=? AND g.user_id=? AND g.enabled=1 AND e.dtend >= ? AND e.dtstart <= ? ORDER BY e.dtstart """, (user_id, user_id, now, end)).fetchall() db.close() return [dict(r) for r in rows] def get_upcoming_reminders(user_id: int = None): """Check for calendar events whose reminders are due.""" if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) now = tz_now() rows = db.execute(""" SELECT e.*, r.minutes_before, g.name as group_name FROM calendar_events e JOIN calendar_sources s ON e.source_id = s.id JOIN calendar_groups g ON s.group_id = g.id JOIN reminders r ON r.calendar_group_id = g.id WHERE e.user_id=? AND g.user_id=? AND g.enabled=1 AND r.enabled=1 """, (user_id, user_id)).fetchall() db.close() due = [] for r in rows: try: dt = datetime.strptime(r["dtstart"], "%Y%m%dT%H%M%S") remind_at = dt - timedelta(minutes=r["minutes_before"]) if remind_at <= now < dt: due.append(dict(r)) except (ValueError, TypeError): pass return due def set_reminder(group_id: int, minutes_before: int, enabled: bool = True, user_id: int = None): if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) if not _require_group_owner(db, user_id, group_id): db.close() raise ValueError("分组不存在或无权访问") existing = db.execute("SELECT id FROM reminders WHERE user_id=? AND calendar_group_id=?", (user_id, group_id)).fetchone() if existing: db.execute("UPDATE reminders SET minutes_before=?, enabled=? WHERE user_id=? AND calendar_group_id=?", (minutes_before, 1 if enabled else 0, user_id, group_id)) else: db.execute("INSERT INTO reminders (user_id, calendar_group_id, minutes_before, enabled) " "VALUES (?, ?, ?, ?)", (user_id, group_id, minutes_before, 1 if enabled else 0)) db.commit() db.close() def get_or_create_manual_source(group_id: int, user_id: int = None) -> int: """Get or create a 'manual' source for the group.""" if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) row = db.execute( "SELECT id FROM calendar_sources WHERE user_id=? AND group_id=? AND type='manual'", (user_id, group_id) ).fetchone() if row: db.close() return row["id"] cur = db.execute( "INSERT INTO calendar_sources (user_id, group_id, type, name) VALUES (?, ?, 'manual', '手动添加')", (user_id, group_id) ) db.commit() sid = cur.lastrowid db.close() return sid def _normalize_dt(dt_str: str) -> str: """Ensure date string has the 'T' separator if it looks like YYYYMMDDHHMMSS.""" if not dt_str or 'T' in dt_str: return dt_str if len(dt_str) >= 14: return dt_str[:8] + 'T' + dt_str[8:] return dt_str def add_event(group_id: int, summary: str, dtstart: str, dtend: str = None, description: str = "", location: str = "", is_all_day: bool = False, user_id: int = None) -> dict: """Manually add a single event to a group.""" if user_id is None: user_id = _uid() dtstart = _normalize_dt(dtstart) dtend = _normalize_dt(dtend) db = get_db(current_app.config["DB_PATH"]) if not _require_group_owner(db, user_id, group_id): db.close() raise ValueError("分组不存在或无权访问") source_id = get_or_create_manual_source(group_id, user_id) if dtend is None: dtend = dtstart cur = db.execute(""" INSERT INTO calendar_events (user_id, source_id, summary, dtstart, dtend, description, location, is_all_day) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (user_id, source_id, summary, dtstart, dtend, description, location, 1 if is_all_day else 0)) db.commit() eid = cur.lastrowid row = dict(db.execute("SELECT * FROM calendar_events WHERE id=?", (eid,)).fetchone()) db.close() current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id) return row def get_events_by_group(group_id: int, days: int = 30, user_id: int = None) -> list: """Get all events for a specific group.""" if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) now = tz_now().strftime("%Y%m%dT000000") end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959") rows = db.execute(""" SELECT e.*, s.group_id FROM calendar_events e JOIN calendar_sources s ON e.source_id = s.id WHERE e.user_id=? AND s.user_id=? AND s.group_id=? AND e.dtend >= ? AND e.dtstart <= ? ORDER BY e.dtstart """, (user_id, user_id, group_id, now, end)).fetchall() db.close() return [dict(r) for r in rows] def update_event(event_id: int, summary: str = None, dtstart: str = None, dtend: str = None, description: str = None, location: str = None, user_id: int = None) -> bool: if user_id is None: user_id = _uid() dtstart = _normalize_dt(dtstart) dtend = _normalize_dt(dtend) db = get_db(current_app.config["DB_PATH"]) fields = [] vals = [] for key, val in [("summary", summary), ("dtstart", dtstart), ("dtend", dtend), ("description", description), ("location", location)]: if val is not None: fields.append(f"{key}=?") vals.append(val) if not fields: db.close() return False vals.append(user_id) vals.append(event_id) cur = db.execute(f"UPDATE calendar_events SET {', '.join(fields)} WHERE user_id=? AND id=?", vals) db.commit() ok = cur.rowcount > 0 # Get group_id for event broadcast row = db.execute(""" SELECT s.group_id FROM calendar_events e JOIN calendar_sources s ON e.source_id = s.id WHERE e.user_id=? AND e.id=? """, (user_id, event_id)).fetchone() db.close() if ok and row: current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id) return ok def delete_event(event_id: int, user_id: int = None) -> bool: if user_id is None: user_id = _uid() db = get_db(current_app.config["DB_PATH"]) row = db.execute(""" SELECT s.group_id FROM calendar_events e JOIN calendar_sources s ON e.source_id = s.id WHERE e.user_id=? AND e.id=? """, (user_id, event_id)).fetchone() cur = db.execute("DELETE FROM calendar_events WHERE user_id=? AND id=?", (user_id, event_id)) db.commit() db.close() if cur.rowcount > 0 and row: current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id) return cur.rowcount > 0 def _parse_and_store_events(db, user_id: int, source_id: int, ics_data: str): """Parse ICS content and store events in DB, expanding recurring events.""" from icalendar import Calendar import recurring_ical_events try: cal = Calendar.from_ical(ics_data) except Exception: return # Determine the expansion window: from start of today up to 365 days ahead today = tz_now().date() window_start = datetime(today.year, today.month, today.day) window_end = window_start + timedelta(days=365) try: expanded = recurring_ical_events.of(cal).between(window_start, window_end) except Exception: expanded = [] for comp in expanded: if comp.name != "VEVENT": continue uid = str(comp.get("uid", "")) summary = str(comp.get("summary", "")) description = str(comp.get("description", "")) location = str(comp.get("location", "")) dtstart = comp.get("dtstart") dtend = comp.get("dtend") if dtstart is None: continue dtstart_str = _format_dt(dtstart.dt) if dtend: dtend_str = _format_dt(dtend.dt) else: dtend_str = dtstart_str is_all_day = 1 if (hasattr(dtstart.dt, 'date') and not hasattr(dtstart.dt, 'time')) else 0 db.execute(""" INSERT INTO calendar_events (user_id, source_id, uid, summary, description, location, dtstart, dtend, is_all_day) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, (user_id, source_id, uid, summary, description, location, dtstart_str, dtend_str, is_all_day)) db.commit() def _format_dt(dt_val) -> str: """Format a datetime/date to the 'YYYYMMDDTHHMMSS' string.""" from datetime import date, datetime as dt_cls if isinstance(dt_val, dt_cls): return dt_val.strftime("%Y%m%dT%H%M%S") elif isinstance(dt_val, date): return dt_val.strftime("%Y%m%dT000000") return str(dt_val)