diff --git a/backend/backup/archive.py b/backend/backup/archive.py index 5b82bd8..ca928d7 100644 --- a/backend/backup/archive.py +++ b/backend/backup/archive.py @@ -152,18 +152,6 @@ 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)): @@ -182,11 +170,7 @@ 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): - 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 实体清单不一致") + 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 2dd0718..42ad0cc 100644 --- a/backend/backup/service.py +++ b/backend/backup/service.py @@ -19,7 +19,6 @@ from backend.models import ( BackupImport, BackupImportEntity, BackupPreflight, - CalendarSubscription, Countdown, Folder, Habit, @@ -66,7 +65,6 @@ ENTITY_MODELS = { "habit_pauses": HabitPause, "countdowns": Countdown, "memos": Memo, - "calendar_subscriptions": CalendarSubscription, "attachments": Attachment, } RELATIONS = { @@ -394,9 +392,6 @@ 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", "备份包含未知实体") @@ -616,16 +611,7 @@ 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, - CalendarSubscription, - Memo, - Countdown, - Task, - Habit, - TaskList, - Folder, - ): + for model in (Attachment, 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 deleted file mode 100644 index 40bf9de..0000000 --- a/backend/calendar.py +++ /dev/null @@ -1,233 +0,0 @@ -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 deleted file mode 100644 index bd16e24..0000000 --- a/backend/calendar_router.py +++ /dev/null @@ -1,223 +0,0 @@ -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 5da7cca..b538bcf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,6 @@ 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, @@ -121,7 +120,6 @@ 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 67a4d33..cd11619 100644 --- a/backend/models.py +++ b/backend/models.py @@ -301,23 +301,6 @@ 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 deleted file mode 100644 index 2a55fba..0000000 --- a/migrations/versions/0020_calendar_subscriptions.py +++ /dev/null @@ -1,42 +0,0 @@ -"""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 7c5c48b..44f6b06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,6 @@ 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 deleted file mode 100644 index 144720d..0000000 --- a/tests/test_calendar_backup_compat.py +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index b0b8749..0000000 --- a/tests/test_calendar_subscriptions.py +++ /dev/null @@ -1,191 +0,0 @@ -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 d50b4e1..4aaf671 100644 --- a/uv.lock +++ b/uv.lock @@ -277,10 +277,8 @@ 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" }, @@ -303,10 +301,8 @@ 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" }, @@ -478,19 +474,6 @@ 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" @@ -759,18 +742,6 @@ 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" @@ -860,15 +831,6 @@ 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" @@ -952,15 +914,6 @@ 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"