First Commit with complete Setup coded by Claude 4.7 Opus
This commit is contained in:
commit
bf1d37de77
19 changed files with 4544 additions and 0 deletions
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal 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
391
backend/carddav_client.py
Normal 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
122
backend/group_sync.py
Normal 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
572
backend/main.py
Normal 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
109
backend/models.py
Normal 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
275
backend/ms_graph.py
Normal 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
14
backend/requirements.txt
Normal 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
79
backend/scheduler.py
Normal 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
398
backend/sync_engine.py
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue