commit bf1d37de77e4cc5e2aeec668098a64eb80b3edf1 Author: Tronax Date: Wed May 13 19:05:40 2026 +0200 First Commit with complete Setup coded by Claude 4.7 Opus diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1439e69 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# ============================================= +# CardSync - Konfiguration +# ============================================= +# Kopiere diese Datei zu .env und passe die Werte an + +# Datenbank +POSTGRES_PASSWORD=sicher_aendern_123 + +# Sicherheit +SECRET_KEY=bitte_einen_langen_zufaelligen_string_hier_eintragen + +# Microsoft Azure App Registration +# Anleitung: https://portal.azure.com -> App registrations -> New registration +# Redirect URI muss sein: ${APP_BASE_URL}/auth/callback +MS_CLIENT_ID=deine-azure-app-client-id +MS_CLIENT_SECRET=dein-azure-app-client-secret +MS_TENANT_ID=common + +# Basis-URL der Anwendung (ohne trailing slash) +# WICHTIG: Muss mit https:// beginnen, auch bei selbstsigniertem Cert +APP_BASE_URL=https://localhost + +# Komma-getrennte Liste der Admin-E-Mail-Adressen +# Diese Benutzer haben Zugang zum Admin-Dashboard +ADMIN_EMAILS=admin@firma.de,it@firma.de diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12a4513 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# ─── Secrets & Konfiguration ────────────────────────────────────────── +.env +.env.local +.env.*.local + +# ─── TLS Zertifikate ─────────────────────────────────────────────────── +# Eigene Zertifikate niemals einchecken +nginx/certs/*.pem +nginx/certs/*.crt +nginx/certs/*.key +nginx/certs/*.pfx +nginx/certs/*.p12 +# README aber behalten +!nginx/certs/README.md + +# ─── Python ──────────────────────────────────────────────────────────── +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +venv/ +.venv/ +env/ +ENV/ + +# ─── Datenbank ───────────────────────────────────────────────────────── +*.db +*.sqlite +*.sqlite3 + +# ─── Docker Volumes (falls bind-mounted) ─────────────────────────────── +postgres_data/ + +# ─── IDE / Editor ────────────────────────────────────────────────────── +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# ─── Logs ────────────────────────────────────────────────────────────── +*.log +logs/ + +# ─── Build artifacts ─────────────────────────────────────────────────── +dist/ +build/ +*.tar.gz +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8da90a --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# CardSync 🔄 + +**Synology CardDAV → Microsoft 365 Kontakte** — Self-hosted, Docker-basiert. + +Synchronisiert Kontakte von einem Synology CardDAV-Server automatisch in die Microsoft 365 Kontakte deiner Benutzer. Benutzer melden sich einmalig per Microsoft OAuth an, ein Admin konfiguriert den Synology-Server zentral. + +--- + +## Voraussetzungen + +- Docker & Docker Compose +- Synology NAS mit aktiviertem CardDAV-Server (Paket: „CardDAV Server" oder „Contacts") +- Microsoft Azure App-Registrierung + +--- + +## Schnellstart + +### Azure App-Registrierung erstellen + +CardSync unterstützt zwei Betriebsmodi — du kannst sie **kombinieren oder nur einen** nutzen: + +**Modus A: OAuth-Login (Delegated Permissions)** +User melden sich einmalig per Microsoft-Login an. Empfohlen für kleine Setups oder zum Testen. + +**Modus B: Azure AD Gruppen-Import (Application Permissions) — empfohlen für Firmen** +Admin trägt eine Azure AD Gruppen-ID ein, alle Mitglieder werden automatisch importiert. Kein User-Login nötig. + +#### Setup-Schritte: + +1. Gehe zu [portal.azure.com](https://portal.azure.com) → **Azure Active Directory** → **App-Registrierungen** +2. **Neue Registrierung** → Name: `CardSync` +3. Kontotypen: nur eigene Organisation +4. Redirect-URI (Web): `https://deine-ip-oder-domain/auth/callback` +5. Unter **Zertifikate & Geheimnisse** ein neues Client-Secret erstellen → notieren + +**Berechtigungen** unter **API-Berechtigungen** → **Microsoft Graph**: + +*Für Modus A (Delegated):* +- `Contacts.ReadWrite` (Delegiert) +- `offline_access`, `openid`, `profile`, `email` + +*Für Modus B (Application — Goldstandard):* +- `Contacts.ReadWrite` (**Anwendung** — wichtig, nicht delegiert!) +- `User.Read.All` (Anwendung) +- `GroupMember.Read.All` (Anwendung) +- Danach **„Administratorzustimmung erteilen"** klicken + +Beide Modi können parallel laufen — User können sich entweder selbst anmelden (Modus A), oder werden per Gruppen-Import angelegt (Modus B). + +### 2. Repository vorbereiten + +```bash +git clone +cd cardsync +cp .env.example .env +``` + +### 3. `.env` konfigurieren + +```env +# Datenbank +POSTGRES_PASSWORD=sicher_aendern_123 + +# Sicherheit (langen zufälligen String generieren: openssl rand -hex 32) +SECRET_KEY=dein_langer_zufaelliger_key + +# Microsoft Azure +MS_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +MS_CLIENT_SECRET=dein-client-secret +MS_TENANT_ID=common # oder deine Tenant-ID für Single-Tenant + +# URL unter der die App erreichbar ist (KEIN trailing slash) +APP_BASE_URL=http://192.168.1.100 + +# Admin-E-Mails (kommagetrennt) — diese Benutzer bekommen Admin-Zugang +ADMIN_EMAILS=admin@firma.de,it@firma.de +``` + +### 4. Starten + +```bash +docker compose up -d +``` + +Die App ist jetzt unter `https://localhost` erreichbar (HTTP wird automatisch auf HTTPS umgeleitet). + +> ⚠️ **Beim ersten Start** wird automatisch ein selbstsigniertes Zertifikat erzeugt — der Browser zeigt eine Sicherheitswarnung. Einmal akzeptieren und weitermachen. + +### 5. (Optional) Eigenes Zertifikat hinterlegen + +Wenn du ein Wildcard- oder CA-signiertes Zertifikat hast: + +```bash +# Lege deine Dateien EXAKT mit diesen Namen ab: +cp dein-wildcard.crt ./nginx/certs/cert.pem +cp dein-wildcard.key ./nginx/certs/key.pem + +# Frontend neu starten — das war's +docker compose restart frontend +``` + +Details und PFX-Konvertierung: siehe `nginx/certs/README.md` + +--- + +## Verwendung + +### Admin-Einrichtung + +1. Gehe zu `http://deine-ip/auth/login?mode=admin` +2. Melde dich mit einem der Admin-Microsoft-Accounts an +3. Gehe im Admin-Dashboard zu **Synology Config** +4. Trage Server-URL, Benutzername und Passwort ein +5. Teste die Verbindung mit **Verbindung testen** + +### Benutzer-Onboarding (Modus A — OAuth) + +1. Benutzer rufen `http://deine-ip` auf +2. Klick auf **Mit Microsoft anmelden** +3. Microsoft OAuth-Zustimmung (Kontakte-Zugriff) +4. Adressbuch auswählen und Sync-Intervall einstellen +5. Optional: **Jetzt synchronisieren** für den ersten sofortigen Sync + +### Benutzer-Import via Azure AD Gruppe (Modus B — empfohlen für Firmen) + +1. Admin-Dashboard → **Azure AD Gruppe** +2. Object-ID der Azure AD Gruppe eintragen (aus Azure Portal → AD → Gruppen → deine Gruppe → Object ID) +3. Default-Adressbuch und Default-Sync-Intervall für neue User wählen +4. **Gruppe testen** klicken → sollte die Mitgliederzahl anzeigen +5. **Speichern**, dann **Mitglieder jetzt importieren** +6. Alle Gruppenmitglieder sind nun in CardSync angelegt, Sync läuft automatisch +7. Mitgliedschaft wird im konfigurierten Intervall (Default: 6h) automatisch aktualisiert + +### Admin: Benutzer verwalten + +- Im Admin-Dashboard unter **Benutzer** alle angemeldeten Benutzer sehen +- Pro Benutzer: Adressbuch zuweisen, Intervall konfigurieren, Sync aktivieren/deaktivieren +- Manuellen Sync für jeden Benutzer triggern +- Sync-Logs einsehen + +--- + +## Synology CardDAV-Server einrichten + +1. Im Synology Package Center „**CardDAV Server**" installieren +2. Oder im Paket „**Contacts**": CardDAV ist automatisch aktiv +3. Standard-Port: **5006** (HTTP) oder **5007** (HTTPS) +4. Server-URL-Format: `http://192.168.1.10:5006` oder `https://nas.firma.de:5007` + +--- + +## Architektur + +``` +┌─────────────────────────────────────────────┐ +│ Docker Compose │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Nginx │──▶│ FastAPI │──▶│PostgreSQL│ │ +│ │ :80 │ │ :8000 │ │ :5432 │ │ +│ └──────────┘ └────┬─────┘ └──────────┘ │ +└───────────────────────┼─────────────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌──────▼──────┐ ┌────────▼──────┐ + │ Synology │ │ Microsoft │ + │ CardDAV │ │ Graph API │ + │ (CardDAV │ │ (REST/OAuth) │ + │ Protokoll)│ └───────────────┘ + └─────────────┘ +``` + +**Sync-Richtung:** Nur Synology → MS365 (einseitig) + +**Matching-Strategie:** +- Kontakte werden per UID (vCard UID-Feld) gematcht +- Die UID wird beim ersten Sync in den MS365-Notizen hinterlegt +- Gelöschte Synology-Kontakte werden aus MS365 entfernt + +--- + +## Sicherheitshinweise + +- In Produktion: HTTPS-Reverse-Proxy (z.B. Traefik, Caddy, nginx mit Let's Encrypt) vorschalten +- `SECRET_KEY` und `POSTGRES_PASSWORD` immer ändern +- Das Synology-Passwort wird aktuell im Klartext in der DB gespeichert (für Produktion: Verschlüsselung ergänzen) +- Microsoft Tokens werden per JWT-Session verwaltet und automatisch refresht + +--- + +## Logs + +```bash +docker compose logs -f backend # Backend-Logs mit Sync-Aktivitäten +docker compose logs -f frontend # Nginx-Logs +``` + +--- + +## Entwicklung / Updates + +```bash +docker compose down +docker compose up -d --build +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..54171db --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/carddav_client.py b/backend/carddav_client.py new file mode 100644 index 0000000..c6c8162 --- /dev/null +++ b/backend/carddav_client.py @@ -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=""" + + + + + +""", + 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 = """ + + + + + + + +""" + 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 = """ + + + + + + +""" + + 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 = """ + + + + + +""" + 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 = """ + + + + + +""" + 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 diff --git a/backend/group_sync.py b/backend/group_sync.py new file mode 100644 index 0000000..f9fc1b6 --- /dev/null +++ b/backend/group_sync.py @@ -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)} diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..4265fcc --- /dev/null +++ b/backend/main.py @@ -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"} diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..5e318c8 --- /dev/null +++ b/backend/models.py @@ -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'")) diff --git a/backend/ms_graph.py b/backend/ms_graph.py new file mode 100644 index 0000000..597bce2 --- /dev/null +++ b/backend/ms_graph.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..bb8a357 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 0000000..72c211c --- /dev/null +++ b/backend/scheduler.py @@ -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() diff --git a/backend/sync_engine.py b/backend/sync_engine.py new file mode 100644 index 0000000..5e0db74 --- /dev/null +++ b/backend/sync_engine.py @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..efb4687 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + container_name: cardsync_db + restart: unless-stopped + environment: + POSTGRES_DB: cardsync + POSTGRES_USER: cardsync + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cardsync"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ./backend + container_name: cardsync_backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql://cardsync:${POSTGRES_PASSWORD:-changeme}@postgres:5432/cardsync + SECRET_KEY: ${SECRET_KEY:-supersecretkey_change_in_production} + MS_CLIENT_ID: ${MS_CLIENT_ID} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET} + MS_TENANT_ID: ${MS_TENANT_ID:-common} + APP_BASE_URL: ${APP_BASE_URL:-https://localhost} + ADMIN_EMAILS: ${ADMIN_EMAILS} + depends_on: + postgres: + condition: service_healthy + volumes: + - ./backend:/app + ports: + - "8000:8000" + + frontend: + image: nginx:alpine + container_name: cardsync_frontend + restart: unless-stopped + volumes: + - ./frontend:/usr/share/nginx/html:ro + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - cardsync_certs:/etc/nginx/certs + - ./nginx/certs:/etc/nginx/custom-certs:ro + ports: + - "80:80" + - "443:443" + depends_on: + - backend + # Beim Start: Eigenes Cert aus ./nginx/certs übernehmen falls vorhanden, + # sonst selbstsigniertes generieren, dann nginx starten + command: > + /bin/sh -c " + if [ -f /etc/nginx/custom-certs/cert.pem ] && [ -f /etc/nginx/custom-certs/key.pem ]; then + echo '==> Verwende eigenes Zertifikat aus ./nginx/certs/'; + cp /etc/nginx/custom-certs/cert.pem /etc/nginx/certs/cert.pem; + cp /etc/nginx/custom-certs/key.pem /etc/nginx/certs/key.pem; + chmod 644 /etc/nginx/certs/cert.pem; + chmod 600 /etc/nginx/certs/key.pem; + elif [ ! -f /etc/nginx/certs/cert.pem ]; then + echo '==> Generiere selbstsigniertes Zertifikat (10 Jahre Laufzeit)'; + mkdir -p /etc/nginx/certs; + openssl req -x509 -nodes -newkey rsa:2048 -keyout /etc/nginx/certs/key.pem -out /etc/nginx/certs/cert.pem -days 3650 -subj '/CN=cardsync.local/O=CardSync/C=DE' -addext 'subjectAltName=DNS:cardsync.local,DNS:localhost,IP:127.0.0.1' 2>/dev/null; + chmod 644 /etc/nginx/certs/cert.pem; + chmod 600 /etc/nginx/certs/key.pem; + else + echo '==> Verwende vorhandenes Zertifikat aus Volume'; + fi; + exec nginx -g 'daemon off;' + " + +volumes: + postgres_data: + cardsync_certs: diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..3a576bc --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,1257 @@ + + + + + +CardSync — Admin + + + + + + + + + + + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9df8f3c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,766 @@ + + + + + +CardSync — Synology · Microsoft 365 + + + + + + +
+ +
+
+ +
+ +
+ + +
+
+ +
+
🔄 Synology → Microsoft 365
+ +

