Theme: - Export/import themes via UI as <date>_<time>.theme (JSON, readable keys, link to THEME.md). Import is partial-aware: only present params are written (server accepts partial); prompts before importing files with unknown params. - Dynamic favicon + theme-color tinted to the primary colour on load/save. - New themable colours: general hover-highlight, day hover/selected/bg, today background, plus two unified sidebar action-icon colours (inactive/active) covering bell, hide, delete and read-only icons. - All new colours are per-setting syncable; documented in THEME.md. UX: - Styled confirm dialog (#modal-confirm) replaces window.confirm() for calendar delete and account disconnect. - Birthday/local calendars and iCal subscriptions can now be hidden from the sidebar via Settings (new sidebar_hidden column + hide toggle). Backend: additive nullable columns + idempotent migrations for user_settings colours and local_calendars/ical_subscriptions.sidebar_hidden. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
426 lines
12 KiB
Python
426 lines
12 KiB
Python
import ipaddress
|
|
import logging
|
|
import socket
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from urllib.parse import urljoin, urlparse
|
|
|
|
import requests as http_requests
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
import models
|
|
from auth import get_current_user
|
|
from caldav_client import _parse_ics
|
|
from database import get_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
class SubscriptionCreate(BaseModel):
|
|
name: str
|
|
url: str
|
|
color: str = "#46bdc6"
|
|
refresh_minutes: int = 60
|
|
|
|
|
|
class SubscriptionUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
url: Optional[str] = None
|
|
color: Optional[str] = None
|
|
enabled: Optional[bool] = None
|
|
sidebar_hidden: Optional[bool] = None
|
|
refresh_minutes: Optional[int] = None
|
|
reminders_enabled: Optional[bool] = None
|
|
|
|
|
|
def _sub_dict(sub: models.ICalSubscription) -> dict:
|
|
return {
|
|
"id": sub.id,
|
|
"name": sub.name,
|
|
"url": sub.url,
|
|
"color": sub.color,
|
|
"enabled": sub.enabled,
|
|
"sidebar_hidden": bool(sub.sidebar_hidden),
|
|
"reminders_enabled": bool(sub.reminders_enabled),
|
|
"refresh_minutes": sub.refresh_minutes,
|
|
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
|
|
}
|
|
|
|
|
|
_MAX_ICS_BYTES = 5 * 1024 * 1024
|
|
_MAX_REDIRECTS = 5
|
|
|
|
|
|
def _host_is_public(host: str) -> bool:
|
|
"""False if the host resolves to any private/loopback/link-local address."""
|
|
try:
|
|
infos = socket.getaddrinfo(host, None)
|
|
except socket.gaierror:
|
|
return False
|
|
for info in infos:
|
|
try:
|
|
ip = ipaddress.ip_address(info[4][0])
|
|
except ValueError:
|
|
return False
|
|
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
|
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _validate_public_url(url: str) -> None:
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ("http", "https"):
|
|
raise ValueError("Nur http(s)-URLs sind erlaubt.")
|
|
if not parsed.hostname:
|
|
raise ValueError("Ungültige URL.")
|
|
if not _host_is_public(parsed.hostname):
|
|
raise ValueError("Interne/private Adressen sind nicht erlaubt.")
|
|
|
|
|
|
def _fetch_ics(url: str) -> str:
|
|
"""Download .ics content, blocking SSRF to internal/private hosts.
|
|
|
|
Redirects are followed manually so every hop is re-validated (a public URL
|
|
can otherwise 30x-redirect into the internal network). Response size is
|
|
capped. (Residual risk: DNS rebinding between check and connect.)
|
|
"""
|
|
if url.startswith("webcal://"):
|
|
url = "https://" + url[len("webcal://"):]
|
|
try:
|
|
for _ in range(_MAX_REDIRECTS + 1):
|
|
_validate_public_url(url)
|
|
resp = http_requests.get(url, timeout=30, allow_redirects=False, stream=True)
|
|
if resp.is_redirect and resp.headers.get("location"):
|
|
url = urljoin(url, resp.headers["location"])
|
|
resp.close()
|
|
continue
|
|
resp.raise_for_status()
|
|
resp.encoding = "utf-8"
|
|
chunks, total = [], 0
|
|
for chunk in resp.iter_content(8192, decode_unicode=True):
|
|
if not chunk:
|
|
continue
|
|
chunks.append(chunk)
|
|
total += len(chunk)
|
|
if total > _MAX_ICS_BYTES:
|
|
resp.close()
|
|
raise ValueError("Datei zu groß (max. 5 MB).")
|
|
return "".join(chunks)
|
|
raise ValueError("Zu viele Weiterleitungen.")
|
|
except http_requests.RequestException as e:
|
|
raise ValueError(f"Fehler beim Abrufen der URL: {e}")
|
|
|
|
|
|
def _refresh_if_needed(sub: models.ICalSubscription, db: Session, force: bool = False):
|
|
"""Refresh cached ICS data if stale or forced."""
|
|
now = datetime.now(timezone.utc)
|
|
if (
|
|
not force
|
|
and sub.cached_ics
|
|
and sub.last_fetched
|
|
and (now - sub.last_fetched.replace(tzinfo=timezone.utc)).total_seconds()
|
|
< sub.refresh_minutes * 60
|
|
):
|
|
return # Cache still fresh
|
|
|
|
try:
|
|
sub.cached_ics = _fetch_ics(sub.url)
|
|
sub.last_fetched = now
|
|
db.commit()
|
|
except ValueError:
|
|
logger.warning("Could not refresh iCal subscription %s", sub.id)
|
|
if not sub.cached_ics:
|
|
raise
|
|
|
|
|
|
def get_events_for_subscription(
|
|
sub: models.ICalSubscription, start_dt: datetime, end_dt: datetime, db: Session = None
|
|
) -> list:
|
|
"""Parse cached ICS and filter events within the date range, applying local overrides."""
|
|
if not sub.cached_ics:
|
|
return []
|
|
|
|
# Load overrides for this subscription
|
|
overrides = {}
|
|
if db:
|
|
for ov in (
|
|
db.query(models.ICalOverride)
|
|
.filter(models.ICalOverride.subscription_id == sub.id)
|
|
.all()
|
|
):
|
|
overrides[ov.event_uid] = ov
|
|
|
|
all_events = _parse_ics(sub.cached_ics, f"ical://{sub.id}")
|
|
filtered = []
|
|
for ev in all_events:
|
|
uid = ev["id"]
|
|
|
|
# Skip hidden (deleted) events
|
|
ov = overrides.get(uid)
|
|
if ov and ov.hidden:
|
|
continue
|
|
|
|
# Apply overrides
|
|
if ov:
|
|
if ov.title is not None:
|
|
ev["title"] = ov.title
|
|
if ov.start is not None:
|
|
ev["start"] = ov.start
|
|
if ov.end is not None:
|
|
ev["end"] = ov.end
|
|
if ov.all_day is not None:
|
|
ev["allDay"] = ov.all_day
|
|
if ov.location is not None:
|
|
ev["location"] = ov.location
|
|
if ov.description is not None:
|
|
ev["description"] = ov.description
|
|
if ov.color is not None:
|
|
ev["color"] = ov.color
|
|
|
|
try:
|
|
ev_start = ev["start"]
|
|
ev_end = ev["end"]
|
|
if ev["allDay"]:
|
|
if ev_end > start_dt.strftime("%Y-%m-%d") and ev_start < end_dt.strftime("%Y-%m-%d"):
|
|
filtered.append(ev)
|
|
else:
|
|
ev_s = datetime.fromisoformat(ev_start.replace("Z", "+00:00"))
|
|
ev_e = datetime.fromisoformat(ev_end.replace("Z", "+00:00"))
|
|
if ev_s.tzinfo is None:
|
|
ev_s = ev_s.replace(tzinfo=timezone.utc)
|
|
if ev_e.tzinfo is None:
|
|
ev_e = ev_e.replace(tzinfo=timezone.utc)
|
|
if ev_e > start_dt and ev_s < end_dt:
|
|
filtered.append(ev)
|
|
except Exception:
|
|
filtered.append(ev)
|
|
|
|
for ev in filtered:
|
|
ev["calendar_id"] = f"ical-{sub.id}"
|
|
ev["calendar_name"] = sub.name
|
|
ev["calendarColor"] = sub.color
|
|
ev["source"] = "ical"
|
|
|
|
return filtered
|
|
|
|
|
|
# ── Subscription CRUD ─────────────────────────────────────
|
|
|
|
@router.get("/subscriptions")
|
|
def list_subscriptions(
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
subs = (
|
|
db.query(models.ICalSubscription)
|
|
.filter(models.ICalSubscription.user_id == current_user.id)
|
|
.all()
|
|
)
|
|
return [_sub_dict(s) for s in subs]
|
|
|
|
|
|
@router.post("/subscriptions")
|
|
def create_subscription(
|
|
data: SubscriptionCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
# Validate URL by fetching it
|
|
try:
|
|
ics_content = _fetch_ics(data.url)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
sub = models.ICalSubscription(
|
|
user_id=current_user.id,
|
|
name=data.name,
|
|
url=data.url,
|
|
color=data.color,
|
|
refresh_minutes=data.refresh_minutes,
|
|
cached_ics=ics_content,
|
|
last_fetched=datetime.now(timezone.utc),
|
|
)
|
|
db.add(sub)
|
|
db.commit()
|
|
db.refresh(sub)
|
|
return _sub_dict(sub)
|
|
|
|
|
|
@router.put("/subscriptions/{sub_id}")
|
|
def update_subscription(
|
|
sub_id: int,
|
|
data: SubscriptionUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
sub = (
|
|
db.query(models.ICalSubscription)
|
|
.filter(
|
|
models.ICalSubscription.id == sub_id,
|
|
models.ICalSubscription.user_id == current_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not sub:
|
|
raise HTTPException(404, "Subscription not found")
|
|
if data.name is not None:
|
|
sub.name = data.name
|
|
if data.url is not None:
|
|
sub.url = data.url
|
|
if data.color is not None:
|
|
sub.color = data.color
|
|
if data.enabled is not None:
|
|
sub.enabled = data.enabled
|
|
if data.sidebar_hidden is not None:
|
|
sub.sidebar_hidden = data.sidebar_hidden
|
|
if data.refresh_minutes is not None:
|
|
sub.refresh_minutes = data.refresh_minutes
|
|
if data.reminders_enabled is not None:
|
|
sub.reminders_enabled = data.reminders_enabled
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/subscriptions/{sub_id}")
|
|
def delete_subscription(
|
|
sub_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
sub = (
|
|
db.query(models.ICalSubscription)
|
|
.filter(
|
|
models.ICalSubscription.id == sub_id,
|
|
models.ICalSubscription.user_id == current_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not sub:
|
|
raise HTTPException(404, "Subscription not found")
|
|
db.delete(sub)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/subscriptions/{sub_id}/refresh")
|
|
def refresh_subscription(
|
|
sub_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
sub = (
|
|
db.query(models.ICalSubscription)
|
|
.filter(
|
|
models.ICalSubscription.id == sub_id,
|
|
models.ICalSubscription.user_id == current_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not sub:
|
|
raise HTTPException(404, "Subscription not found")
|
|
try:
|
|
_refresh_if_needed(sub, db, force=True)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
return _sub_dict(sub)
|
|
|
|
|
|
# ── Event overrides (edit/delete iCal events locally) ─────
|
|
|
|
class ICalEventUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
start: Optional[str] = None
|
|
end: Optional[str] = None
|
|
allDay: Optional[bool] = None
|
|
location: Optional[str] = None
|
|
description: Optional[str] = None
|
|
color: Optional[str] = None
|
|
|
|
|
|
@router.put("/events/{sub_id}/{event_uid}")
|
|
def update_ical_event(
|
|
sub_id: int,
|
|
event_uid: str,
|
|
data: ICalEventUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
sub = (
|
|
db.query(models.ICalSubscription)
|
|
.filter(
|
|
models.ICalSubscription.id == sub_id,
|
|
models.ICalSubscription.user_id == current_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not sub:
|
|
raise HTTPException(404, "Subscription not found")
|
|
|
|
ov = (
|
|
db.query(models.ICalOverride)
|
|
.filter(
|
|
models.ICalOverride.subscription_id == sub_id,
|
|
models.ICalOverride.event_uid == event_uid,
|
|
)
|
|
.first()
|
|
)
|
|
if not ov:
|
|
ov = models.ICalOverride(subscription_id=sub_id, event_uid=event_uid)
|
|
db.add(ov)
|
|
|
|
if data.title is not None:
|
|
ov.title = data.title
|
|
if data.start is not None:
|
|
ov.start = data.start
|
|
if data.end is not None:
|
|
ov.end = data.end
|
|
if data.allDay is not None:
|
|
ov.all_day = data.allDay
|
|
if data.location is not None:
|
|
ov.location = data.location
|
|
if data.description is not None:
|
|
ov.description = data.description
|
|
if data.color is not None:
|
|
ov.color = data.color
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/events/{sub_id}/{event_uid}")
|
|
def delete_ical_event(
|
|
sub_id: int,
|
|
event_uid: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
sub = (
|
|
db.query(models.ICalSubscription)
|
|
.filter(
|
|
models.ICalSubscription.id == sub_id,
|
|
models.ICalSubscription.user_id == current_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not sub:
|
|
raise HTTPException(404, "Subscription not found")
|
|
|
|
ov = (
|
|
db.query(models.ICalOverride)
|
|
.filter(
|
|
models.ICalOverride.subscription_id == sub_id,
|
|
models.ICalOverride.event_uid == event_uid,
|
|
)
|
|
.first()
|
|
)
|
|
if not ov:
|
|
ov = models.ICalOverride(subscription_id=sub_id, event_uid=event_uid, hidden=True)
|
|
db.add(ov)
|
|
else:
|
|
ov.hidden = True
|
|
db.commit()
|
|
return {"ok": True}
|