[verified] feat: add external calendar subscriptions

This commit is contained in:
2026-09-20 12:29:21 +08:00
parent d7014c4f4f
commit 583b6a8a9d
11 changed files with 849 additions and 2 deletions
+17 -1
View File
@@ -152,6 +152,18 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
content_names = set(names) - {"manifest.json"}
if set(checksums) != content_names:
raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致")
# Calendar subscriptions are an additive backup-v2 entity. Accept archives
# produced by older helpers that checksum the new file but omit its count.
optional_entities = {"calendar_subscriptions"}
undeclared_optional = {
f"data/{name}.json" for name in optional_entities - set(declared_entities)
}
if set(declared_entities) != {
name[5:-5]
for name in content_names - undeclared_optional
if name.startswith("data/") and name.endswith(".json")
}:
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
entities: dict[str, list[dict]] = {}
files: dict[str, StagedBlob] = {}
for index, name in enumerate(sorted(content_names)):
@@ -170,7 +182,11 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
if not isinstance(checksums[name], str) or actual_digest != checksums[name]:
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
if set(declared_entities) != set(entities):
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
required_declared = set(entities) - optional_entities
if set(declared_entities) != required_declared or any(
entities.get(name) for name in optional_entities - set(declared_entities)
):
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
if any(type(count) is not int or count < 0 or count != len(entities[name]) for name, count in declared_entities.items()):
raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致")
except HTTPException:
+15 -1
View File
@@ -19,6 +19,7 @@ from backend.models import (
BackupImport,
BackupImportEntity,
BackupPreflight,
CalendarSubscription,
Countdown,
Folder,
Habit,
@@ -65,6 +66,7 @@ ENTITY_MODELS = {
"habit_pauses": HabitPause,
"countdowns": Countdown,
"memos": Memo,
"calendar_subscriptions": CalendarSubscription,
"attachments": Attachment,
}
RELATIONS = {
@@ -392,6 +394,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
def validate_archive(parsed: ParsedArchive) -> None:
unknown = set(parsed.entities) - set(ENTITY_MODELS)
# Calendar subscriptions were introduced after backup v2. Treat their
# absence as an empty collection so archives from older Dodo releases remain restorable.
parsed.entities.setdefault("calendar_subscriptions", [])
missing = set(ENTITY_MODELS) - set(parsed.entities)
if unknown:
raise backup_error("backup_entity_unknown", "备份包含未知实体")
@@ -611,7 +616,16 @@ async def restore_v2(
await db.execute(delete(HabitPause).where(
HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
))
for model in (Attachment, Memo, Countdown, Task, Habit, TaskList, Folder):
for model in (
Attachment,
CalendarSubscription,
Memo,
Countdown,
Task,
Habit,
TaskList,
Folder,
):
await db.execute(delete(model).where(model.user_id == user.id))
await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id))
await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id))
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import http.client
import ipaddress
import socket
import ssl
import urllib.parse
from dataclasses import dataclass
from datetime import UTC, date, datetime, time, timedelta
from itertools import islice
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from dateutil.rrule import rrulestr
from fastapi import HTTPException
from icalendar import Calendar
MAX_ICS_BYTES = 2_000_000
MAX_REDIRECTS = 3
DEFAULT_RECURRENCE_LIMIT = 10_000
TIMEOUT_SECONDS = 10
_ALLOWED_CONTENT_TYPES = {"text/calendar", "text/plain", "application/octet-stream"}
@dataclass(frozen=True)
class FetchResult:
content: bytes | None
etag: str | None
last_modified: str | None
not_modified: bool
def _is_global(value: str) -> bool:
return ipaddress.ip_address(value.split("%", 1)[0]).is_global
def validate_calendar_url(url: str) -> tuple[str, str, int]:
try:
parsed = urllib.parse.urlsplit(url)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
except ValueError as exc:
raise HTTPException(422, "invalid calendar URL") from exc
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
):
raise HTTPException(422, "calendar URL must be HTTP(S) without userinfo or fragment")
try:
infos = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise HTTPException(422, "calendar host cannot be resolved") from exc
addresses = {item[4][0] for item in infos}
if not addresses or not all(_is_global(address) for address in addresses):
raise HTTPException(422, "calendar host must resolve only to public addresses")
return url, min(addresses), port
def _request(url: str, headers: dict[str, str]) -> tuple[int, list[tuple[str, str]], str, bytes]:
_, ip, port = validate_calendar_url(url)
parsed = urllib.parse.urlsplit(url)
target = f"[{ip}]" if ":" in ip else ip
host = parsed.hostname or ""
if parsed.port:
host = f"{host}:{parsed.port}"
request_headers = {"Host": host, "User-Agent": "dodo-calendar-fetch/1.0", **headers}
path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, ""))
connection: http.client.HTTPConnection
if parsed.scheme == "https":
connection = http.client.HTTPSConnection(target, port=port, timeout=TIMEOUT_SECONDS)
else:
connection = http.client.HTTPConnection(target, port=port, timeout=TIMEOUT_SECONDS)
try:
if parsed.scheme == "https":
raw = socket.create_connection((ip, port), timeout=TIMEOUT_SECONDS)
connection.sock = ssl.create_default_context().wrap_socket(
raw, server_hostname=parsed.hostname
)
connection.request("GET", path, headers=request_headers)
response = connection.getresponse()
length = response.getheader("Content-Length")
if length and int(length) > MAX_ICS_BYTES:
raise HTTPException(413, "calendar exceeds 2MB")
body = response.read(MAX_ICS_BYTES + 1)
return response.status, response.getheaders(), response.getheader("Content-Type") or "", body
except HTTPException:
raise
except (OSError, http.client.HTTPException, ValueError) as exc:
raise HTTPException(502, "calendar upstream unavailable") from exc
finally:
connection.close()
def fetch_calendar(url: str, *, etag: str | None = None, last_modified: str | None = None) -> FetchResult:
headers = {}
if etag:
headers["If-None-Match"] = etag
if last_modified:
headers["If-Modified-Since"] = last_modified
current = url
for redirect_count in range(MAX_REDIRECTS + 1):
status, response_headers, content_type, body = _request(current, headers)
header_map = {key.lower(): value for key, value in response_headers}
if status in {301, 302, 303, 307, 308}:
if redirect_count == MAX_REDIRECTS or not header_map.get("location"):
raise HTTPException(502, "calendar redirect limit exceeded")
current = urllib.parse.urljoin(current, header_map["location"])
validate_calendar_url(current)
continue
if status == 304:
return FetchResult(None, etag, last_modified, True)
if status >= 400:
raise HTTPException(502, f"calendar upstream returned HTTP {status}")
if len(body) > MAX_ICS_BYTES:
raise HTTPException(413, "calendar exceeds 2MB")
if content_type.split(";", 1)[0].lower() not in _ALLOWED_CONTENT_TYPES:
raise HTTPException(422, "URL did not return an iCalendar document")
return FetchResult(body, header_map.get("etag"), header_map.get("last-modified"), False)
raise HTTPException(502, "calendar redirect limit exceeded")
def _localize(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]:
if isinstance(value, datetime):
return (value if value.tzinfo else value.replace(tzinfo=timezone)), False
return datetime.combine(value, time.min, tzinfo=timezone), True
def _utc(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]:
localized, all_day = _localize(value, timezone)
return localized.astimezone(UTC), all_day
def _duration(event: Any, starts_at: datetime, all_day: bool, timezone: ZoneInfo) -> timedelta:
if event.get("dtend"):
ends_at, _ = _utc(event.decoded("dtend"), timezone)
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 _exdates(event: Any, timezone: ZoneInfo) -> set[datetime]:
values = event.get("exdate")
if not values:
return set()
excluded = set()
for item in values if isinstance(values, list) else [values]:
for value in getattr(item, "dts", []):
excluded.add(_utc(value.dt, timezone)[0])
return excluded
def _overlaps(start: datetime, end: datetime, window_start: datetime, window_end: datetime) -> bool:
return start < window_end and end > window_start
def _event_dict(event: Any, source: str, color: str, start: datetime, end: datetime, all_day: bool) -> dict:
uid = str(event.get("uid") or "")
title = str(event.get("summary") or "Untitled event").strip() or "Untitled event"
return {
"id": f"{uid or title}:{start.isoformat()}",
"title": title,
"starts_at": start,
"ends_at": end,
"all_day": all_day,
"source_name": source,
"color": color,
}
def parse_ics_events(
content: bytes | str,
source_name: str,
color: str,
window_start: datetime,
window_end: datetime,
timezone_name: str,
*,
recurrence_limit: int = DEFAULT_RECURRENCE_LIMIT,
) -> list[dict]:
try:
timezone = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise ValueError("invalid timezone") from exc
try:
calendar = Calendar.from_ical(content)
except Exception as exc:
raise ValueError("invalid iCalendar document") from exc
components = list(calendar.walk("VEVENT"))
overrides = {}
for event in components:
recurrence_id = event.get("recurrence-id")
if recurrence_id:
overrides[(str(event.get("uid") or ""), _utc(event.decoded("recurrence-id"), timezone)[0])] = event
events = []
for event in components:
if not event.get("dtstart") or event.get("recurrence-id") or str(event.get("status") or "").upper() == "CANCELLED":
continue
local_start, all_day = _localize(event.decoded("dtstart"), timezone)
start = local_start.astimezone(UTC)
duration = _duration(event, start, all_day, timezone)
uid = str(event.get("uid") or "")
if event.get("rrule"):
try:
rule = rrulestr(event.get("rrule").to_ical().decode(), dtstart=local_start)
bounded = rule.xafter(window_start - duration, count=recurrence_limit + 1, inc=True)
occurrences = [item for item in islice(bounded, recurrence_limit + 1) if item < window_end]
except Exception as exc:
raise ValueError("invalid recurrence rule") from exc
if len(occurrences) > recurrence_limit:
raise ValueError("recurrence limit exceeded")
excluded = _exdates(event, timezone)
for occurrence in occurrences:
occurrence = (occurrence if occurrence.tzinfo else occurrence.replace(tzinfo=timezone)).astimezone(UTC)
if occurrence in excluded or (uid, occurrence) in overrides:
continue
end = occurrence + duration
if _overlaps(occurrence, end, window_start, window_end):
events.append(_event_dict(event, source_name, color, occurrence, end, all_day))
else:
end = start + duration
if _overlaps(start, end, window_start, window_end):
events.append(_event_dict(event, source_name, color, start, end, all_day))
for event in overrides.values():
if not event.get("dtstart") or str(event.get("status") or "").upper() == "CANCELLED":
continue
start, all_day = _utc(event.decoded("dtstart"), timezone)
end = start + _duration(event, start, all_day, timezone)
if _overlaps(start, end, window_start, window_end):
events.append(_event_dict(event, source_name, color, start, end, all_day))
return sorted(events, key=lambda item: (item["starts_at"], item["title"], item["id"]))
+223
View File
@@ -0,0 +1,223 @@
from datetime import UTC, datetime, timedelta
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from . import calendar as calendar_service
from .auth import current_user
from .db import get_db
from .models import CalendarSubscription, User, utcnow
router = APIRouter(prefix="/api/v1", tags=["calendar"])
MAX_WINDOW = timedelta(days=366)
class SubscriptionCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
url: str = Field(min_length=1, max_length=2000)
color: str = Field(default="#f15a29", pattern=r"^#[0-9A-Fa-f]{6}$")
enabled: bool = True
@field_validator("name")
@classmethod
def clean_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
class SubscriptionUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
url: str | None = Field(default=None, min_length=1, max_length=2000)
color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$")
enabled: bool | None = None
@field_validator("name")
@classmethod
def clean_name(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
@model_validator(mode="after")
def reject_nulls(self):
for field in self.model_fields_set:
if getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
return self
class SubscriptionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
url: str
color: str
enabled: bool
refreshed_at: datetime | None
last_error: str | None
stale: bool
def _out(row: CalendarSubscription) -> dict:
return {
"id": row.id,
"name": row.name,
"url": row.url,
"color": row.color,
"enabled": row.enabled,
"refreshed_at": row.refreshed_at,
"last_error": row.last_error,
"stale": bool(row.last_error and row.ics_cache),
}
async def _owned(db: AsyncSession, user_id: UUID, subscription_id: UUID) -> CalendarSubscription:
row = await db.scalar(select(CalendarSubscription).where(
CalendarSubscription.id == subscription_id,
CalendarSubscription.user_id == user_id,
))
if row is None:
raise HTTPException(404, "calendar subscription not found")
return row
async def _refresh(db: AsyncSession, row: CalendarSubscription) -> None:
try:
result = await __import__("asyncio").to_thread(
calendar_service.fetch_calendar, row.url, etag=row.etag, last_modified=row.last_modified
)
if result.not_modified:
if not row.ics_cache:
raise HTTPException(502, "calendar returned not modified without cache")
elif result.content is not None:
# Parse before replacing a known-good cache.
calendar_service.parse_ics_events(
result.content,
row.name,
row.color,
datetime.now(UTC) - timedelta(days=1),
datetime.now(UTC) + timedelta(days=1),
"UTC",
)
row.ics_cache = result.content.decode("utf-8-sig")
row.etag = result.etag
row.last_modified = result.last_modified
row.refreshed_at = utcnow()
row.last_error = None
except Exception as exc:
row.last_error = exc.detail if isinstance(exc, HTTPException) else str(exc)
if not row.ics_cache:
await db.rollback()
raise HTTPException(502, row.last_error) from exc
await db.commit()
@router.get("/calendar-subscriptions", response_model=list[SubscriptionOut])
async def list_subscriptions(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
rows = (await db.scalars(select(CalendarSubscription).where(
CalendarSubscription.user_id == user.id
).order_by(CalendarSubscription.created_at, CalendarSubscription.id))).all()
return [_out(row) for row in rows]
@router.post("/calendar-subscriptions", response_model=SubscriptionOut, status_code=201)
async def create_subscription(
payload: SubscriptionCreate,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
calendar_service.validate_calendar_url(payload.url)
row = CalendarSubscription(user_id=user.id, **payload.model_dump())
db.add(row)
await db.flush()
await _refresh(db, row)
await db.refresh(row)
return _out(row)
@router.patch("/calendar-subscriptions/{subscription_id}", response_model=SubscriptionOut)
async def update_subscription(
subscription_id: UUID,
payload: SubscriptionUpdate,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
row = await _owned(db, user.id, subscription_id)
changes = payload.model_dump(exclude_unset=True)
if "url" in changes:
calendar_service.validate_calendar_url(changes["url"])
if changes["url"] != row.url:
row.ics_cache = row.etag = row.last_modified = row.refreshed_at = row.last_error = None
for key, value in changes.items():
setattr(row, key, value)
await db.commit()
await db.refresh(row)
return _out(row)
@router.delete("/calendar-subscriptions/{subscription_id}", status_code=204)
async def delete_subscription(
subscription_id: UUID,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
row = await _owned(db, user.id, subscription_id)
await db.delete(row)
await db.commit()
return Response(status_code=204)
@router.post("/calendar-subscriptions/{subscription_id}/refresh", response_model=SubscriptionOut)
async def refresh_subscription(
subscription_id: UUID,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
row = await _owned(db, user.id, subscription_id)
await _refresh(db, row)
await db.refresh(row)
return _out(row)
@router.get("/calendar-events")
async def calendar_events(
start: datetime = Query(),
end: datetime = Query(),
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
if start.tzinfo is None or end.tzinfo is None or end <= start or end - start > MAX_WINDOW:
raise HTTPException(422, "start/end must be timezone-aware and span at most 366 days")
try:
ZoneInfo(user.timezone)
except ZoneInfoNotFoundError as exc:
raise HTTPException(422, "user timezone is invalid") from exc
rows = (await db.scalars(select(CalendarSubscription).where(
CalendarSubscription.user_id == user.id,
CalendarSubscription.enabled.is_(True),
).order_by(CalendarSubscription.created_at, CalendarSubscription.id))).all()
events = []
sources = []
for row in rows:
if not row.ics_cache:
await _refresh(db, row)
try:
parsed = calendar_service.parse_ics_events(
row.ics_cache or "", row.name, row.color, start, end, user.timezone
)
events.extend(parsed)
except ValueError as exc:
row.last_error = str(exc)
await db.commit()
sources.append({"id": row.id, "name": row.name, "stale": bool(row.last_error)})
events.sort(key=lambda item: (item["starts_at"], item["title"], item["id"]))
return {"events": events, "sources": sources}
+2
View File
@@ -30,6 +30,7 @@ from .auth import (
verify_password,
)
from .backup import router as backup_router
from .calendar_router import router as calendar_router
from .db import create_schema, get_db
from .models import (
AppState,
@@ -120,6 +121,7 @@ async def openapi(_: User = Depends(current_user)):
app.include_router(mvp_router)
app.include_router(backup_router)
app.include_router(calendar_router)
logger = logging.getLogger(__name__)
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
+17
View File
@@ -301,6 +301,23 @@ class BackupImportEntity(Base):
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
class CalendarSubscription(Base):
__tablename__ = "calendar_subscriptions"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(120))
url: Mapped[str] = mapped_column(Text)
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
ics_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
etag: Mapped[str | None] = mapped_column(String(512), nullable=True)
last_modified: Mapped[str | None] = mapped_column(String(512), nullable=True)
refreshed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow, onupdate=utcnow)
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
@@ -0,0 +1,42 @@
"""restore persistent external calendar subscriptions
Revision ID: 0020_calendar_subscriptions
Revises: 0019_backup_imports
"""
import sqlalchemy as sa
from alembic import op
revision = "0020_calendar_subscriptions"
down_revision = "0019_backup_imports"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"calendar_subscriptions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("color", sa.String(length=32), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("ics_cache", sa.Text(), nullable=True),
sa.Column("etag", sa.String(length=512), nullable=True),
sa.Column("last_modified", sa.String(length=512), nullable=True),
sa.Column("refreshed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_calendar_subscriptions_user_id", "calendar_subscriptions", ["user_id"]
)
def downgrade() -> None:
op.drop_index("ix_calendar_subscriptions_user_id", table_name="calendar_subscriptions")
op.drop_table("calendar_subscriptions")
+2
View File
@@ -16,6 +16,8 @@ dependencies = [
"structlog>=25,<26",
"lunar-python>=1.2,<2",
"httpx>=0.28,<1",
"icalendar>=6,<7",
"python-dateutil>=2.9,<3",
]
[dependency-groups]
+60
View File
@@ -0,0 +1,60 @@
import asyncio
import json
import zipfile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from backend.backup.archive import parse_archive_path
from backend.backup.service import export_v2, validate_archive
from backend.db import get_engine
from backend.models import CalendarSubscription, User
def initialized(client):
response = client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
assert response.status_code == 201
return client
def test_backup_exports_calendar_subscriptions_and_accepts_old_archive(client, tmp_path):
client = initialized(client)
async def prepare_and_export():
factory = async_sessionmaker(get_engine(), expire_on_commit=False)
async with factory() as db:
user = await db.scalar(select(User))
db.add(CalendarSubscription(
user_id=user.id,
name="Work",
url="https://example.com/work.ics",
color="#123abc",
ics_cache="BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n",
))
await db.commit()
path = await export_v2(db, user)
await db.commit()
return path
path = asyncio.run(prepare_and_export())
try:
parsed = parse_archive_path(path)
assert parsed.entities["calendar_subscriptions"][0]["name"] == "Work"
validate_archive(parsed)
old_path = tmp_path / "old.zip"
with zipfile.ZipFile(path) as source, zipfile.ZipFile(old_path, "w") as target:
manifest = json.loads(source.read("manifest.json"))
manifest["entities"].pop("calendar_subscriptions")
manifest["checksums"].pop("data/calendar_subscriptions.json")
for name in source.namelist():
if name not in {"manifest.json", "data/calendar_subscriptions.json"}:
target.writestr(name, source.read(name))
from backend.backup.archive import canonical_json
target.writestr("manifest.json", canonical_json(manifest))
validate_archive(parse_archive_path(old_path))
finally:
path.unlink(missing_ok=True)
+191
View File
@@ -0,0 +1,191 @@
import socket
from datetime import UTC, datetime
import pytest
from fastapi import HTTPException
from backend.calendar import (
FetchResult,
parse_ics_events,
validate_calendar_url,
)
ICS = b"""BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:one\r
DTSTART:20260920T090000Z\r
DTEND:20260920T100000Z\r
SUMMARY:Meeting\r
END:VEVENT\r
END:VCALENDAR\r
"""
RECURRING_ICS = b"""BEGIN:VCALENDAR\r
VERSION:2.0\r
BEGIN:VEVENT\r
UID:daily\r
DTSTART;TZID=Asia/Shanghai:20260920T090000\r
DTEND;TZID=Asia/Shanghai:20260920T100000\r
RRULE:FREQ=DAILY;COUNT=3\r
EXDATE;TZID=Asia/Shanghai:20260921T090000\r
SUMMARY:Daily\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:daily\r
RECURRENCE-ID;TZID=Asia/Shanghai:20260922T090000\r
DTSTART;TZID=Asia/Shanghai:20260922T110000\r
DTEND;TZID=Asia/Shanghai:20260922T120000\r
SUMMARY:Moved\r
END:VEVENT\r
END:VCALENDAR\r
"""
def initialized(client, username="owner"):
response = client.post(
"/api/v1/setup/initialize",
json={"username": username, "password": "correct horse battery staple"},
)
assert response.status_code == 201
return client
def test_parser_restored_with_recurrence_exdates_overrides_and_timezone():
events = parse_ics_events(
RECURRING_ICS,
"Work",
"#123456",
datetime(2026, 9, 19, tzinfo=UTC),
datetime(2026, 9, 24, tzinfo=UTC),
"Asia/Shanghai",
)
assert [(event["title"], event["starts_at"].isoformat()) for event in events] == [
("Daily", "2026-09-20T01:00:00+00:00"),
("Moved", "2026-09-22T03:00:00+00:00"),
]
def test_parser_limits_recurrence_expansion():
endless = ICS.replace(b"UID:one", b"UID:one\r\nRRULE:FREQ=SECONDLY")
with pytest.raises(ValueError, match="recurrence limit"):
parse_ics_events(
endless,
"x",
"#000000",
datetime(2026, 9, 20, tzinfo=UTC),
datetime(2026, 9, 21, tzinfo=UTC),
"UTC",
recurrence_limit=10,
)
def test_url_validation_rejects_fragments_userinfo_and_mixed_dns(monkeypatch):
with pytest.raises(HTTPException):
validate_calendar_url("https://example.com/a.ics#secret")
with pytest.raises(HTTPException):
validate_calendar_url("https://[email protected]/a.ics")
monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443)),
])
with pytest.raises(HTTPException, match="public"):
validate_calendar_url("https://example.com/a.ics")
def test_subscription_crud_refresh_events_and_stale_cache(client, monkeypatch):
client = initialized(client)
monkeypatch.setattr(
"backend.calendar.validate_calendar_url",
lambda url: (url, "93.184.216.34", 443),
)
calls = []
def fetch(url, *, etag=None, last_modified=None):
calls.append((url, etag, last_modified))
if len(calls) == 1:
return FetchResult(ICS, '"v1"', "Sun, 20 Sep 2026 00:00:00 GMT", False)
raise HTTPException(502, "upstream down")
monkeypatch.setattr("backend.calendar.fetch_calendar", fetch)
created = client.post(
"/api/v1/calendar-subscriptions",
json={"name": " Work ", "url": "https://example.com/work.ics", "color": "#123abc"},
)
assert created.status_code == 201
body = created.json()
assert body["name"] == "Work"
assert body["enabled"] is True
assert body["stale"] is False
assert calls == [("https://example.com/work.ics", None, None)]
listed = client.get("/api/v1/calendar-subscriptions")
assert [item["id"] for item in listed.json()] == [body["id"]]
events = client.get(
"/api/v1/calendar-events",
params={"start": "2026-09-20T00:00:00Z", "end": "2026-09-21T00:00:00Z"},
)
assert events.status_code == 200
assert events.json()["events"][0]["title"] == "Meeting"
assert events.json()["sources"][0]["stale"] is False
refreshed = client.post(f"/api/v1/calendar-subscriptions/{body['id']}/refresh")
assert refreshed.status_code == 200
assert refreshed.json()["stale"] is True
assert refreshed.json()["last_error"] == "upstream down"
assert calls[1][1:] == ('"v1"', "Sun, 20 Sep 2026 00:00:00 GMT")
patched = client.patch(
f"/api/v1/calendar-subscriptions/{body['id']}",
json={"name": "Personal", "enabled": False, "color": "#abcdef"},
)
assert patched.status_code == 200
assert patched.json()["name"] == "Personal"
assert patched.json()["enabled"] is False
assert client.delete(f"/api/v1/calendar-subscriptions/{body['id']}").status_code == 204
def test_events_validate_window_and_disabled_sources_are_skipped(client, monkeypatch):
client = initialized(client)
monkeypatch.setattr(
"backend.calendar.validate_calendar_url",
lambda url: (url, "93.184.216.34", 443),
)
monkeypatch.setattr(
"backend.calendar.fetch_calendar",
lambda *args, **kwargs: FetchResult(ICS, None, None, False),
)
sub = client.post(
"/api/v1/calendar-subscriptions",
json={"name": "x", "url": "https://example.com/x.ics", "enabled": False},
).json()
assert sub["enabled"] is False
response = client.get(
"/api/v1/calendar-events",
params={"start": "2026-09-21T00:00:00Z", "end": "2026-09-20T00:00:00Z"},
)
assert response.status_code == 422
valid = client.get(
"/api/v1/calendar-events",
params={"start": "2026-09-20T00:00:00Z", "end": "2026-09-21T00:00:00Z"},
)
assert valid.json() == {"events": [], "sources": []}
def test_subscription_ownership_is_strict(client, monkeypatch):
client = initialized(client)
monkeypatch.setattr(
"backend.calendar.validate_calendar_url",
lambda url: (url, "93.184.216.34", 443),
)
monkeypatch.setattr(
"backend.calendar.fetch_calendar",
lambda *args, **kwargs: FetchResult(ICS, None, None, False),
)
sub = client.post(
"/api/v1/calendar-subscriptions",
json={"name": "private", "url": "https://example.com/private.ics"},
).json()
client.post("/api/v1/auth/logout")
assert client.patch(f"/api/v1/calendar-subscriptions/{sub['id']}", json={"name": "x"}).status_code == 401
assert client.delete(f"/api/v1/calendar-subscriptions/{sub['id']}").status_code == 401
Generated
+47
View File
@@ -277,8 +277,10 @@ dependencies = [
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "icalendar" },
{ name = "lunar-python" },
{ name = "pydantic-settings" },
{ name = "python-dateutil" },
{ name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "structlog" },
@@ -301,8 +303,10 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.30,<1" },
{ name = "fastapi", specifier = ">=0.116,<1" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "icalendar", specifier = ">=6,<7" },
{ name = "lunar-python", specifier = ">=1.2,<2" },
{ name = "pydantic-settings", specifier = ">=2.10,<3" },
{ name = "python-dateutil", specifier = ">=2.9,<3" },
{ name = "python-multipart", specifier = ">=0.0.20,<1" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" },
{ name = "structlog", specifier = ">=25,<26" },
@@ -474,6 +478,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "icalendar"
version = "6.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/70/458092b3e7c15783423fe64d07e63ea3311a597e723be6a1060513e3db93/icalendar-6.3.2.tar.gz", hash = "sha256:e0c10ecbfcebe958d33af7d491f6e6b7580d11d475f2eeb29532d0424f9110a1", size = 178422 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/ee/2ff96bb5bd88fe03ab90aedf5180f96dc0f3ae4648ca264b473055bcaaff/icalendar-6.3.2-py3-none-any.whl", hash = "sha256:d400e9c9bb8c025e5a3c77c236941bb690494be52528a0b43cc7e8b7c9505064", size = 242403 },
]
[[package]]
name = "idna"
version = "3.19"
@@ -742,6 +759,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
@@ -831,6 +860,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850 },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
@@ -914,6 +952,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750 },
]
[[package]]
name = "tzdata"
version = "2026.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494 },
]
[[package]]
name = "uuid-utils"
version = "0.17.0"