Kontakte automatisch
synchronisieren

+

Verbinde dein Synology Adressbuch mit Microsoft 365 — einmalig autorisieren, ab dann läuft alles automatisch.

+ + +
+

Jetzt verbinden

+

Autorisiere den Zugriff auf deine Microsoft-Kontakte — einmalig, sicher per OAuth.

+ +
+
+
1
+ Mit Microsoft-Konto anmelden +
+
+
2
+ Kontakte-Zugriff erlauben +
+
+
3
+ Adressbuch auswählen & Sync startet +
+
+ + + + Mit Microsoft anmelden + + +

🔒 Nur Kontakte werden synchronisiert. Keine Daten werden gespeichert oder weitergegeben.

+
+ + +
+

Dein Sync

+

Angemeldet als

+ +
+ Lade Status… +
+ +
+
Adressbuch auswählen
+ +
+ +
+
Sync-Intervall
+ +
+ +
+
+
Automatischer Sync
+
Kontakte werden regelmäßig synchronisiert
+
+ +
+ +
+ + +
+
+ +
+
+
🔒
+
Sicher
+
OAuth 2.0 — keine Passwörter gespeichert
+
+
+
+
Automatisch
+
Konfigurierbare Intervalle ab 15 Minuten
+
+
+
📋
+
Einseitig
+
Nur Synology → MS365, kein Datenverlust
+
+
+
+ +
+ System läuft + CardSync — Self-hosted +
+
+ + + + diff --git a/frontend/success.html b/frontend/success.html new file mode 100644 index 0000000..9586677 --- /dev/null +++ b/frontend/success.html @@ -0,0 +1,29 @@ + + + + + +CardSync — Verbunden! + + + + +
+
+

