First Commit with complete Setup coded by Claude 4.7 Opus

This commit is contained in:
Tronax 2026-05-13 19:05:40 +02:00
commit bf1d37de77
19 changed files with 4544 additions and 0 deletions

14
backend/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

391
backend/carddav_client.py Normal file
View file

@ -0,0 +1,391 @@
import httpx
import vobject
import logging
from typing import Optional
from xml.etree import ElementTree as ET
logger = logging.getLogger(__name__)
CARDDAV_NAMESPACES = {
"d": "DAV:",
"cs": "http://calendarserver.org/ns/",
"card": "urn:ietf:params:xml:ns:carddav",
}
class CardDAVClient:
def __init__(self, server_url: str, username: str, password: str):
# Trailing slash entfernen
self.base_url = server_url.rstrip("/")
self.auth = (username, password)
def _client(self) -> httpx.Client:
return httpx.Client(auth=self.auth, verify=False, timeout=30)
def discover_principal(self) -> Optional[str]:
"""CardDAV Principal-URL ermitteln.
Strategie:
1. Wenn die Server-URL bereits einen Pfad enthält (z.B. /carddav/User),
wird sie direkt verwendet der Admin weiß was er tut.
2. Wenn nur Host:Port angegeben ist, wird /.well-known/carddav probiert,
dann fällt es auf /carddav zurück.
"""
from urllib.parse import urlparse
parsed = urlparse(self.base_url)
path = (parsed.path or "").strip("/")
# Wenn URL schon einen Pfad hat -> direkt verwenden
if path:
return self.base_url
# Sonst: Discovery versuchen
try:
with self._client() as client:
resp = client.request(
"PROPFIND",
f"{self.base_url}/.well-known/carddav",
headers={"Depth": "0", "Content-Type": "application/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<current-user-principal/>
<principal-URL/>
</prop>
</propfind>""",
follow_redirects=True,
)
if resp.status_code in (200, 207):
return str(resp.url).rstrip("/")
except Exception as e:
logger.warning(f"Well-known discovery failed: {e}")
return f"{self.base_url}/carddav"
def list_address_books(self) -> list[dict]:
"""Alle verfügbaren Adressbücher auflisten"""
base = self.discover_principal()
body = """<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
<prop>
<resourcetype/>
<displayname/>
<cs:getctag/>
<card:addressbook-description/>
</prop>
</propfind>"""
results = []
try:
with self._client() as client:
resp = client.request(
"PROPFIND",
base,
headers={"Depth": "1", "Content-Type": "application/xml"},
content=body,
)
if resp.status_code != 207:
logger.error(f"PROPFIND failed: {resp.status_code}")
return results
root = ET.fromstring(resp.text)
for response in root.findall(".//d:response", CARDDAV_NAMESPACES):
href_el = response.find("d:href", CARDDAV_NAMESPACES)
rt = response.find(".//d:resourcetype", CARDDAV_NAMESPACES)
is_ab = rt is not None and rt.find("card:addressbook", CARDDAV_NAMESPACES) is not None
if is_ab and href_el is not None:
href = href_el.text
name_el = response.find(".//d:displayname", CARDDAV_NAMESPACES)
name = name_el.text if name_el is not None else href.split("/")[-2]
# Absolute URL aufbauen
if href.startswith("/"):
from urllib.parse import urlparse
parsed = urlparse(self.base_url)
full_url = f"{parsed.scheme}://{parsed.netloc}{href}"
else:
full_url = href
results.append({"name": name, "url": full_url, "href": href})
except Exception as e:
logger.error(f"list_address_books error: {e}")
return results
def get_contacts(self, addressbook_url: str) -> list[dict]:
"""Alle vCards aus einem Adressbuch laden.
Verwendet REPORT addressbook-query (RFC 6352) funktioniert mit Radicale,
SOGo, Nextcloud, Baikal und allen anderen RFC-konformen CardDAV-Servern.
"""
contacts = []
# REPORT addressbook-query holt URLs + vCard-Daten in einem Call
report_body = """<?xml version="1.0" encoding="utf-8"?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop>
<d:getetag/>
<card:address-data/>
</d:prop>
<card:filter/>
</card:addressbook-query>"""
try:
with self._client() as client:
resp = client.request(
"REPORT",
addressbook_url,
headers={
"Depth": "1",
"Content-Type": "application/xml; charset=utf-8",
},
content=report_body,
)
if resp.status_code == 207:
contacts = self._parse_vcard_response(resp.text)
logger.info(f"REPORT returned {len(contacts)} contacts")
if contacts:
return contacts
else:
logger.warning(f"REPORT failed ({resp.status_code}), falling back to PROPFIND+GET")
# Fallback 1: PROPFIND mit address-data
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop>
<d:getetag/>
<card:address-data/>
</d:prop>
</d:propfind>"""
resp = client.request(
"PROPFIND",
addressbook_url,
headers={"Depth": "1", "Content-Type": "application/xml; charset=utf-8"},
content=propfind_body,
)
if resp.status_code == 207:
contacts = self._parse_vcard_response(resp.text)
if contacts:
logger.info(f"PROPFIND returned {len(contacts)} contacts")
return contacts
# Fallback 2: PROPFIND nur für hrefs + einzelne GET-Requests
logger.info("Falling back to PROPFIND + individual GETs")
href_body = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getetag/>
<d:getcontenttype/>
</d:prop>
</d:propfind>"""
resp = client.request(
"PROPFIND",
addressbook_url,
headers={"Depth": "1", "Content-Type": "application/xml; charset=utf-8"},
content=href_body,
)
if resp.status_code != 207:
logger.error(f"href PROPFIND failed: {resp.status_code}")
return contacts
# vCard URLs sammeln
from urllib.parse import urlparse
parsed_base = urlparse(self.base_url)
root = ET.fromstring(resp.text)
vcard_urls = []
for response in root.findall(".//d:response", CARDDAV_NAMESPACES):
href_el = response.find("d:href", CARDDAV_NAMESPACES)
ctype_el = response.find(".//d:getcontenttype", CARDDAV_NAMESPACES)
if href_el is None:
continue
href = href_el.text or ""
ctype = (ctype_el.text or "") if ctype_el is not None else ""
# Nur vCard-Ressourcen, keine Collection
if not href.endswith(".vcf") and "vcard" not in ctype.lower():
continue
if href.startswith("/"):
full_url = f"{parsed_base.scheme}://{parsed_base.netloc}{href}"
elif href.startswith("http"):
full_url = href
else:
full_url = f"{addressbook_url.rstrip('/')}/{href}"
vcard_urls.append((href, full_url))
logger.info(f"Fetching {len(vcard_urls)} vCards individually")
for href, url in vcard_urls:
try:
r = client.get(url)
if r.status_code == 200 and "BEGIN:VCARD" in r.text:
parsed = parse_vcard(r.text)
if parsed:
parsed["_href"] = href
parsed["_raw"] = r.text
contacts.append(parsed)
except Exception as e:
logger.warning(f"Failed to GET vCard {url}: {e}")
except Exception as e:
logger.error(f"get_contacts error: {e}", exc_info=True)
return contacts
def _parse_vcard_response(self, xml_text: str) -> list[dict]:
"""Parst eine 207 Multi-Status Antwort und extrahiert vCards"""
contacts = []
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
logger.error(f"XML parse error: {e}")
return contacts
for response in root.findall(".//d:response", CARDDAV_NAMESPACES):
href_el = response.find("d:href", CARDDAV_NAMESPACES)
etag_el = response.find(".//d:getetag", CARDDAV_NAMESPACES)
vcard_el = response.find(".//card:address-data", CARDDAV_NAMESPACES)
if vcard_el is not None and vcard_el.text:
vcard_data = vcard_el.text.strip()
if "BEGIN:VCARD" in vcard_data:
parsed = parse_vcard(vcard_data)
if parsed:
parsed["_href"] = href_el.text if href_el is not None else ""
parsed["_etag"] = etag_el.text if etag_el is not None else ""
parsed["_raw"] = vcard_data
contacts.append(parsed)
return contacts
def test_connection(self) -> tuple[bool, str]:
"""Verbindungstest"""
try:
books = self.list_address_books()
if books is not None:
return True, f"{len(books)} Adressbuch/Adressbücher gefunden"
return False, "Keine Adressbücher gefunden"
except Exception as e:
return False, str(e)
def parse_vcard(vcard_text: str) -> Optional[dict]:
"""vCard → Dictionary für Microsoft Graph"""
try:
vcard = vobject.readOne(vcard_text)
except Exception as e:
logger.warning(f"vCard parse error: {e}")
return None
def get(attr):
try:
return getattr(vcard, attr).value
except AttributeError:
return None
def get_all(attr):
try:
return [x.value for x in getattr(vcard, attr + "_list", [])]
except (AttributeError, TypeError):
return []
result = {}
# Name
name = get("n")
if name:
result["givenName"] = name.given or ""
result["surname"] = name.family or ""
result["middleName"] = name.additional or ""
result["title"] = name.prefix or ""
result["generation"] = name.suffix or ""
fn = get("fn")
if fn:
result["displayName"] = fn
elif "givenName" in result or "surname" in result:
result["displayName"] = f"{result.get('givenName', '')} {result.get('surname', '')}".strip()
# E-Mails
emails = []
try:
for email_obj in getattr(vcard, "email_list", []):
addr = email_obj.value
params = email_obj.params
types = [t.lower() for t in params.get("TYPE", [])]
address_type = "work" if "work" in types else ("home" if "home" in types else "other")
emails.append({"address": addr, "name": result.get("displayName", ""), "type": address_type})
except Exception:
pass
if emails:
result["emailAddresses"] = emails
# Telefon
phones_business = []
phones_home = []
mobile = None
try:
for tel_obj in getattr(vcard, "tel_list", []):
num = tel_obj.value
types = [t.lower() for t in tel_obj.params.get("TYPE", [])]
if "cell" in types or "mobile" in types:
mobile = num
elif "home" in types:
phones_home.append(num)
else:
phones_business.append(num)
except Exception:
pass
if phones_business:
result["businessPhones"] = phones_business
if phones_home:
result["homePhones"] = phones_home
if mobile:
result["mobilePhone"] = mobile
# Organisation
org = get("org")
if org:
if isinstance(org, list):
result["companyName"] = org[0] if org else ""
result["department"] = org[1] if len(org) > 1 else ""
else:
result["companyName"] = str(org)
title = get("title")
if title:
result["jobTitle"] = title
# Adresse
try:
for adr_obj in getattr(vcard, "adr_list", []):
adr = adr_obj.value
types = [t.lower() for t in adr_obj.params.get("TYPE", [])]
addr_dict = {
"street": adr.street or "",
"city": adr.city or "",
"state": adr.region or "",
"postalCode": adr.code or "",
"countryOrRegion": adr.country or "",
}
if "home" in types:
result["homeAddress"] = addr_dict
else:
result["businessAddress"] = addr_dict
break # nur erste Adresse
except Exception:
pass
# Notizen
note = get("note")
if note:
result["personalNotes"] = note
# URL
url = get("url")
if url:
result["businessHomePage"] = url
# UID für Identifizierung
uid = get("uid")
result["_uid"] = uid or ""
return result

122
backend/group_sync.py Normal file
View file

@ -0,0 +1,122 @@
"""Synchronisation von Azure AD Gruppenmitgliedern → CardSync User-Tabelle.
Holt alle Mitglieder einer Azure AD Gruppe via Microsoft Graph (Application Permissions)
und legt sie als User in CardSync an. Bestehende User werden aktualisiert,
nicht mehr in der Gruppe vorhandene User werden NICHT automatisch gelöscht
(nur deren Sync deaktiviert, damit es kein Datenverlust gibt).
"""
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from models import User, AzureGroupConfig
from ms_graph import get_group_info, get_group_members
logger = logging.getLogger(__name__)
async def sync_group_members(db: Session) -> dict:
"""Holt alle Gruppenmitglieder und legt User in der DB an / aktualisiert sie."""
config = db.query(AzureGroupConfig).first()
if not config:
return {"status": "error", "message": "Keine Azure-Gruppen-Konfiguration vorhanden"}
started = datetime.now(timezone.utc)
try:
# Gruppen-Info aktualisieren
group_info = await get_group_info(config.group_id)
if group_info:
config.group_name = group_info.get("displayName", config.group_name)
members = await get_group_members(config.group_id)
logger.info(f"Azure AD: {len(members)} Mitglieder in Gruppe '{config.group_name}' gefunden")
created = 0
updated = 0
deactivated = 0
# Set für die Erkennung entfernter Mitglieder
current_member_ids = set()
for m in members:
ms_user_id = m.get("id")
email = (m.get("mail") or m.get("userPrincipalName") or "").lower()
display_name = m.get("displayName", "")
if not ms_user_id or not email:
continue
current_member_ids.add(ms_user_id)
user = db.query(User).filter(User.ms_user_id == ms_user_id).first()
if not user:
# Mit E-Mail als Fallback suchen
user = db.query(User).filter(User.email == email).first()
if user:
# Existierender User → Stammdaten aktualisieren
if user.ms_user_id != ms_user_id:
user.ms_user_id = ms_user_id
if user.email != email:
user.email = email
if user.display_name != display_name and display_name:
user.display_name = display_name
# Auto-Aktivierung NUR für reine Group-Import-User
# OAuth-User behalten ihre individuellen Einstellungen
if user.source == "group_import":
if not user.sync_enabled and config.default_sync_enabled:
user.sync_enabled = True
user.updated_at = datetime.now(timezone.utc)
updated += 1
else:
# Neuer User
new_user = User(
ms_user_id=ms_user_id,
email=email,
display_name=display_name,
source="group_import",
sync_enabled=config.default_sync_enabled,
carddav_folder=config.default_carddav_folder,
sync_interval_minutes=config.default_sync_interval_minutes,
)
db.add(new_user)
created += 1
logger.info(f"Neuer User aus Gruppe importiert: {email}")
# User die nicht mehr in der Gruppe sind → Sync deaktivieren
# (nur für group_import User, OAuth-User nicht anfassen)
all_imported = db.query(User).filter(User.source == "group_import").all()
for u in all_imported:
if u.ms_user_id not in current_member_ids and u.sync_enabled:
u.sync_enabled = False
u.last_sync_message = "Aus Azure AD Gruppe entfernt — Sync automatisch deaktiviert"
u.updated_at = datetime.now(timezone.utc)
deactivated += 1
logger.info(f"Sync deaktiviert für {u.email} (nicht mehr in Gruppe)")
config.last_member_sync_at = started
config.last_member_sync_status = "success"
config.last_member_sync_message = (
f"{created} neu, {updated} aktualisiert, {deactivated} deaktiviert"
)
db.commit()
return {
"status": "success",
"message": config.last_member_sync_message,
"created": created,
"updated": updated,
"deactivated": deactivated,
"total_members": len(members),
}
except Exception as e:
logger.error(f"Group member sync failed: {e}", exc_info=True)
config.last_member_sync_at = started
config.last_member_sync_status = "error"
config.last_member_sync_message = str(e)
db.commit()
return {"status": "error", "message": str(e)}

572
backend/main.py Normal file
View file

@ -0,0 +1,572 @@
import os
import logging
import secrets
from datetime import datetime, timezone, timedelta
from typing import Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Depends, HTTPException, Query
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from sqlalchemy.orm import Session
from jose import jwt, JWTError
from models import create_tables, get_db, User, SynologyConfig, SyncLog, AzureGroupConfig
from ms_graph import (
get_auth_url, exchange_code_for_tokens, get_ms_user_info, get_valid_token,
get_app_token, get_group_info,
)
from carddav_client import CardDAVClient
from sync_engine import sync_user_contacts, invalidate_carddav_cache
from group_sync import sync_group_members
from scheduler import start_scheduler, stop_scheduler
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SECRET_KEY = os.getenv("SECRET_KEY", "fallback_secret")
ADMIN_EMAILS = [e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()]
APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost")
ALGORITHM = "HS256"
TOKEN_EXPIRE_HOURS = 24
@asynccontextmanager
async def lifespan(app: FastAPI):
create_tables()
start_scheduler()
logger.info("CardSync gestartet")
yield
stop_scheduler()
logger.info("CardSync gestoppt")
app = FastAPI(title="CardSync", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── JWT Utilities ──────────────────────────────────────────────────────────
def create_session_token(email: str, is_admin: bool) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRE_HOURS)
return jwt.encode({"sub": email, "admin": is_admin, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
def get_current_user_email(request: Request) -> Optional[str]:
token = request.cookies.get("session") or request.headers.get("X-Session-Token")
if not token:
return None
try:
payload = decode_token(token)
return payload.get("sub")
except JWTError:
return None
def require_admin(request: Request):
token = request.cookies.get("session") or request.headers.get("X-Session-Token")
if not token:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
try:
payload = decode_token(token)
if not payload.get("admin"):
raise HTTPException(status_code=403, detail="Nur Admins haben Zugriff")
return payload.get("sub")
except JWTError:
raise HTTPException(status_code=401, detail="Ungültiger Token")
# ── Auth Routen ────────────────────────────────────────────────────────────
@app.get("/auth/login")
async def login(mode: str = Query(default="user")):
"""Startet Microsoft OAuth Flow"""
state = f"{mode}:{secrets.token_urlsafe(16)}"
url = get_auth_url(state=state)
return RedirectResponse(url)
@app.get("/auth/callback")
async def auth_callback(
code: str = Query(...),
state: str = Query(default=""),
db: Session = Depends(get_db),
):
"""Microsoft OAuth Callback"""
try:
tokens = await exchange_code_for_tokens(code)
except Exception as e:
logger.error(f"Token exchange failed: {e}")
return RedirectResponse(f"{APP_BASE_URL}/?error=auth_failed")
try:
user_info = await get_ms_user_info(tokens["access_token"])
except Exception as e:
logger.error(f"Get user info failed: {e}")
return RedirectResponse(f"{APP_BASE_URL}/?error=userinfo_failed")
email = (user_info.get("mail") or user_info.get("userPrincipalName", "")).lower()
ms_user_id = user_info.get("id", "")
display_name = user_info.get("displayName", "")
# Benutzer anlegen oder aktualisieren
user = db.query(User).filter(User.email == email).first()
if not user:
user = User(email=email, ms_user_id=ms_user_id, display_name=display_name)
db.add(user)
user.ms_access_token = tokens.get("access_token")
user.ms_refresh_token = tokens.get("refresh_token")
expires_in = tokens.get("expires_in", 3600)
user.token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
user.updated_at = datetime.now(timezone.utc)
db.commit()
is_admin = email in ADMIN_EMAILS
session_token = create_session_token(email, is_admin)
# Weiterleitung
mode = state.split(":")[0] if ":" in state else "user"
if is_admin and mode == "admin":
redirect_url = f"{APP_BASE_URL}/admin.html"
else:
redirect_url = f"{APP_BASE_URL}/success.html"
response = RedirectResponse(redirect_url)
response.set_cookie(
"session", session_token,
httponly=True, samesite="lax",
max_age=TOKEN_EXPIRE_HOURS * 3600
)
return response
@app.get("/auth/me")
async def get_me(request: Request, db: Session = Depends(get_db)):
"""Gibt Info zum aktuell angemeldeten Benutzer zurück"""
email = get_current_user_email(request)
if not email:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
token = request.cookies.get("session") or request.headers.get("X-Session-Token")
payload = decode_token(token)
return {
"email": user.email,
"display_name": user.display_name,
"is_admin": payload.get("admin", False),
"sync_enabled": user.sync_enabled,
"carddav_folder": user.carddav_folder,
"sync_interval_minutes": user.sync_interval_minutes,
"last_sync_at": user.last_sync_at.isoformat() if user.last_sync_at else None,
"last_sync_status": user.last_sync_status,
"last_sync_message": user.last_sync_message,
}
@app.post("/auth/logout")
async def logout():
response = JSONResponse({"ok": True})
response.delete_cookie("session")
return response
# ── CardDAV Routen ─────────────────────────────────────────────────────────
@app.get("/api/carddav/folders")
async def list_folders(request: Request, db: Session = Depends(get_db)):
"""Listet verfügbare CardDAV-Ordner auf"""
email = get_current_user_email(request)
if not email:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
config = db.query(SynologyConfig).first()
if not config:
raise HTTPException(status_code=404, detail="Keine Synology-Konfiguration vorhanden")
client = CardDAVClient(config.server_url, config.username, config.password)
folders = client.list_address_books()
return {"folders": folders}
# ── Benutzer Einstellungen ─────────────────────────────────────────────────
class UserSettings(BaseModel):
carddav_folder: Optional[str] = None
sync_interval_minutes: Optional[int] = 60
sync_enabled: Optional[bool] = None
@app.post("/api/user/settings")
async def update_user_settings(
settings: UserSettings,
request: Request,
db: Session = Depends(get_db)
):
email = get_current_user_email(request)
if not email:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(status_code=404)
if settings.carddav_folder is not None:
user.carddav_folder = settings.carddav_folder
if settings.sync_interval_minutes is not None:
user.sync_interval_minutes = max(5, settings.sync_interval_minutes)
if settings.sync_enabled is not None:
user.sync_enabled = settings.sync_enabled
user.updated_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
@app.post("/api/user/sync-now")
async def trigger_sync_now(request: Request, db: Session = Depends(get_db)):
"""Manuellen Sync für den angemeldeten Benutzer starten"""
email = get_current_user_email(request)
if not email:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(status_code=404)
result = await sync_user_contacts(user.id, db)
return result
@app.get("/api/user/sync-logs")
async def get_sync_logs(request: Request, db: Session = Depends(get_db)):
email = get_current_user_email(request)
if not email:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(status_code=404)
logs = db.query(SyncLog).filter(SyncLog.user_id == user.id)\
.order_by(SyncLog.started_at.desc()).limit(20).all()
return {"logs": [
{
"started_at": l.started_at.isoformat() if l.started_at else None,
"finished_at": l.finished_at.isoformat() if l.finished_at else None,
"status": l.status,
"contacts_synced": l.contacts_synced,
"contacts_created": l.contacts_created,
"contacts_updated": l.contacts_updated,
"contacts_deleted": l.contacts_deleted,
"error_message": l.error_message,
}
for l in logs
]}
# ── Admin Routen ────────────────────────────────────────────────────────────
@app.get("/api/admin/config")
async def get_admin_config(admin=Depends(require_admin), db: Session = Depends(get_db)):
config = db.query(SynologyConfig).first()
if not config:
return {"configured": False}
return {
"configured": True,
"server_url": config.server_url,
"username": config.username,
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
}
class SynologyConfigInput(BaseModel):
server_url: str
username: str
password: str
@app.post("/api/admin/config")
async def save_admin_config(
data: SynologyConfigInput,
admin=Depends(require_admin),
db: Session = Depends(get_db)
):
config = db.query(SynologyConfig).first()
if config:
config.server_url = data.server_url
config.username = data.username
config.password = data.password
config.updated_at = datetime.now(timezone.utc)
else:
config = SynologyConfig(
server_url=data.server_url,
username=data.username,
password=data.password,
)
db.add(config)
db.commit()
return {"ok": True}
@app.post("/api/admin/config/test")
async def test_config(
data: SynologyConfigInput,
admin=Depends(require_admin),
):
client = CardDAVClient(data.server_url, data.username, data.password)
ok, msg = client.test_connection()
return {"ok": ok, "message": msg}
# ── Azure AD Gruppen-Konfiguration ─────────────────────────────────────────
class AzureGroupConfigInput(BaseModel):
group_id: str
auto_sync_members: Optional[bool] = True
member_sync_interval_hours: Optional[int] = 6
default_carddav_folder: Optional[str] = None
default_sync_interval_minutes: Optional[int] = 60
default_sync_enabled: Optional[bool] = True
@app.get("/api/admin/azure-group")
async def get_azure_group(admin=Depends(require_admin), db: Session = Depends(get_db)):
cfg = db.query(AzureGroupConfig).first()
if not cfg:
return {"configured": False}
return {
"configured": True,
"group_id": cfg.group_id,
"group_name": cfg.group_name,
"auto_sync_members": cfg.auto_sync_members,
"member_sync_interval_hours": cfg.member_sync_interval_hours,
"default_carddav_folder": cfg.default_carddav_folder,
"default_sync_interval_minutes": cfg.default_sync_interval_minutes,
"default_sync_enabled": cfg.default_sync_enabled,
"last_member_sync_at": cfg.last_member_sync_at.isoformat() if cfg.last_member_sync_at else None,
"last_member_sync_status": cfg.last_member_sync_status,
"last_member_sync_message": cfg.last_member_sync_message,
}
@app.post("/api/admin/azure-group")
async def save_azure_group(
data: AzureGroupConfigInput,
admin=Depends(require_admin),
db: Session = Depends(get_db),
):
cfg = db.query(AzureGroupConfig).first()
if cfg:
cfg.group_id = data.group_id
cfg.auto_sync_members = data.auto_sync_members
cfg.member_sync_interval_hours = max(1, data.member_sync_interval_hours or 6)
cfg.default_carddav_folder = data.default_carddav_folder
cfg.default_sync_interval_minutes = max(5, data.default_sync_interval_minutes or 60)
cfg.default_sync_enabled = data.default_sync_enabled
cfg.updated_at = datetime.now(timezone.utc)
else:
cfg = AzureGroupConfig(
group_id=data.group_id,
auto_sync_members=data.auto_sync_members,
member_sync_interval_hours=max(1, data.member_sync_interval_hours or 6),
default_carddav_folder=data.default_carddav_folder,
default_sync_interval_minutes=max(5, data.default_sync_interval_minutes or 60),
default_sync_enabled=data.default_sync_enabled,
)
db.add(cfg)
db.commit()
return {"ok": True}
@app.post("/api/admin/azure-group/test")
async def test_azure_group(
data: AzureGroupConfigInput,
admin=Depends(require_admin),
):
"""Testet ob die Gruppe per App-Token erreichbar ist + holt Anzahl Mitglieder"""
token = await get_app_token()
if not token:
return {"ok": False, "message": "App-Token konnte nicht abgerufen werden. Sind die Application Permissions in Azure konfiguriert + Admin-Consent erteilt?"}
info = await get_group_info(data.group_id)
if not info:
return {"ok": False, "message": "Gruppe nicht gefunden oder keine Berechtigung. GroupMember.Read.All erforderlich."}
from ms_graph import get_group_members
members = await get_group_members(data.group_id)
return {
"ok": True,
"message": f"Gruppe '{info.get('displayName', '?')}' mit {len(members)} Mitgliedern gefunden",
"group_name": info.get("displayName"),
"member_count": len(members),
}
@app.post("/api/admin/azure-group/sync-now")
async def trigger_group_sync(admin=Depends(require_admin), db: Session = Depends(get_db)):
"""Manuell die Gruppenmitglieder importieren"""
result = await sync_group_members(db)
return result
@app.delete("/api/admin/azure-group")
async def delete_azure_group(admin=Depends(require_admin), db: Session = Depends(get_db)):
"""Gruppenkonfiguration entfernen (User bleiben bestehen)"""
cfg = db.query(AzureGroupConfig).first()
if cfg:
db.delete(cfg)
db.commit()
return {"ok": True}
# ── User-Liste (erweitert um "source") ──────────────────────────────────────
@app.get("/api/admin/users")
async def list_users(admin=Depends(require_admin), db: Session = Depends(get_db)):
users = db.query(User).order_by(User.email).all()
return {"users": [
{
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"source": getattr(u, "source", "oauth"),
"sync_enabled": u.sync_enabled,
"carddav_folder": u.carddav_folder,
"sync_interval_minutes": u.sync_interval_minutes,
"last_sync_at": u.last_sync_at.isoformat() if u.last_sync_at else None,
"last_sync_status": u.last_sync_status,
"last_sync_message": u.last_sync_message,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in users
]}
class AdminUserUpdate(BaseModel):
sync_enabled: Optional[bool] = None
carddav_folder: Optional[str] = None
sync_interval_minutes: Optional[int] = None
@app.patch("/api/admin/users/{user_id}")
async def update_user(
user_id: int,
data: AdminUserUpdate,
admin=Depends(require_admin),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
if data.sync_enabled is not None:
user.sync_enabled = data.sync_enabled
if data.carddav_folder is not None:
user.carddav_folder = data.carddav_folder
if data.sync_interval_minutes is not None:
user.sync_interval_minutes = max(5, data.sync_interval_minutes)
user.updated_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
@app.delete("/api/admin/users/{user_id}")
async def delete_user(
user_id: int,
admin=Depends(require_admin),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
db.delete(user)
db.commit()
return {"ok": True}
@app.post("/api/admin/users/{user_id}/sync-now")
async def admin_sync_user(
user_id: int,
admin=Depends(require_admin),
db: Session = Depends(get_db),
):
result = await sync_user_contacts(user_id, db)
return result
@app.get("/api/admin/users/{user_id}/logs")
async def admin_user_logs(
user_id: int,
admin=Depends(require_admin),
db: Session = Depends(get_db),
):
logs = db.query(SyncLog).filter(SyncLog.user_id == user_id)\
.order_by(SyncLog.started_at.desc()).limit(20).all()
return {"logs": [
{
"started_at": l.started_at.isoformat() if l.started_at else None,
"finished_at": l.finished_at.isoformat() if l.finished_at else None,
"status": l.status,
"contacts_synced": l.contacts_synced,
"contacts_created": l.contacts_created,
"contacts_updated": l.contacts_updated,
"contacts_deleted": l.contacts_deleted,
"error_message": l.error_message,
}
for l in logs
]}
@app.get("/api/admin/stats")
async def admin_stats(admin=Depends(require_admin), db: Session = Depends(get_db)):
total_users = db.query(User).count()
sync_enabled = db.query(User).filter(User.sync_enabled == True).count()
recent_logs = db.query(SyncLog).order_by(SyncLog.started_at.desc()).limit(5).all()
return {
"total_users": total_users,
"sync_enabled_users": sync_enabled,
"recent_logs": [
{
"user_id": l.user_id,
"started_at": l.started_at.isoformat() if l.started_at else None,
"status": l.status,
"contacts_synced": l.contacts_synced,
}
for l in recent_logs
]
}
@app.post("/api/admin/cache/clear")
async def clear_cache(admin=Depends(require_admin)):
"""CardDAV-Cache komplett leeren — nächste Syncs holen frische Daten."""
invalidate_carddav_cache()
return {"ok": True, "message": "CardDAV-Cache geleert"}
# ── Health ─────────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok", "service": "CardSync"}

109
backend/models.py Normal file
View file

@ -0,0 +1,109 @@
from sqlalchemy import (
Column, String, Integer, Boolean, DateTime, Text, ForeignKey, create_engine
)
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
from datetime import datetime, timezone
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://cardsync:changeme@localhost:5432/cardsync")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
class SynologyConfig(Base):
"""Zentrale Synology-Serverkonfiguration (Admin-seitig)"""
__tablename__ = "synology_config"
id = Column(Integer, primary_key=True, index=True)
server_url = Column(String(500), nullable=False)
username = Column(String(255), nullable=False)
password = Column(Text, nullable=False)
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class AzureGroupConfig(Base):
"""Konfiguration für Azure AD Gruppen-basiertes User-Management"""
__tablename__ = "azure_group_config"
id = Column(Integer, primary_key=True, index=True)
group_id = Column(String(255), nullable=False) # Azure AD Group Object ID
group_name = Column(String(255)) # Anzeigename (cached)
auto_sync_members = Column(Boolean, default=True) # Mitglieder regelmäßig abgleichen
member_sync_interval_hours = Column(Integer, default=6) # Wie oft Gruppenmitgliedschaft prüfen
default_carddav_folder = Column(String(500)) # Default für neue User
default_sync_interval_minutes = Column(Integer, default=60)
default_sync_enabled = Column(Boolean, default=True) # Neue User direkt aktiviert?
last_member_sync_at = Column(DateTime)
last_member_sync_status = Column(String(50))
last_member_sync_message = Column(Text)
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class User(Base):
"""Benutzer (per OAuth oder per Azure-AD-Gruppen-Import angelegt)"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
ms_user_id = Column(String(255), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
display_name = Column(String(255))
# Delegated-Mode (OAuth) Tokens — optional, nur wenn User sich selbst angemeldet hat
ms_access_token = Column(Text)
ms_refresh_token = Column(Text)
token_expires_at = Column(DateTime)
# Wie wurde der User angelegt? "oauth" oder "group_import"
source = Column(String(50), default="oauth")
sync_enabled = Column(Boolean, default=False)
carddav_folder = Column(String(500))
sync_interval_minutes = Column(Integer, default=60)
last_sync_at = Column(DateTime)
last_sync_status = Column(String(50))
last_sync_message = Column(Text)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
sync_logs = relationship("SyncLog", back_populates="user", cascade="all, delete-orphan")
class SyncLog(Base):
"""Protokoll aller Sync-Vorgänge"""
__tablename__ = "sync_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
finished_at = Column(DateTime)
status = Column(String(50))
contacts_synced = Column(Integer, default=0)
contacts_created = Column(Integer, default=0)
contacts_updated = Column(Integer, default=0)
contacts_deleted = Column(Integer, default=0)
error_message = Column(Text)
user = relationship("User", back_populates="sync_logs")
def create_tables():
Base.metadata.create_all(bind=engine)
# Migration für bestehende DBs: neue Spalte "source" ergänzen falls sie fehlt
from sqlalchemy import inspect, text
inspector = inspect(engine)
if "users" in inspector.get_table_names():
cols = [c["name"] for c in inspector.get_columns("users")]
if "source" not in cols:
with engine.begin() as conn:
conn.execute(text("ALTER TABLE users ADD COLUMN source VARCHAR(50) DEFAULT 'oauth'"))

275
backend/ms_graph.py Normal file
View file

@ -0,0 +1,275 @@
import os
import httpx
from datetime import datetime, timezone, timedelta
from typing import Optional
import logging
import asyncio
logger = logging.getLogger(__name__)
MS_CLIENT_ID = os.getenv("MS_CLIENT_ID")
MS_CLIENT_SECRET = os.getenv("MS_CLIENT_SECRET")
MS_TENANT_ID = os.getenv("MS_TENANT_ID", "common")
APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost")
REDIRECT_URI = f"{APP_BASE_URL}/auth/callback"
SCOPES = "openid profile email offline_access Contacts.ReadWrite"
AUTH_URL = f"https://login.microsoftonline.com/{MS_TENANT_ID}/oauth2/v2.0/authorize"
TOKEN_URL = f"https://login.microsoftonline.com/{MS_TENANT_ID}/oauth2/v2.0/token"
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
# ── App-Token-Cache (in-memory) ────────────────────────────────────────────
_app_token_cache: dict = {"token": None, "expires_at": None}
def get_auth_url(state: str = "") -> str:
params = {
"client_id": MS_CLIENT_ID,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"scope": SCOPES,
"response_mode": "query",
"state": state,
}
query = "&".join(f"{k}={v}" for k, v in params.items())
return f"{AUTH_URL}?{query}"
async def exchange_code_for_tokens(code: str) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.post(TOKEN_URL, data={
"client_id": MS_CLIENT_ID,
"client_secret": MS_CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
"grant_type": "authorization_code",
})
resp.raise_for_status()
return resp.json()
async def refresh_access_token(refresh_token: str) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
resp = await client.post(TOKEN_URL, data={
"client_id": MS_CLIENT_ID,
"client_secret": MS_CLIENT_SECRET,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
"scope": SCOPES,
})
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.error(f"Token refresh failed: {e}")
return None
async def get_ms_user_info(access_token: str) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{GRAPH_BASE}/me",
headers={"Authorization": f"Bearer {access_token}"}
)
resp.raise_for_status()
return resp.json()
async def get_valid_token(user, db) -> Optional[str]:
"""Gibt ein gültiges Access Token zurück.
1. Wenn der User per Gruppen-Import angelegt wurde: App-Token (Application Permissions)
2. Wenn der User OAuth-Login gemacht hat: User-Token (mit Refresh)
"""
# Gruppen-Import User → App-Token nutzen
if getattr(user, "source", "oauth") == "group_import":
return await get_app_token()
now = datetime.now(timezone.utc)
expires = user.token_expires_at
if expires and expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires and now < expires - timedelta(minutes=5):
return user.ms_access_token
if not user.ms_refresh_token:
# Kein Refresh-Token? Versuche App-Token als Fallback
return await get_app_token()
tokens = await refresh_access_token(user.ms_refresh_token)
if not tokens:
return await get_app_token()
user.ms_access_token = tokens["access_token"]
if "refresh_token" in tokens:
user.ms_refresh_token = tokens["refresh_token"]
user.token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=tokens.get("expires_in", 3600))
db.commit()
return user.ms_access_token
# ── Application Permissions (Client Credentials Flow) ──────────────────────
async def get_app_token() -> Optional[str]:
"""Holt ein App-Token via Client Credentials Flow.
Wird gecacht und automatisch erneuert.
Voraussetzung: In Azure App-Registrierung Application Permissions vergeben
+ Admin-Consent erteilt:
- Contacts.ReadWrite (Application)
- User.Read.All (Application)
- GroupMember.Read.All (Application)
"""
now = datetime.now(timezone.utc)
cached = _app_token_cache.get("token")
expires = _app_token_cache.get("expires_at")
if cached and expires and now < expires - timedelta(minutes=5):
return cached
try:
async with httpx.AsyncClient() as client:
resp = await client.post(TOKEN_URL, data={
"client_id": MS_CLIENT_ID,
"client_secret": MS_CLIENT_SECRET,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials",
})
if resp.status_code != 200:
logger.error(f"App-Token Anfrage fehlgeschlagen: {resp.status_code} {resp.text}")
return None
data = resp.json()
token = data["access_token"]
_app_token_cache["token"] = token
_app_token_cache["expires_at"] = now + timedelta(seconds=data.get("expires_in", 3600))
return token
except Exception as e:
logger.error(f"App-Token Fehler: {e}")
return None
# ── Group / User Lookup (App-Token) ─────────────────────────────────────────
async def get_group_info(group_id: str) -> Optional[dict]:
"""Liest Gruppen-Metadaten (Name etc.)"""
token = await get_app_token()
if not token:
return None
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{GRAPH_BASE}/groups/{group_id}?$select=id,displayName,description",
headers={"Authorization": f"Bearer {token}"}
)
if resp.status_code != 200:
logger.error(f"get_group_info failed: {resp.status_code} {resp.text}")
return None
return resp.json()
async def get_group_members(group_id: str) -> list[dict]:
"""Liest alle Mitglieder einer Azure AD Gruppe.
Folgt automatisch der Paginierung (@odata.nextLink).
Filtert auf User-Objekte (keine Gruppen-in-Gruppen, keine Service Principals).
"""
token = await get_app_token()
if not token:
return []
members = []
url = f"{GRAPH_BASE}/groups/{group_id}/members?$select=id,displayName,mail,userPrincipalName&$top=100"
async with httpx.AsyncClient() as client:
while url:
resp = await client.get(url, headers={"Authorization": f"Bearer {token}"})
if resp.status_code != 200:
logger.error(f"get_group_members failed: {resp.status_code} {resp.text}")
break
data = resp.json()
for m in data.get("value", []):
# Nur echte User
if m.get("@odata.type", "").lower().endswith("user"):
members.append(m)
elif "userPrincipalName" in m:
members.append(m)
url = data.get("@odata.nextLink")
return members
# ── Microsoft Graph: Contacts ──────────────────────────────────────────────
def _contacts_endpoint(user_principal: Optional[str]) -> str:
"""Wählt den richtigen Contacts-Endpoint.
user_principal=None /me/contacts (Delegated)
user_principal=email/id /users/{principal}/contacts (Application)
"""
if user_principal:
return f"{GRAPH_BASE}/users/{user_principal}/contacts"
return f"{GRAPH_BASE}/me/contacts"
CONTACT_SELECT = "id,displayName,emailAddresses,businessPhones,mobilePhone,homePhones,jobTitle,companyName,department,businessAddress,homeAddress,birthday,personalNotes,givenName,surname,middleName,nickName,title,generation,imAddresses,fileAs,initials,businessHomePage,spouseName,categories"
async def graph_get_contacts(access_token: str, user_principal: Optional[str] = None) -> list[dict]:
"""Alle Kontakte abrufen. user_principal nur bei App-Token nötig."""
contacts = []
url = f"{_contacts_endpoint(user_principal)}?$top=100&$select={CONTACT_SELECT}"
headers = {"Authorization": f"Bearer {access_token}"}
async with httpx.AsyncClient() as client:
while url:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
contacts.extend(data.get("value", []))
url = data.get("@odata.nextLink")
return contacts
async def graph_create_contact(access_token: str, contact_data: dict, user_principal: Optional[str] = None) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.post(
_contacts_endpoint(user_principal),
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json=contact_data,
)
if resp.status_code >= 400:
logger.error(f"Graph create_contact failed ({resp.status_code}): {resp.text}")
logger.error(f"Payload was: {contact_data}")
resp.raise_for_status()
return resp.json()
async def graph_update_contact(access_token: str, contact_id: str, contact_data: dict, user_principal: Optional[str] = None) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.patch(
f"{_contacts_endpoint(user_principal)}/{contact_id}",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json=contact_data,
)
if resp.status_code >= 400:
logger.error(f"Graph update_contact failed ({resp.status_code}): {resp.text}")
logger.error(f"Payload was: {contact_data}")
resp.raise_for_status()
return resp.json()
async def graph_delete_contact(access_token: str, contact_id: str, user_principal: Optional[str] = None):
async with httpx.AsyncClient() as client:
resp = await client.delete(
f"{_contacts_endpoint(user_principal)}/{contact_id}",
headers={"Authorization": f"Bearer {access_token}"}
)
resp.raise_for_status()

