diff --git a/backend/backup/archive.py b/backend/backup/archive.py index ca928d7..5b82bd8 100644 --- a/backend/backup/archive.py +++ b/backend/backup/archive.py @@ -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: diff --git a/backend/backup/service.py b/backend/backup/service.py index 42ad0cc..2dd0718 100644 --- a/backend/backup/service.py +++ b/backend/backup/service.py @@ -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)) diff --git a/backend/calendar.py b/backend/calendar.py new file mode 100644 index 0000000..40bf9de --- /dev/null +++ b/backend/calendar.py @@ -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"])) diff --git a/backend/calendar_router.py b/backend/calendar_router.py new file mode 100644 index 0000000..bd16e24 --- /dev/null +++ b/backend/calendar_router.py @@ -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} diff --git a/backend/main.py b/backend/main.py index b538bcf..5da7cca 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/models.py b/backend/models.py index cd11619..67a4d33 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/migrations/versions/0020_calendar_subscriptions.py b/migrations/versions/0020_calendar_subscriptions.py new file mode 100644 index 0000000..2a55fba --- /dev/null +++ b/migrations/versions/0020_calendar_subscriptions.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 44f6b06..7c5c48b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/test_calendar_backup_compat.py b/tests/test_calendar_backup_compat.py new file mode 100644 index 0000000..144720d --- /dev/null +++ b/tests/test_calendar_backup_compat.py @@ -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) diff --git a/tests/test_calendar_subscriptions.py b/tests/test_calendar_subscriptions.py new file mode 100644 index 0000000..b0b8749 --- /dev/null +++ b/tests/test_calendar_subscriptions.py @@ -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://user@example.com/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 diff --git a/uv.lock b/uv.lock index 4aaf671..d50b4e1 100644 --- a/uv.lock +++ b/uv.lock @@ -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"