fix: harden calendar subscriptions and add left-swipe reopen
ci / docker (push) Successful in 3m31s

This commit is contained in:
2026-09-06 09:45:00 +08:00
parent b937c8ca72
commit c98223b7d3
12 changed files with 264 additions and 103 deletions
+139 -83
View File
@@ -1,18 +1,21 @@
import asyncio
import csv
import http.client
import io
import ipaddress
import re
import socket
import urllib.error
import ssl
import urllib.parse
import urllib.request
from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path
from uuid import UUID
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse
from icalendar import Calendar
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import case, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -268,97 +271,157 @@ def calendar_subscription_dict(row: CalendarSubscription):
return {"id": row.id, "name": row.name, "url": row.url, "color": row.color, "enabled": row.enabled}
def parse_ics_datetime(value: str):
raw = value.strip()
if len(raw) == 8 and raw.isdigit():
return datetime.strptime(raw, "%Y%m%d").replace(tzinfo=UTC), True
cleaned = raw.removesuffix("Z")
parsed = datetime.fromisoformat(cleaned)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC), False
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
def parse_ics_events(content: str, source_name: str, color: str):
def _is_forbidden_ip(value: str) -> bool:
ip = ipaddress.ip_address(value)
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved
def _as_utc(value, default_tz=LOCAL_TZ):
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=default_tz)
return value.astimezone(UTC), False
if isinstance(value, date):
return datetime.combine(value, time.min, tzinfo=default_tz).astimezone(UTC), True
raise ValueError("unsupported ICS datetime")
def _event_duration(event, starts_at: datetime, all_day: bool):
if event.get("dtend"):
ends_at, _ = _as_utc(event.decoded("dtend"))
return max(ends_at - starts_at, timedelta())
if event.get("duration"):
return event.decoded("duration")
return timedelta(days=1) if all_day else timedelta(hours=1)
def _excluded_starts(event):
excluded = set()
exdates = event.get("exdate")
if not exdates:
return excluded
if not isinstance(exdates, list):
exdates = [exdates]
for exdate in exdates:
for item in getattr(exdate, "dts", []):
excluded.add(_as_utc(item.dt)[0])
return excluded
def _append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day):
uid = str(event.get("uid") or "")
title = str(event.get("summary") or "未命名事件").strip() or "未命名事件"
events.append({
"id": uid or f"{source_name}-{starts_at.isoformat()}-{title}",
"title": title,
"starts_at": starts_at,
"ends_at": ends_at,
"all_day": all_day,
"source_name": source_name,
"color": color,
})
def _overlaps(starts_at: datetime, ends_at: datetime | None, window_start: datetime | None, window_end: datetime | None) -> bool:
if not window_start or not window_end:
return True
return starts_at < window_end and (ends_at or starts_at) > window_start
def parse_ics_events(content: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
calendar = Calendar.from_ical(content)
events = []
current = None
for raw_line in content.splitlines():
line = raw_line.strip()
if line == "BEGIN:VEVENT":
current = {}
for event in calendar.walk("VEVENT"):
if not event.get("dtstart"):
continue
if line == "END:VEVENT":
if current and current.get("title") and current.get("starts_at"):
events.append({
"id": current.get("uid") or f"{source_name}-{current['starts_at'].isoformat()}-{current['title']}",
"title": current["title"],
"starts_at": current["starts_at"],
"ends_at": current.get("ends_at"),
"all_day": current.get("all_day", False),
"source_name": source_name,
"color": color,
})
current = None
starts_at, all_day = _as_utc(event.decoded("dtstart"))
duration = _event_duration(event, starts_at, all_day)
excluded = _excluded_starts(event)
if event.get("rrule") and window_start and window_end:
rule_text = event.get("rrule").to_ical().decode()
rule = rrulestr(rule_text, dtstart=starts_at)
for occurrence in rule.between(window_start - duration, window_end, inc=True):
occurrence = occurrence.astimezone(UTC) if occurrence.tzinfo else occurrence.replace(tzinfo=UTC)
if occurrence in excluded:
continue
ends_at = occurrence + duration
if _overlaps(occurrence, ends_at, window_start, window_end):
_append_calendar_event(events, source_name, color, event, occurrence, ends_at, all_day)
continue
if current is None or ":" not in line:
continue
key, value = line.split(":", 1)
field = key.split(";", 1)[0].upper()
if field == "SUMMARY":
current["title"] = value.strip()
elif field == "UID":
current["uid"] = value.strip()
elif field in {"DTSTART", "DTEND"}:
try:
dt, all_day = parse_ics_datetime(value)
except ValueError:
continue
current["all_day"] = current.get("all_day", False) or all_day
current["starts_at" if field == "DTSTART" else "ends_at"] = dt
ends_at = starts_at + duration
if _overlaps(starts_at, ends_at, window_start, window_end):
_append_calendar_event(events, source_name, color, event, starts_at, ends_at, all_day)
return events
def _validate_public_calendar_url(url: str):
def _validated_calendar_target(url: str):
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise HTTPException(422, "日历订阅只支持 http/https 链接")
if parsed.username or parsed.password:
raise HTTPException(422, "日历订阅链接不能包含账号密码")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
addresses = socket.getaddrinfo(parsed.hostname, None)
addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise HTTPException(422, "日历订阅域名无法解析") from exc
for item in addresses:
ip = ipaddress.ip_address(item[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
raise HTTPException(422, "日历订阅不能指向内网地址")
ip = item[4][0]
if not _is_forbidden_ip(ip):
return parsed, ip, port
raise HTTPException(422, "日历订阅不能指向内网地址")
def fetch_calendar_events(url: str, source_name: str, color: str):
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _validate_public_calendar_url(url: str):
_validated_calendar_target(url)
def _request_pinned_calendar_url(url: str):
parsed, ip, port = _validated_calendar_target(url)
target_host = f"[{ip}]" if ":" in ip else ip
path = urllib.parse.urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, ""))
headers = {"Host": parsed.hostname or "", "User-Agent": "dodo-calendar-fetch/1.0"}
if parsed.port:
headers["Host"] = f"{headers['Host']}:{parsed.port}"
if parsed.scheme == "https":
connection = http.client.HTTPSConnection(target_host, port=port, timeout=15, context=ssl.create_default_context())
else:
connection = http.client.HTTPConnection(target_host, port=port, timeout=15)
try:
if parsed.scheme == "https":
raw = socket.create_connection((ip, port), timeout=15)
sock = ssl.create_default_context().wrap_socket(raw, server_hostname=parsed.hostname)
connection.sock = sock
connection.request("GET", path, headers=headers)
response = connection.getresponse()
body = response.read(2_000_001)
return response.status, response.getheaders(), response.getheader("Content-Type") or "", body
except OSError as exc:
raise HTTPException(502, f"订阅拉取失败: {exc}") from exc
finally:
connection.close()
def fetch_calendar_events(url: str, source_name: str, color: str, window_start: datetime | None = None, window_end: datetime | None = None):
current_url = url
opener = urllib.request.build_opener(NoRedirect)
for _ in range(4):
_validate_public_calendar_url(current_url)
try:
with opener.open(current_url, timeout=15) as response:
content_type = response.headers.get_content_type()
if content_type not in {"text/calendar", "text/plain", "application/octet-stream"}:
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
body = response.read(2_000_001)
if len(body) > 2_000_000:
raise HTTPException(413, "日历订阅内容超过 2MB")
return parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color)
except urllib.error.HTTPError as exc:
if exc.code not in {301, 302, 303, 307, 308}:
raise HTTPException(502, f"订阅拉取失败: HTTP {exc.code}") from exc
location = exc.headers.get("Location")
status, headers, content_type, body = _request_pinned_calendar_url(current_url)
if status in {301, 302, 303, 307, 308}:
location = dict(headers).get("Location")
if not location:
raise HTTPException(502, "订阅重定向缺少目标地址") from exc
raise HTTPException(502, "订阅重定向缺少目标地址")
current_url = urllib.parse.urljoin(current_url, location)
except urllib.error.URLError as exc:
raise HTTPException(502, f"订阅拉取失败: {exc.reason}") from exc
continue
if status >= 400:
raise HTTPException(502, f"订阅拉取失败: HTTP {status}")
if content_type.split(";", 1)[0].lower() not in {"text/calendar", "text/plain", "application/octet-stream"}:
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
if len(body) > 2_000_000:
raise HTTPException(413, "日历订阅内容超过 2MB")
return parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color, window_start, window_end)
raise HTTPException(502, "日历订阅重定向次数过多")
@@ -388,11 +451,11 @@ async def today_calendar_events(
if not rows:
return []
tomorrow = day + timedelta(days=1)
day_start = datetime.combine(day, time.min, tzinfo=UTC)
day_end = datetime.combine(tomorrow, time.min, tzinfo=UTC)
day_start = datetime.combine(day, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
day_end = datetime.combine(tomorrow, time.min, tzinfo=LOCAL_TZ).astimezone(UTC)
async def pull(row: CalendarSubscription):
return await asyncio.to_thread(fetch_calendar_events, row.url, row.name, row.color)
return await asyncio.to_thread(fetch_calendar_events, row.url, row.name, row.color, day_start, day_end)
results = await asyncio.gather(*(pull(row) for row in rows), return_exceptions=True)
events = []
@@ -400,18 +463,11 @@ async def today_calendar_events(
if isinstance(result, Exception):
continue
for item in result:
starts_at = item["starts_at"]
ends_at = item.get("ends_at")
if item["all_day"]:
if not (day_start <= starts_at < day_end):
continue
elif not (day_start <= starts_at < day_end):
continue
events.append({
"id": item["id"],
"title": item["title"],
"starts_at": starts_at.isoformat(),
"ends_at": ends_at.isoformat() if ends_at else None,
"starts_at": item["starts_at"].isoformat(),
"ends_at": item["ends_at"].isoformat() if item.get("ends_at") else None,
"all_day": item["all_day"],
"source_name": row.name,
"color": row.color,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BizE1tvq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BR_rdQ3j.css">
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BWTHcY_k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaaWUARH.css">
</head><body><div id="app"></div></body></html>