14
backend/requirements.txt Normal file
View file

@ -0,0 +1,14 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
alembic==1.14.0
httpx==0.28.1
python-jose[cryptography]==3.3.0
python-multipart==0.0.20
apscheduler==3.10.4
vobject==0.9.6.1
requests==2.32.3
python-dotenv==1.0.1
pydantic==2.10.3
pydantic-settings==2.6.1

79
backend/scheduler.py Normal file
View file

@ -0,0 +1,79 @@
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy.orm import Session
from models import SessionLocal, User, AzureGroupConfig
from sync_engine import sync_user_contacts
from group_sync import sync_group_members
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler(timezone="UTC")
async def run_due_syncs():
"""Prüft welche Benutzer synchronisiert werden sollen und startet den Sync"""
db: Session = SessionLocal()
try:
users = db.query(User).filter(User.sync_enabled == True).all()
now = datetime.now(timezone.utc)
for user in users:
interval = user.sync_interval_minutes or 60
last = user.last_sync_at
if last:
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
next_sync = last + timedelta(minutes=interval)
if now < next_sync:
continue
logger.info(f"Starte Sync für {user.email}")
await sync_user_contacts(user.id, db)
except Exception as e:
logger.error(f"Scheduler run_due_syncs error: {e}")
finally:
db.close()
async def run_due_group_sync():
"""Prüft ob Azure AD Gruppenmitgliedschaft erneut synchronisiert werden soll"""
db: Session = SessionLocal()
try:
config = db.query(AzureGroupConfig).first()
if not config or not config.auto_sync_members:
return
now = datetime.now(timezone.utc)
interval = config.member_sync_interval_hours or 6
last = config.last_member_sync_at
if last:
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
if now < last + timedelta(hours=interval):
return
logger.info(f"Starte Azure AD Gruppen-Sync (alle {interval}h)")
await sync_group_members(db)
except Exception as e:
logger.error(f"Scheduler group sync error: {e}")
finally:
db.close()
def start_scheduler():
scheduler.add_job(run_due_syncs, "interval", minutes=1, id="sync_job", replace_existing=True)
scheduler.add_job(run_due_group_sync, "interval", minutes=10, id="group_sync_job", replace_existing=True)
scheduler.start()
logger.info("Sync-Scheduler gestartet (Kontakte: 1 Min., Gruppen: 10 Min.)")
def stop_scheduler():
if scheduler.running:
scheduler.shutdown()