Erfolgreich verbunden!

+

Dein Microsoft-365-Konto ist jetzt mit CardSync verknüpft. Wähle auf der Startseite dein Synology-Adressbuch aus und aktiviere den Sync.

+ Zum Dashboard → +
+ + diff --git a/nginx/certs/README.md b/nginx/certs/README.md new file mode 100644 index 0000000..8e939ba --- /dev/null +++ b/nginx/certs/README.md @@ -0,0 +1,50 @@ +# TLS-Zertifikate + +Dieses Verzeichnis ist für deine eigenen TLS-Zertifikate gedacht. + +## Selbstsigniertes Zertifikat (Standard) + +Wenn dieses Verzeichnis **leer** ist, generiert CardSync beim ersten Start +automatisch ein selbstsigniertes Zertifikat (10 Jahre gültig) für die Hostnamen: +- `cardsync.local` +- `localhost` +- `127.0.0.1` + +Browser zeigen dabei eine Sicherheitswarnung, die du einmalig akzeptieren musst. + +## Eigenes Zertifikat verwenden (Wildcard, Let's Encrypt, etc.) + +Lege deine Dateien hier ab — **exakt mit diesen Namen**: + +``` +nginx/certs/cert.pem ← Public Cert (inkl. Intermediate-Chain bei CA-Certs) +nginx/certs/key.pem ← Private Key (unverschlüsselt) +``` + +Falls dein Provider die Datei anders nennt: +| Datei vom Provider | → umbenennen zu | +|-------------------------------------|-----------------| +| `fullchain.pem` (Let's Encrypt) | `cert.pem` | +| `privkey.pem` (Let's Encrypt) | `key.pem` | +| `cert.crt` + `bundle.crt` | zusammenfügen zu `cert.pem` (cert zuerst, dann bundle) | +| `*.pfx` (Windows) | siehe Konvertierung unten | + +## PFX/P12 nach PEM konvertieren + +Falls du nur eine `.pfx`-Datei hast (typisch bei Windows-Wildcard-Certs): + +```bash +# Cert extrahieren +openssl pkcs12 -in dein-zertifikat.pfx -clcerts -nokeys -out cert.pem + +# Key extrahieren (entschlüsselt) +openssl pkcs12 -in dein-zertifikat.pfx -nocerts -nodes -out key.pem +``` + +## Nach Cert-Tausch + +```bash +docker compose restart frontend +``` + +Das ist alles — CardSync übernimmt das neue Zertifikat automatisch. diff --git a/nginx/entrypoint.sh b/nginx/entrypoint.sh new file mode 100755 index 0000000..25015a1 --- /dev/null +++ b/nginx/entrypoint.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# CardSync Nginx Entrypoint +# Generiert ein selbstsigniertes Zertifikat falls noch keins vorhanden ist. +# Eigene Zertifikate können einfach in ./nginx/certs/ als cert.pem + key.pem +# abgelegt werden — diese werden dann verwendet. + +set -e + +CERT_DIR="/etc/nginx/certs" +CERT_FILE="$CERT_DIR/cert.pem" +KEY_FILE="$CERT_DIR/key.pem" + +mkdir -p "$CERT_DIR" + +if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then + echo "==> Kein TLS-Zertifikat gefunden — generiere selbstsigniertes Cert..." + + # OpenSSL ist im nginx:alpine Image bereits vorhanden + openssl req -x509 -nodes -newkey rsa:2048 \ + -keyout "$KEY_FILE" \ + -out "$CERT_FILE" \ + -days 3650 \ + -subj "/CN=cardsync.local/O=CardSync/C=DE" \ + -addext "subjectAltName=DNS:cardsync.local,DNS:localhost,IP:127.0.0.1" \ + 2>/dev/null + + chmod 644 "$CERT_FILE" + chmod 600 "$KEY_FILE" + + echo "==> Selbstsigniertes Zertifikat erstellt (gültig 10 Jahre)" + echo " Cert: $CERT_FILE" + echo " Key: $KEY_FILE" + echo "" + echo " Um ein eigenes Zertifikat zu verwenden:" + echo " - Lege cert.pem + key.pem in ./nginx/certs/ ab" + echo " - docker compose restart frontend" +else + echo "==> Verwende vorhandenes TLS-Zertifikat aus $CERT_DIR" + # Kurze Cert-Info ausgeben + openssl x509 -in "$CERT_FILE" -noout -subject -issuer -dates 2>/dev/null || true +fi + +# Nginx im Vordergrund starten (Standard-Behavior fortsetzen) +exec nginx -g "daemon off;" diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..016efb5 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,58 @@ +# Redirect HTTP → HTTPS +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +# HTTPS +server { + listen 443 ssl; + http2 on; + server_name _; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + + # Moderne TLS-Konfiguration (Mozilla "intermediate") + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + # HSTS (1 Jahr) + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Frontend (statische Dateien) + root /usr/share/nginx/html; + index index.html; + + # API zum Backend + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + location /auth/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://backend:8000; + } + + # Frontend-Dateien + location / { + try_files $uri $uri/ /index.html; + } +}