Files
icloud-contacts-sync/src/api/main.py
T
2026-08-10 23:55:23 +02:00

711 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
"""FastAPI-App für die interne Web-Ansicht/API der iCloud-Kontakte.
Läuft im selben Image wie der Sync-Container, wird aber über einen
eigenen Docker-Compose-Service mit abweichendem Startbefehl gestartet
(uvicorn statt sync/mailer). Zugriff ausschließlich über einen
vorgeschalteten Reverse-Proxy mit Authelia, der den eingeloggten
Benutzernamen im Remote-User-Header mitschickt."""
import json
import logging
import secrets
from datetime import datetime
from urllib.parse import quote_plus
from fastapi import Depends, FastAPI, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
import db
from api.auth import get_current_user, resolve_account_for_user
from api.schemas import (
ContactListResponse,
ContactOut,
GroupDetailOut,
GroupListResponse,
SyncRunOut,
)
from config import Config
from mailer import build_message, fetch_todays_birthdays_for_account, send_message
from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("api")
app = FastAPI(title="iCloud Contacts Sync Interne API", version="1.0.0")
app.add_middleware(SessionMiddleware, secret_key=secrets.token_hex(32), session_cookie="ics_session")
app.mount("/static", StaticFiles(directory="api/static"), name="static")
templates = Jinja2Templates(directory="api/templates")
templates.env.filters["urlquote"] = lambda s: quote_plus(s or "")
templates.env.filters["fmt_birthday"] = fmt_birthday_short
templates.env.filters["fmt_age"] = fmt_birthday_age
templates.env.filters["has_year"] = lambda b: not is_unknown_year(b)
def _fmt_ts(dt) -> str | None:
if dt is None:
return None
if dt.tzinfo is None:
from datetime import timezone
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(Config.TIMEZONE).strftime("%d.%m.%Y %H:%M:%S")
def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict:
row = dict(row)
for field in ["emails", "phones", "addresses", "urls", "social_profiles", "related_names", "categories"]:
raw = row.get(field)
row[field] = json.loads(raw) if raw else []
if not row.get("full_name"):
row["full_name"] = db._build_full_name(row)
row["updated_at"] = _fmt_ts(row["updated_at"])
row["groups"] = group_names if group_names is not None else []
return row
def _enrich_related_names(conn, contact: dict) -> None:
related = contact.get("related_names", [])
if not related:
return
names = list({r["value"] for r in related if r.get("value")})
if not names:
return
resolved = db.resolve_related_names(conn, contact["account"], names)
for r in related:
name = r.get("value", "")
info = resolved.get(name)
if info:
r["id"] = info["id"]
r["name"] = info["name"]
else:
r["id"] = None
r["name"] = name
def _account_filter_clause(account_name: str | None) -> tuple[str, list]:
if account_name is None:
return "", []
return "WHERE account = %s", [account_name]
def _resolve_effective_account(request: Request, current_user: str) -> tuple[str | None, bool, bool]:
account_name, is_admin = resolve_account_for_user(current_user)
if not is_admin:
return account_name, False, False
show_all = request.session.get("show_all", False)
if show_all:
return None, True, True
return account_name, True, False
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.get("/api/contacts", response_model=ContactListResponse)
def list_contacts(
request: Request,
q: str | None = Query(default=None, description="Freitextsuche über Name, Organisation, E-Mail"),
limit: int = Query(default=50, le=200),
offset: int = Query(default=0, ge=0),
current_user: str = Depends(get_current_user),
):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
search_clause = ""
if q:
search_op = "AND" if where_clause else "WHERE"
search_clause = f" {search_op} (full_name LIKE %s OR given_name LIKE %s OR family_name LIKE %s OR organization LIKE %s OR emails LIKE %s)"
like = f"%{q}%"
params.extend([like, like, like, like, like])
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS total FROM contacts {where_clause}{search_clause}", params)
total = cur.fetchone()["total"]
cur.execute(
f"""SELECT id, account, uid, full_name, given_name, family_name, organization,
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, categories, updated_at
FROM contacts {where_clause}{search_clause}
ORDER BY given_name, family_name, full_name
LIMIT %s OFFSET %s""",
params + [limit, offset],
)
rows = cur.fetchall()
items = [_row_to_contact_out(r) for r in rows]
return {"total": total, "items": items}
@app.get("/api/contacts/{contact_id}", response_model=ContactOut)
def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
id_clause = "AND id = %s" if where_clause else "WHERE id = %s"
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization,
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, related_names, categories, updated_at
FROM contacts {where_clause} {id_clause}""",
params + [contact_id],
)
row = cur.fetchone()
if not row:
return {}
groups = db.get_groups_for_contact(conn, row["account"], row["uid"])
group_names = [g["name"] for g in groups if g.get("name")]
contact = _row_to_contact_out(row, group_names=group_names)
_enrich_related_names(conn, contact)
return contact
@app.get("/api/contacts/birthdays/today", response_model=list[ContactOut])
def birthdays_today(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
today = datetime.now(Config.TIMEZONE).date()
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
month_day_clause = "AND MONTH(birthday) = %s AND DAY(birthday) = %s" if where_clause \
else "WHERE MONTH(birthday) = %s AND DAY(birthday) = %s"
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, uid, full_name, given_name, family_name, organization,
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, categories, updated_at
FROM contacts {where_clause} {month_day_clause}
ORDER BY given_name, family_name, full_name""",
params + [today.month, today.day],
)
rows = cur.fetchall()
return [_row_to_contact_out(r) for r in rows]
@app.get("/api/contacts/birthdays/upcoming")
def birthdays_upcoming(
days: int = Query(default=7, ge=1, le=90),
current_user: str = Depends(get_current_user),
):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
rows = db.get_upcoming_birthdays(conn, account_name, days)
return {"days": days, "items": rows}
@app.get("/api/contacts/count")
def contact_count(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
total = db.get_contact_count(conn, account_name)
return {"total": total}
@app.get("/api/sync-runs", response_model=list[SyncRunOut])
def list_sync_runs(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, sync_type, started_at, finished_at, status,
contacts_upserted, contacts_deleted, error_message
FROM sync_runs {where_clause}
ORDER BY started_at DESC
LIMIT 50""",
params,
)
rows = cur.fetchall()
for r in rows:
r["started_at"] = _fmt_ts(r["started_at"])
r["finished_at"] = _fmt_ts(r["finished_at"])
return rows
@app.get("/api/groups", response_model=GroupListResponse)
def list_groups(
limit: int = Query(default=50, le=200),
offset: int = Query(default=0, ge=0),
current_user: str = Depends(get_current_user),
):
account_name, _ = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS total FROM `groups` {where_clause}", params)
total = cur.fetchone()["total"]
cur.execute(
f"""SELECT g.id, g.account, g.uid, g.name, g.updated_at,
(SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count
FROM `groups` g {where_clause}
ORDER BY g.name
LIMIT %s OFFSET %s""",
params + [limit, offset],
)
rows = cur.fetchall()
for r in rows:
r["updated_at"] = _fmt_ts(r["updated_at"])
return {"total": total, "items": rows}
@app.get("/api/groups/{group_id}", response_model=GroupDetailOut)
def get_group(group_id: int, current_user: str = Depends(get_current_user)):
account_name, _ = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
id_clause = "AND g.id = %s" if where_clause else "WHERE g.id = %s"
with conn.cursor() as cur:
cur.execute(
f"""SELECT g.id, g.account, g.uid, g.name, g.updated_at,
(SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count
FROM `groups` g {where_clause} {id_clause}""",
params + [group_id],
)
group_row = cur.fetchone()
if not group_row:
return {}
cur.execute(
"""SELECT gm.member_uid, c.id, c.full_name, c.given_name, c.family_name
FROM group_members gm
JOIN `groups` g ON g.id = gm.group_id
LEFT JOIN contacts c ON c.account = g.account AND c.uid = gm.member_uid
WHERE g.id = %s
ORDER BY c.given_name, c.family_name, c.full_name""",
(group_id,),
)
members = cur.fetchall()
for m in members:
if not m.get("full_name"):
m["full_name"] = db._build_full_name(m) if any(m.get(k) for k in ("given_name", "family_name")) else None
group_row["updated_at"] = _fmt_ts(group_row["updated_at"])
group_row["members"] = [
{"member_uid": m["member_uid"], "full_name": m["full_name"], "id": m["id"]}
for m in members
]
return group_row
@app.get("/api/groups/{group_id}/members")
def get_group_members(group_id: int, current_user: str = Depends(get_current_user)):
account_name, _ = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(
f"""SELECT g.id, g.account FROM `groups` g {where_clause}
{"AND" if where_clause else "WHERE"} g.id = %s""",
params + [group_id],
)
group = cur.fetchone()
if not group:
return {}
cur.execute(
"""SELECT gm.member_uid, c.id, c.full_name, c.given_name, c.family_name,
c.organization, c.birthday, c.photo_url
FROM group_members gm
JOIN `groups` g ON g.id = gm.group_id
LEFT JOIN contacts c ON c.account = g.account AND c.uid = gm.member_uid
WHERE gm.group_id = %s
ORDER BY c.given_name, c.family_name, c.full_name""",
(group_id,),
)
rows = cur.fetchall()
for r in rows:
if not r.get("full_name"):
r["full_name"] = db._build_full_name(r) if any(r.get(k) for k in ("given_name", "family_name")) else None
return {"group_id": group_id, "members": rows}
@app.get("/", response_class=HTMLResponse)
def web_dashboard(
request: Request,
current_user: str = Depends(get_current_user),
):
account_name, is_admin, show_all = _resolve_effective_account(request, current_user)
with db.get_connection() as conn:
contact_count = db.get_contact_count(conn, account_name)
upcoming_birthdays = db.get_upcoming_birthdays(conn, account_name, 7)
groups = db.get_all_groups(conn, account_name)
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, sync_type, started_at, finished_at, status,
contacts_upserted, contacts_deleted, error_message
FROM sync_runs {where_clause}
ORDER BY started_at DESC
LIMIT 1""",
params,
)
last_sync = cur.fetchone()
change_filter = "AND account = %s" if account_name else ""
change_params = [account_name] if account_name else []
cur.execute(
f"""SELECT started_at, contacts_upserted
FROM sync_runs
WHERE contacts_upserted > 0 {change_filter}
ORDER BY started_at DESC
LIMIT 1""",
change_params,
)
last_sync_with_changes = cur.fetchone()
if last_sync:
last_sync["started_at"] = _fmt_ts(last_sync["started_at"])
last_sync["finished_at"] = _fmt_ts(last_sync["finished_at"])
if last_sync_with_changes:
last_sync_with_changes["started_at"] = _fmt_ts(last_sync_with_changes["started_at"])
if last_sync and last_sync["started_at"] == last_sync_with_changes["started_at"]:
last_sync_with_changes = None
return templates.TemplateResponse(
request,
"dashboard.html",
{
"current_user": current_user,
"is_admin": is_admin,
"show_all": show_all,
"account_name": account_name or "alle Accounts",
"contact_count": contact_count,
"upcoming_birthdays": upcoming_birthdays,
"last_sync": last_sync,
"last_sync_with_changes": last_sync_with_changes,
"groups": groups,
"current_year": datetime.now(Config.TIMEZONE).date().year,
"today": datetime.now(Config.TIMEZONE).date(),
},
)
@app.get("/admin", response_class=HTMLResponse)
def web_admin(
request: Request,
current_user: str = Depends(get_current_user),
):
_, is_admin, _ = _resolve_effective_account(request, current_user)
if not is_admin:
return RedirectResponse(url="/", status_code=303)
show_all = request.session.get("show_all", False)
return templates.TemplateResponse(
request,
"admin.html",
{
"current_user": current_user,
"show_all": show_all,
},
)
@app.post("/admin/toggle")
def admin_toggle(
request: Request,
current_user: str = Depends(get_current_user),
):
_, is_admin, _ = _resolve_effective_account(request, current_user)
if not is_admin:
return RedirectResponse(url="/", status_code=303)
show_all = request.session.get("show_all", False)
request.session["show_all"] = not show_all
return RedirectResponse(url="/admin", status_code=303)
@app.post("/admin/test-send")
def admin_test_send(
request: Request,
current_user: str = Depends(get_current_user),
):
_, is_admin, _ = _resolve_effective_account(request, current_user)
if not is_admin:
return RedirectResponse(url="/", status_code=303)
try:
Config.validate_mailer()
except RuntimeError as exc:
return templates.TemplateResponse(
request,
"admin.html",
{
"current_user": current_user,
"show_all": request.session.get("show_all", False),
"error": str(exc),
},
status_code=400,
)
accounts = Config.load_accounts()
mail_accounts = [a for a in accounts if a.birthday_mail_to]
if not mail_accounts:
return templates.TemplateResponse(
request,
"admin.html",
{
"current_user": current_user,
"show_all": request.session.get("show_all", False),
"error": "Keine Accounts mit birthday_mail_to konfiguriert",
},
status_code=400,
)
with db.get_connection() as conn:
target = db.get_most_common_birthday(conn)
if target is None:
return templates.TemplateResponse(
request,
"admin.html",
{
"current_user": current_user,
"show_all": request.session.get("show_all", False),
"error": "Keine Kontakte mit Geburtstag in der Datenbank",
},
status_code=400,
)
sent_count = 0
errors = []
for account in mail_accounts:
birthdays = fetch_todays_birthdays_for_account(conn, account.name, target)
if not birthdays:
continue
msg = build_message(account.name, birthdays, target_date=target)
msg["To"] = account.birthday_mail_to
try:
send_message(msg)
sent_count += 1
except Exception as exc:
errors.append(f"{account.name}: {exc}")
if errors:
return templates.TemplateResponse(
request,
"admin.html",
{
"current_user": current_user,
"show_all": request.session.get("show_all", False),
"error": f"Versand fehlgeschlagen: {'; '.join(errors)}",
},
status_code=500,
)
return templates.TemplateResponse(
request,
"admin.html",
{
"current_user": current_user,
"show_all": request.session.get("show_all", False),
"success": f"Test-Mails für {target.strftime('%d.%m.%Y')} gesendet ({sent_count} Accounts).",
},
)
@app.get("/search/special", response_class=HTMLResponse)
def web_search_special(
request: Request,
type: str = Query(...),
current_user: str = Depends(get_current_user),
):
account_name, is_admin, show_all = _resolve_effective_account(request, current_user)
query_fn = {
"no_photo": db.search_contacts_without_photo,
"no_city": db.search_contacts_without_city,
"no_social": db.search_contacts_without_social,
}.get(type)
if not query_fn:
return RedirectResponse(url="/", status_code=303)
title_map = {
"no_photo": "Kontakte ohne Bild",
"no_city": "Kontakte ohne Stadt",
"no_social": "Kontakte ohne Social Profil",
}
with db.get_connection() as conn:
rows = query_fn(conn, account_name)
groups = db.get_all_groups(conn, account_name)
for row in rows:
if not row.get("full_name"):
row["full_name"] = db._build_full_name(row)
return templates.TemplateResponse(
request,
"index.html",
{
"current_user": current_user,
"is_admin": is_admin,
"show_all": show_all,
"account_name": account_name or "alle Accounts",
"contacts": rows,
"search": "",
"groups": groups,
"search_title": title_map.get(type, "Suche"),
},
)
@app.get("/search", response_class=HTMLResponse)
def web_search(
request: Request,
search: str | None = Query(default=None),
group: str | None = Query(default=None),
current_user: str = Depends(get_current_user),
):
account_name, is_admin, show_all = _resolve_effective_account(request, current_user)
with db.get_connection() as conn:
groups = db.get_all_groups(conn, account_name)
if group:
rows = db.get_contacts_by_group_uid(conn, account_name, group)
search_title = None
for g in groups:
if g["uid"] == group:
search_title = g["name"]
break
else:
where_clause, params = _account_filter_clause(account_name)
search_clause = ""
if search:
search_op = "AND" if where_clause else "WHERE"
search_clause = f" {search_op} (full_name LIKE %s OR given_name LIKE %s OR family_name LIKE %s OR organization LIKE %s)"
like = f"%{search}%"
params.extend([like, like, like, like])
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, full_name, given_name, middle_name, family_name,
prefix, suffix, organization, birthday, account, photo_url
FROM contacts {where_clause}{search_clause}
ORDER BY given_name, family_name, full_name
LIMIT 200""",
params,
)
rows = cur.fetchall()
search_title = None
for row in rows:
if not row.get("full_name"):
row["full_name"] = db._build_full_name(row)
return templates.TemplateResponse(
request,
"index.html",
{
"current_user": current_user,
"is_admin": is_admin,
"show_all": show_all,
"account_name": account_name or "alle Accounts",
"contacts": rows,
"search": search or "",
"groups": groups,
"search_title": search_title,
},
)
@app.get("/contacts/{contact_id}", response_class=HTMLResponse)
def web_contact(
request: Request,
contact_id: int,
search: str | None = Query(default=None),
current_user: str = Depends(get_current_user),
):
account_name, is_admin, show_all = _resolve_effective_account(request, current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
id_clause = "AND id = %s" if where_clause else "WHERE id = %s"
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization,
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, related_names, categories, updated_at
FROM contacts {where_clause} {id_clause}""",
params + [contact_id],
)
row = cur.fetchone()
if not row:
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/search", status_code=303)
groups = db.get_groups_for_contact(conn, row["account"], row["uid"])
group_names = [g["name"] for g in groups if g.get("name")]
contact = _row_to_contact_out(row, group_names=group_names)
_enrich_related_names(conn, contact)
homecity = ""
workcity = ""
for addr in contact.get("addresses", []):
city = (addr.get("city") or "").strip()
if not city:
continue
addr_type = (addr.get("type") or "").lower()
if addr_type == "home" and not homecity:
homecity = city
elif addr_type == "work" and not workcity:
workcity = city
custom_links = []
contact_account = contact.get("account")
if contact_account:
accounts = Config.load_accounts()
for acc in accounts:
if acc.name == contact_account:
custom_links = acc.custom_links
break
resolved_links = []
for link in custom_links:
url = link["url"]
if "[homecity]" in url and not homecity:
continue
if "[workcity]" in url and not workcity:
continue
url = url.replace("[fullname]", quote_plus(contact.get("full_name") or ""))
url = url.replace("[homecity]", quote_plus(homecity))
url = url.replace("[workcity]", quote_plus(workcity))
resolved_links.append({"label": link["label"], "url": url})
return templates.TemplateResponse(
request,
"contact.html",
{
"current_user": current_user,
"is_admin": is_admin,
"show_all": show_all,
"contact": contact,
"groups": groups,
"search": search or "",
"custom_links": resolved_links,
},
)