398
backend/sync_engine.py Normal file
View file

@ -0,0 +1,398 @@
import logging
import asyncio
from datetime import datetime, timezone, timedelta
from sqlalchemy.orm import Session
from models import User, SyncLog, SynologyConfig
from carddav_client import CardDAVClient
from ms_graph import get_valid_token, graph_get_contacts, graph_create_contact, graph_update_contact, graph_delete_contact
logger = logging.getLogger(__name__)
# Trennzeichen für UID im personalNotes-Feld (um Synology-UID in MS zu speichern)
UID_MARKER = "[cardsync_uid:"
# ── CardDAV Cache ──────────────────────────────────────────────────────────
# Bei vielen Usern die dasselbe Adressbuch synchronisieren wird die Synology
# sonst pro Sync-Zyklus N-mal abgefragt. Cache reduziert das auf 1× pro TTL.
# Cache pro (server_url, folder_url) — pro Adressbuch separater Eintrag.
_carddav_cache: dict = {}
_carddav_cache_locks: dict = {}
CARDDAV_CACHE_TTL_SECONDS = 300 # 5 Minuten
def _cache_key(server_url: str, folder_url: str) -> str:
return f"{server_url}||{folder_url}"
async def _get_cached_carddav_contacts(server_url: str, username: str, password: str, folder_url: str) -> list[dict]:
"""Lädt CardDAV-Kontakte aus dem Cache oder frisch — pro (Server, Folder)
nur ein paralleler Abruf dank Lock. Folge-Requests warten und bekommen das Ergebnis."""
key = _cache_key(server_url, folder_url)
now = datetime.now(timezone.utc)
# Cache-Hit?
entry = _carddav_cache.get(key)
if entry:
expires_at = entry["expires_at"]
if now < expires_at:
logger.info(f"CardDAV Cache HIT für {folder_url} (age: {(now - entry['cached_at']).total_seconds():.0f}s)")
return entry["contacts"]
# Lock pro Schlüssel — verhindert dass 170 User gleichzeitig CardDAV abfragen
lock = _carddav_cache_locks.setdefault(key, asyncio.Lock())
async with lock:
# Nochmal prüfen — ein anderer Task hat eventuell schon geladen
entry = _carddav_cache.get(key)
if entry and now < entry["expires_at"]:
logger.info(f"CardDAV Cache HIT (nach Lock) für {folder_url}")
return entry["contacts"]
logger.info(f"CardDAV Cache MISS für {folder_url} — lade frisch")
client = CardDAVClient(server_url, username, password)
# client.get_contacts ist synchron — in Thread auslagern damit asyncio nicht blockiert
contacts = await asyncio.to_thread(client.get_contacts, folder_url)
_carddav_cache[key] = {
"contacts": contacts,
"cached_at": now,
"expires_at": now + timedelta(seconds=CARDDAV_CACHE_TTL_SECONDS),
}
return contacts
def invalidate_carddav_cache(folder_url: str = None):
"""Cache invalidieren — entweder komplett oder nur für ein Adressbuch."""
if folder_url is None:
_carddav_cache.clear()
logger.info("CardDAV-Cache komplett geleert")
else:
keys_to_remove = [k for k in _carddav_cache if k.endswith(f"||{folder_url}")]
for k in keys_to_remove:
del _carddav_cache[k]
logger.info(f"CardDAV-Cache invalidiert für {folder_url}")
def _embed_uid(uid: str, notes: str = "") -> str:
"""UID in personalNotes einbetten, damit wir MS-Kontakte mit vCards matchen können"""
if not uid:
return notes
marker = f"{UID_MARKER}{uid}]"
if marker in (notes or ""):
return notes
return f"{notes}\n{marker}".strip() if notes else marker
def _extract_uid(notes: str) -> str:
"""UID aus personalNotes extrahieren"""
if not notes or UID_MARKER not in notes:
return ""
start = notes.index(UID_MARKER) + len(UID_MARKER)
end = notes.index("]", start)
return notes[start:end]
def _clean_payload(contact: dict) -> dict:
"""Bereinigt das Kontakt-Dict für die Microsoft Graph API.
- Entfernt interne Felder (mit _ prefix)
- Entfernt leere Strings (Graph erwartet null oder weglassen)
- Entfernt leere Adress-Objekte (alle Felder leer -> komplettes Objekt weg)
- Stellt sicher dass Listen wirklich Listen mit Inhalt sind
"""
payload = {}
for k, v in contact.items():
if k.startswith("_"):
continue
# Leere Strings -> weg
if isinstance(v, str):
if v.strip():
payload[k] = v
continue
# Adress-Objekte: nur senden wenn min. 1 Feld gefüllt
if isinstance(v, dict):
cleaned = {sk: sv for sk, sv in v.items() if isinstance(sv, str) and sv.strip()}
if cleaned:
payload[k] = cleaned
continue
# Listen: leere raus
if isinstance(v, list):
if k == "emailAddresses":
# nur Einträge mit gültiger address behalten
cleaned_list = [e for e in v if isinstance(e, dict) and e.get("address", "").strip()]
# Graph-Schema: email braucht nur "address", "name" ist optional; "type" entfernen!
cleaned_list = [{"address": e["address"], "name": e.get("name", "") or e["address"]} for e in cleaned_list]
# Microsoft Graph erlaubt max. 3 E-Mail-Adressen
if len(cleaned_list) > 3:
cleaned_list = cleaned_list[:3]
if cleaned_list:
payload[k] = cleaned_list
elif k in ("businessPhones", "homePhones"):
cleaned_list = [p for p in v if isinstance(p, str) and p.strip()]
# Microsoft Graph erlaubt max. 2 Einträge pro Telefon-Liste
if len(cleaned_list) > 2:
cleaned_list = cleaned_list[:2]
if cleaned_list:
payload[k] = cleaned_list
else:
if v:
payload[k] = v
continue
# Sonst: nur setzen wenn Wert da
if v is not None and v != "":
payload[k] = v
return payload
def _embed_uid(uid: str, notes: str = "") -> str:
"""UID in personalNotes einbetten, damit wir MS-Kontakte mit vCards matchen können"""
if not uid:
return notes
marker = f"{UID_MARKER}{uid}]"
if marker in (notes or ""):
return notes
return f"{notes}\n{marker}".strip() if notes else marker
def _extract_uid(notes: str) -> str:
"""UID aus personalNotes extrahieren"""
if not notes or UID_MARKER not in notes:
return ""
start = notes.index(UID_MARKER) + len(UID_MARKER)
end = notes.index("]", start)
return notes[start:end]
def _normalize_addr(addr: dict) -> dict:
"""Normalisiert ein Adress-Dict für stabilen Vergleich"""
if not isinstance(addr, dict):
return {}
return {
"street": (addr.get("street") or "").strip(),
"city": (addr.get("city") or "").strip(),
"state": (addr.get("state") or "").strip(),
"postalCode": (addr.get("postalCode") or "").strip(),
"countryOrRegion": (addr.get("countryOrRegion") or "").strip(),
}
def _contacts_equal(carddav_contact: dict, ms_contact: dict) -> bool:
"""Prüft ob sich der Kontakt geändert hat.
Vergleicht ALLE Felder die wir auch hochladen string-Felder, Listen, Adressen.
Wenn diese Funktion True liefert, wird der Kontakt nicht erneut hochgeladen.
"""
# Bereinigte Versionen vergleichen, damit Vergleich konsistent ist
cv_clean = _clean_payload(carddav_contact)
# MS-Kontakt: gleicher Cleanup für fairen Vergleich
# Notizen: cardsync-UID-Marker ausblenden, da der in MS bewusst gesetzt wird
def strip_uid_marker(notes: str) -> str:
if not notes:
return ""
if UID_MARKER not in notes:
return notes.strip()
# Marker entfernen
start = notes.index(UID_MARKER)
end = notes.index("]", start) + 1
return (notes[:start] + notes[end:]).strip()
# Einfache String-Felder
string_fields = [
"givenName", "surname", "middleName", "displayName", "title", "generation",
"jobTitle", "companyName", "department", "mobilePhone",
"businessHomePage", "nickName",
]
for f in string_fields:
if (cv_clean.get(f, "") or "") != (ms_contact.get(f, "") or ""):
return False
# Notizen vergleichen — UID-Marker rausrechnen
cv_notes = strip_uid_marker(cv_clean.get("personalNotes", ""))
ms_notes = strip_uid_marker(ms_contact.get("personalNotes", ""))
if cv_notes != ms_notes:
return False
# E-Mails
cv_emails = set((e.get("address") or "").lower() for e in cv_clean.get("emailAddresses", []))
ms_emails = set((e.get("address") or "").lower() for e in ms_contact.get("emailAddresses", []))
if cv_emails != ms_emails:
return False
# Telefone (geordnete Listen, da Graph nur max. 2 erlaubt sollte das robust sein)
if (cv_clean.get("businessPhones") or []) != (ms_contact.get("businessPhones") or []):
return False
if (cv_clean.get("homePhones") or []) != (ms_contact.get("homePhones") or []):
return False
# Adressen
if _normalize_addr(cv_clean.get("businessAddress", {})) != _normalize_addr(ms_contact.get("businessAddress", {})):
return False
if _normalize_addr(cv_clean.get("homeAddress", {})) != _normalize_addr(ms_contact.get("homeAddress", {})):
return False
return True
async def sync_user_contacts(user_id: int, db: Session) -> dict:
"""Synchronisiert Kontakte für einen Benutzer: Synology → MS365"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"status": "error", "message": "Benutzer nicht gefunden"}
if not user.sync_enabled:
return {"status": "skipped", "message": "Sync deaktiviert"}
if not user.carddav_folder:
return {"status": "error", "message": "Kein CardDAV-Ordner konfiguriert"}
# Sync-Log anlegen
log = SyncLog(user_id=user.id, started_at=datetime.now(timezone.utc))
db.add(log)
db.commit()
user.last_sync_status = "running"
user.updated_at = datetime.now(timezone.utc)
db.commit()
try:
# 1. Synology-Konfiguration laden
config = db.query(SynologyConfig).first()
if not config:
raise ValueError("Keine Synology-Konfiguration vorhanden")
# 2. CardDAV-Kontakte laden (mit Cache — bei vielen Usern mit gleichem
# Adressbuch wird Synology nur einmal pro Cache-TTL abgefragt)
carddav_contacts = await _get_cached_carddav_contacts(
config.server_url, config.username, config.password, user.carddav_folder
)
logger.info(f"[{user.email}] {len(carddav_contacts)} Kontakte von CardDAV geladen")
# 3. MS365-Kontakte laden
access_token = await get_valid_token(user, db)
if not access_token:
raise ValueError("Kein gültiges Microsoft-Token Benutzer muss sich neu anmelden")
# Bei Gruppen-Import-Usern: über /users/{upn}/contacts gehen (App-Token)
user_principal = user.email if getattr(user, "source", "oauth") == "group_import" else None
ms_contacts = await graph_get_contacts(access_token, user_principal)
logger.info(f"[{user.email}] {len(ms_contacts)} Kontakte von MS365 geladen")
# 4. Index aufbauen: UID → MS-Kontakt
ms_by_uid = {}
ms_by_display = {}
for ms_c in ms_contacts:
uid = _extract_uid(ms_c.get("personalNotes", ""))
if uid:
ms_by_uid[uid] = ms_c
name = ms_c.get("displayName", "").strip().lower()
if name:
ms_by_display[name] = ms_c
# 5. CardDAV → MS365 synchronisieren
carddav_uids = set()
created = updated = skipped = failed = 0
for contact in carddav_contacts:
uid = contact.get("_uid", "")
display = contact.get("displayName", "").strip().lower()
if not contact.get("displayName"):
continue # Kontakte ohne Namen überspringen
carddav_uids.add(uid)
# UID in Notes einbetten
notes = contact.get("personalNotes", "")
contact["personalNotes"] = _embed_uid(uid, notes)
# Felder für Graph API bereinigen
ms_payload = _clean_payload(contact)
if not ms_payload.get("displayName") and not ms_payload.get("givenName") and not ms_payload.get("surname"):
continue # Graph braucht mindestens einen Namen
try:
if uid and uid in ms_by_uid:
# Kontakt existiert schon → Update wenn nötig
ms_c = ms_by_uid[uid]
if not _contacts_equal(contact, ms_c):
await graph_update_contact(access_token, ms_c["id"], ms_payload, user_principal)
updated += 1
logger.debug(f"[{user.email}] Updated: {contact.get('displayName')}")
else:
skipped += 1
elif display and display in ms_by_display and not uid:
# Kein UID aber gleicher Name → Update + UID nachpflegen
ms_c = ms_by_display[display]
if not _contacts_equal(contact, ms_c):
await graph_update_contact(access_token, ms_c["id"], ms_payload, user_principal)
updated += 1
else:
skipped += 1
else:
# Neuer Kontakt → erstellen
await graph_create_contact(access_token, ms_payload, user_principal)
created += 1
logger.debug(f"[{user.email}] Created: {contact.get('displayName')}")
except Exception as ce:
failed += 1
logger.warning(f"[{user.email}] Kontakt '{contact.get('displayName')}' übersprungen: {ce}")
continue
# 6. Gelöschte Kontakte aus MS365 entfernen
deleted = 0
for uid, ms_c in ms_by_uid.items():
if uid not in carddav_uids:
try:
await graph_delete_contact(access_token, ms_c["id"], user_principal)
deleted += 1
logger.debug(f"[{user.email}] Deleted: {ms_c.get('displayName')}")
except Exception as de:
logger.warning(f"[{user.email}] Delete failed for {ms_c.get('displayName')}: {de}")
total = created + updated
message = f"{created} erstellt, {updated} aktualisiert, {deleted} gelöscht, {skipped} unverändert"
if failed:
message += f", {failed} fehlgeschlagen"
logger.info(f"[{user.email}] Sync abgeschlossen: {message}")
# Log abschließen
now = datetime.now(timezone.utc)
log.finished_at = now
log.status = "success"
log.contacts_synced = len(carddav_contacts)
log.contacts_created = created
log.contacts_updated = updated
log.contacts_deleted = deleted
user.last_sync_at = now
user.last_sync_status = "success"
user.last_sync_message = message
user.updated_at = now
db.commit()
return {"status": "success", "message": message, "created": created, "updated": updated, "deleted": deleted}
except Exception as e:
error_msg = str(e)
logger.error(f"[{user.email}] Sync failed: {error_msg}")
now = datetime.now(timezone.utc)
log.finished_at = now
log.status = "error"
log.error_message = error_msg
user.last_sync_at = now
user.last_sync_status = "error"
user.last_sync_message = error_msg
user.updated_at = now
db.commit()
return {"status": "error", "message": error_msg}