First Push

This commit is contained in:
喆OvO
2026-08-06 11:11:11 +08:00
commit 0fef309972
551 changed files with 15793 additions and 0 deletions
+471
View File
@@ -0,0 +1,471 @@
"""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)