First Commit with complete Setup coded by Claude 4.7 Opus

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

25
.env.example Normal file
View file

@ -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

56
.gitignore vendored Normal file
View file

@ -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

207
README.md Normal file
View file

@ -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 <dieses-repo>
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
```

14
backend/Dockerfile Normal file
View file

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

391
backend/carddav_client.py Normal file
View file

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

122
backend/group_sync.py Normal file
View file

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

572
backend/main.py Normal file
View file

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

109
backend/models.py Normal file
View file

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

275
backend/ms_graph.py Normal file
View file

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

14
backend/requirements.txt Normal file
View file

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

79
backend/scheduler.py Normal file
View file

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

398
backend/sync_engine.py Normal file
View file

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

78
docker-compose.yml Normal file
View file

@ -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:

1257
frontend/admin.html Normal file

File diff suppressed because it is too large Load diff

766
frontend/index.html Normal file
View file

@ -0,0 +1,766 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CardSync — Synology · Microsoft 365</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0e0f14;
--surface: #16181f;
--surface2: #1e2029;
--border: #2a2d38;
--accent: #4f8ef7;
--accent2: #7c5cfc;
--success: #3ecf8e;
--error: #f76c6c;
--text: #e8eaf0;
--muted: #7a7f96;
--ms-blue: #0078d4;
}
html { height: 100%; }
body {
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 15px;
line-height: 1.6;
display: flex;
flex-direction: column;
}
/* Background grid */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(79,142,247,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(79,142,247,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Glow blobs */
.blob {
position: fixed;
border-radius: 50%;
filter: blur(120px);
opacity: 0.12;
pointer-events: none;
z-index: 0;
}
.blob-1 {
width: 500px; height: 500px;
background: var(--accent);
top: -150px; left: -100px;
animation: drift 12s ease-in-out infinite alternate;
}
.blob-2 {
width: 400px; height: 400px;
background: var(--accent2);
bottom: -100px; right: -100px;
animation: drift 15s ease-in-out infinite alternate-reverse;
}
@keyframes drift {
from { transform: translate(0, 0); }
to { transform: translate(30px, 20px); }
}
/* Layout */
.page {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
padding: 24px 40px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
backdrop-filter: blur(10px);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
}
.logo-icon {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.logo-text {
font-family: 'DM Serif Display', serif;
font-size: 20px;
color: var(--text);
letter-spacing: -0.3px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.btn-ghost {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--muted);
font-family: 'DM Sans', sans-serif;
font-size: 14px;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.btn-ghost:hover {
border-color: var(--accent);
color: var(--text);
}
/* Hero */
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: rgba(79,142,247,0.08);
border: 1px solid rgba(79,142,247,0.2);
border-radius: 100px;
font-size: 12px;
font-weight: 500;
color: var(--accent);
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 32px;
}
h1 {
font-family: 'DM Serif Display', serif;
font-size: clamp(36px, 6vw, 64px);
line-height: 1.1;
text-align: center;
letter-spacing: -1px;
max-width: 800px;
margin-bottom: 20px;
}
h1 em {
font-style: italic;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
text-align: center;
color: var(--muted);
font-size: 17px;
max-width: 520px;
margin-bottom: 48px;
font-weight: 300;
}
/* Connect Card */
.connect-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
padding: 40px;
width: 100%;
max-width: 460px;
position: relative;
overflow: hidden;
}
.connect-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
}
.card-title {
font-family: 'DM Serif Display', serif;
font-size: 22px;
margin-bottom: 8px;
}
.card-desc {
color: var(--muted);
font-size: 14px;
margin-bottom: 28px;
}
.flow-steps {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 28px;
}
.flow-step {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--surface2);
border-radius: 10px;
border: 1px solid var(--border);
font-size: 13px;
color: var(--muted);
}
.step-num {
width: 22px; height: 22px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.btn-ms {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 24px;
background: var(--ms-blue);
color: white;
border: none;
border-radius: 10px;
font-family: 'DM Sans', sans-serif;
font-size: 15px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
box-shadow: 0 4px 20px rgba(0,120,212,0.3);
}
.btn-ms:hover {
background: #106ebe;
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(0,120,212,0.4);
}
.btn-ms:active { transform: translateY(0); }
.ms-logo {
width: 20px; height: 20px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
}
.ms-logo span {
display: block;
border-radius: 1px;
}
.ms-logo span:nth-child(1) { background: #f25022; }
.ms-logo span:nth-child(2) { background: #7fba00; }
.ms-logo span:nth-child(3) { background: #00a4ef; }
.ms-logo span:nth-child(4) { background: #ffb900; }
.privacy-note {
text-align: center;
font-size: 12px;
color: var(--muted);
margin-top: 16px;
}
/* Status (logged in view) */
#status-view { display: none; }
.status-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
}
.status-bar.success { background: rgba(62,207,142,0.1); color: var(--success); border: 1px solid rgba(62,207,142,0.2); }
.status-bar.error { background: rgba(247,108,108,0.1); color: var(--error); border: 1px solid rgba(247,108,108,0.2); }
.status-bar.info { background: rgba(79,142,247,0.1); color: var(--accent); border: 1px solid rgba(79,142,247,0.2); }
.status-bar.running { background: rgba(255,185,0,0.1); color: #ffb900; border: 1px solid rgba(255,185,0,0.2); }
.select-field, .input-field {
width: 100%;
padding: 10px 14px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.select-field:focus, .input-field:focus { border-color: var(--accent); }
.select-field option { background: var(--surface2); }
.field-label {
font-size: 12px;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.field-group { margin-bottom: 16px; }
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: white;
border: none;
border-radius: 8px;
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
font-family: 'DM Sans', sans-serif;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover { border-color: var(--accent); }
.btn-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 20px;
}
.toggle-wrap {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--surface2);
border-radius: 10px;
border: 1px solid var(--border);
margin-bottom: 16px;
}
.toggle-label { font-size: 14px; font-weight: 500; }
.toggle-sub { font-size: 12px; color: var(--muted); }
.toggle {
position: relative;
width: 42px; height: 24px;
flex-shrink: 0;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
inset: 0;
background: var(--border);
border-radius: 12px;
cursor: pointer;
transition: 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 18px; height: 18px;
left: 3px; top: 3px;
background: white;
border-radius: 50%;
transition: 0.2s;
}
.toggle input:checked + .toggle-slider { background: var(--accent); }
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); }
/* Features strip */
.features {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
max-width: 700px;
width: 100%;
margin-top: 60px;
}
.feature {
text-align: center;
padding: 24px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
}
.feature-icon {
font-size: 28px;
margin-bottom: 10px;
}
.feature-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.feature-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
/* Footer */
footer {
padding: 20px 40px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--muted);
position: relative;
z-index: 1;
}
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); display: inline-block; margin-right: 6px; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:0.3} }
@media (max-width: 600px) {
header { padding: 16px 20px; }
.features { grid-template-columns: 1fr; }
.connect-card { padding: 28px 20px; }
footer { flex-direction: column; gap: 8px; }
}
</style>
</head>
<body>
<div class="page">
<!-- Blobs -->
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<header>
<a class="logo" href="/">
<div class="logo-icon"></div>
<span class="logo-text">CardSync</span>
</a>
<div class="header-right">
<a id="admin-link" href="/auth/login?mode=admin" class="btn-ghost" style="display:none">Admin</a>
<button id="logout-btn" class="btn-ghost" style="display:none" onclick="logout()">Abmelden</button>
</div>
</header>
<main>
<div class="badge">🔄 Synology → Microsoft 365</div>
<h1>Kontakte <em>automatisch</em><br>synchronisieren</h1>
<p class="subtitle">Verbinde dein Synology Adressbuch mit Microsoft 365 — einmalig autorisieren, ab dann läuft alles automatisch.</p>
<!-- Login View -->
<div id="login-view" class="connect-card">
<h2 class="card-title">Jetzt verbinden</h2>
<p class="card-desc">Autorisiere den Zugriff auf deine Microsoft-Kontakte — einmalig, sicher per OAuth.</p>
<div class="flow-steps">
<div class="flow-step">
<div class="step-num">1</div>
<span>Mit Microsoft-Konto anmelden</span>
</div>
<div class="flow-step">
<div class="step-num">2</div>
<span>Kontakte-Zugriff erlauben</span>
</div>
<div class="flow-step">
<div class="step-num">3</div>
<span>Adressbuch auswählen & Sync startet</span>
</div>
</div>
<a href="/auth/login" class="btn-ms">
<div class="ms-logo">
<span></span><span></span><span></span><span></span>
</div>
Mit Microsoft anmelden
</a>
<p class="privacy-note">🔒 Nur Kontakte werden synchronisiert. Keine Daten werden gespeichert oder weitergegeben.</p>
</div>
<!-- Logged-in View -->
<div id="status-view" class="connect-card" style="max-width:500px">
<h2 class="card-title">Dein Sync</h2>
<p class="card-desc">Angemeldet als <strong id="user-email"></strong></p>
<div id="sync-status-bar" class="status-bar info" style="margin-bottom:20px">
<span id="sync-status-text">Lade Status…</span>
</div>
<div class="field-group">
<div class="field-label">Adressbuch auswählen</div>
<select id="folder-select" class="select-field">
<option value="">Wird geladen…</option>
</select>
</div>
<div class="field-group">
<div class="field-label">Sync-Intervall</div>
<select id="interval-select" class="select-field">
<option value="15">Alle 15 Minuten</option>
<option value="30">Alle 30 Minuten</option>
<option value="60" selected>Stündlich</option>
<option value="360">Alle 6 Stunden</option>
<option value="720">Alle 12 Stunden</option>
<option value="1440">Täglich</option>
</select>
</div>
<div class="toggle-wrap">
<div>
<div class="toggle-label">Automatischer Sync</div>
<div class="toggle-sub">Kontakte werden regelmäßig synchronisiert</div>
</div>
<label class="toggle">
<input type="checkbox" id="sync-toggle" onchange="toggleSync()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="btn-row">
<button class="btn-primary" onclick="saveSettings()">💾 Einstellungen speichern</button>
<button class="btn-secondary" onclick="syncNow()" id="sync-btn">⚡ Jetzt synchronisieren</button>
</div>
</div>
<div class="features">
<div class="feature">
<div class="feature-icon">🔒</div>
<div class="feature-title">Sicher</div>
<div class="feature-desc">OAuth 2.0 — keine Passwörter gespeichert</div>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-title">Automatisch</div>
<div class="feature-desc">Konfigurierbare Intervalle ab 15 Minuten</div>
</div>
<div class="feature">
<div class="feature-icon">📋</div>
<div class="feature-title">Einseitig</div>
<div class="feature-desc">Nur Synology → MS365, kein Datenverlust</div>
</div>
</div>
</main>
<footer>
<span><span class="dot"></span>System läuft</span>
<span>CardSync &mdash; Self-hosted</span>
</footer>
</div>
<script>
const API = '';
async function init() {
try {
const resp = await fetch(`${API}/auth/me`, { credentials: 'include' });
if (!resp.ok) throw new Error('not logged in');
const me = await resp.json();
document.getElementById('login-view').style.display = 'none';
document.getElementById('status-view').style.display = 'block';
document.getElementById('user-email').textContent = me.email;
document.getElementById('logout-btn').style.display = 'inline-flex';
if (me.is_admin) {
document.getElementById('admin-link').style.display = 'inline-flex';
}
// Sync-Status anzeigen
updateStatusBar(me.last_sync_status, me.last_sync_message, me.last_sync_at);
// Einstellungen laden
if (me.sync_interval_minutes) {
document.getElementById('interval-select').value = me.sync_interval_minutes;
}
document.getElementById('sync-toggle').checked = me.sync_enabled;
// Ordner laden
await loadFolders(me.carddav_folder);
} catch (e) {
document.getElementById('login-view').style.display = 'block';
document.getElementById('status-view').style.display = 'none';
}
}
function updateStatusBar(status, message, lastSyncAt) {
const bar = document.getElementById('sync-status-bar');
const text = document.getElementById('sync-status-text');
bar.className = 'status-bar';
if (!status) {
bar.classList.add('info');
text.textContent = 'Noch kein Sync durchgeführt';
return;
}
const timeStr = lastSyncAt ? ` · ${new Date(lastSyncAt).toLocaleString('de-DE')}` : '';
if (status === 'success') {
bar.classList.add('success');
text.textContent = `✓ ${message || 'Erfolgreich synchronisiert'}${timeStr}`;
} else if (status === 'error') {
bar.classList.add('error');
text.textContent = `✗ Fehler: ${message}${timeStr}`;
} else if (status === 'running') {
bar.classList.add('running');
text.textContent = `⟳ Sync läuft…`;
} else {
bar.classList.add('info');
text.textContent = message || status;
}
}
async function loadFolders(currentFolder) {
const sel = document.getElementById('folder-select');
try {
const resp = await fetch(`${API}/api/carddav/folders`, { credentials: 'include' });
if (!resp.ok) {
sel.innerHTML = '<option value="">Synology nicht konfiguriert</option>';
return;
}
const data = await resp.json();
sel.innerHTML = '<option value="">Adressbuch wählen…</option>';
data.folders.forEach(f => {
const opt = document.createElement('option');
opt.value = f.url;
opt.textContent = f.name;
if (f.url === currentFolder) opt.selected = true;
sel.appendChild(opt);
});
} catch {
sel.innerHTML = '<option value="">Fehler beim Laden</option>';
}
}
async function saveSettings() {
const folder = document.getElementById('folder-select').value;
const interval = parseInt(document.getElementById('interval-select').value);
const enabled = document.getElementById('sync-toggle').checked;
const resp = await fetch(`${API}/api/user/settings`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ carddav_folder: folder, sync_interval_minutes: interval, sync_enabled: enabled }),
});
if (resp.ok) {
showToast('Einstellungen gespeichert ✓', 'success');
} else {
showToast('Fehler beim Speichern', 'error');
}
}
async function toggleSync() {
await saveSettings();
}
async function syncNow() {
const btn = document.getElementById('sync-btn');
btn.disabled = true;
btn.textContent = '⟳ Läuft…';
try {
const resp = await fetch(`${API}/api/user/sync-now`, {
method: 'POST',
credentials: 'include',
});
const data = await resp.json();
updateStatusBar(data.status, data.message, new Date().toISOString());
showToast(data.status === 'success' ? `Sync abgeschlossen: ${data.message}` : `Fehler: ${data.message}`, data.status);
} catch {
showToast('Verbindungsfehler', 'error');
}
btn.disabled = false;
btn.textContent = '⚡ Jetzt synchronisieren';
}
async function logout() {
await fetch(`${API}/auth/logout`, { method: 'POST', credentials: 'include' });
window.location.reload();
}
function showToast(msg, type = 'info') {
const t = document.createElement('div');
t.style.cssText = `
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
padding: 12px 20px; border-radius: 10px; font-size: 14px; font-weight: 500;
background: ${type === 'success' ? 'rgba(62,207,142,0.15)' : type === 'error' ? 'rgba(247,108,108,0.15)' : 'rgba(79,142,247,0.15)'};
border: 1px solid ${type === 'success' ? 'rgba(62,207,142,0.3)' : type === 'error' ? 'rgba(247,108,108,0.3)' : 'rgba(79,142,247,0.3)'};
color: ${type === 'success' ? '#3ecf8e' : type === 'error' ? '#f76c6c' : '#4f8ef7'};
animation: slideIn 0.3s ease; font-family: 'DM Sans', sans-serif;
`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 4000);
}
init();
</script>
</body>
</html>

29
frontend/success.html Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CardSync — Verbunden!</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --bg:#0e0f14;--accent:#4f8ef7;--accent2:#7c5cfc;--success:#3ecf8e;--text:#e8eaf0;--muted:#7a7f96; }
body { min-height:100vh;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center; }
.card { text-align:center;max-width:440px;padding:48px 40px; }
.icon { font-size:64px;margin-bottom:24px;animation:pop 0.5s cubic-bezier(0.34,1.56,0.64,1); }
@keyframes pop { from{transform:scale(0);opacity:0} to{transform:scale(1);opacity:1} }
h1 { font-family:'DM Serif Display',serif;font-size:32px;margin-bottom:12px;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text; }
p { color:var(--muted);margin-bottom:32px;line-height:1.7;font-size:15px; }
.btn { display:inline-flex;align-items:center;gap:8px;padding:12px 28px;background:linear-gradient(135deg,var(--accent),var(--accent2));color:white;border:none;border-radius:10px;font-family:'DM Sans',sans-serif;font-size:15px;font-weight:500;cursor:pointer;text-decoration:none;transition:opacity 0.2s; }
.btn:hover { opacity:0.9; }
</style>
</head>
<body>
<div class="card">
<div class="icon"></div>
<h1>Erfolgreich verbunden!</h1>
<p>Dein Microsoft-365-Konto ist jetzt mit CardSync verknüpft. Wähle auf der Startseite dein Synology-Adressbuch aus und aktiviere den Sync.</p>
<a href="/" class="btn">Zum Dashboard →</a>
</div>
</body>
</html>

50
nginx/certs/README.md Normal file
View file

@ -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.

44
nginx/entrypoint.sh Executable file
View file

@ -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;"

58
nginx/nginx.conf Normal file
View file

@ -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;
}
}