First Commit with complete Setup coded by Claude 4.7 Opus
This commit is contained in:
commit
bf1d37de77
19 changed files with 4544 additions and 0 deletions
25
.env.example
Normal file
25
.env.example
Normal 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
56
.gitignore
vendored
Normal 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
207
README.md
Normal 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
14
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
391
backend/carddav_client.py
Normal file
391
backend/carddav_client.py
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import httpx
|
||||
import vobject
|
||||
import logging
|
||||
from typing import Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CARDDAV_NAMESPACES = {
|
||||
"d": "DAV:",
|
||||
"cs": "http://calendarserver.org/ns/",
|
||||
"card": "urn:ietf:params:xml:ns:carddav",
|
||||
}
|
||||
|
||||
|
||||
class CardDAVClient:
|
||||
def __init__(self, server_url: str, username: str, password: str):
|
||||
# Trailing slash entfernen
|
||||
self.base_url = server_url.rstrip("/")
|
||||
self.auth = (username, password)
|
||||
|
||||
def _client(self) -> httpx.Client:
|
||||
return httpx.Client(auth=self.auth, verify=False, timeout=30)
|
||||
|
||||
def discover_principal(self) -> Optional[str]:
|
||||
"""CardDAV Principal-URL ermitteln.
|
||||
|
||||
Strategie:
|
||||
1. Wenn die Server-URL bereits einen Pfad enthält (z.B. /carddav/User),
|
||||
wird sie direkt verwendet — der Admin weiß was er tut.
|
||||
2. Wenn nur Host:Port angegeben ist, wird /.well-known/carddav probiert,
|
||||
dann fällt es auf /carddav zurück.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(self.base_url)
|
||||
path = (parsed.path or "").strip("/")
|
||||
|
||||
# Wenn URL schon einen Pfad hat -> direkt verwenden
|
||||
if path:
|
||||
return self.base_url
|
||||
|
||||
# Sonst: Discovery versuchen
|
||||
try:
|
||||
with self._client() as client:
|
||||
resp = client.request(
|
||||
"PROPFIND",
|
||||
f"{self.base_url}/.well-known/carddav",
|
||||
headers={"Depth": "0", "Content-Type": "application/xml"},
|
||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<current-user-principal/>
|
||||
<principal-URL/>
|
||||
</prop>
|
||||
</propfind>""",
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code in (200, 207):
|
||||
return str(resp.url).rstrip("/")
|
||||
except Exception as e:
|
||||
logger.warning(f"Well-known discovery failed: {e}")
|
||||
|
||||
return f"{self.base_url}/carddav"
|
||||
|
||||
def list_address_books(self) -> list[dict]:
|
||||
"""Alle verfügbaren Adressbücher auflisten"""
|
||||
base = self.discover_principal()
|
||||
body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<prop>
|
||||
<resourcetype/>
|
||||
<displayname/>
|
||||
<cs:getctag/>
|
||||
<card:addressbook-description/>
|
||||
</prop>
|
||||
</propfind>"""
|
||||
results = []
|
||||
try:
|
||||
with self._client() as client:
|
||||
resp = client.request(
|
||||
"PROPFIND",
|
||||
base,
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=body,
|
||||
)
|
||||
if resp.status_code != 207:
|
||||
logger.error(f"PROPFIND failed: {resp.status_code}")
|
||||
return results
|
||||
|
||||
root = ET.fromstring(resp.text)
|
||||
for response in root.findall(".//d:response", CARDDAV_NAMESPACES):
|
||||
href_el = response.find("d:href", CARDDAV_NAMESPACES)
|
||||
rt = response.find(".//d:resourcetype", CARDDAV_NAMESPACES)
|
||||
is_ab = rt is not None and rt.find("card:addressbook", CARDDAV_NAMESPACES) is not None
|
||||
|
||||
if is_ab and href_el is not None:
|
||||
href = href_el.text
|
||||
name_el = response.find(".//d:displayname", CARDDAV_NAMESPACES)
|
||||
name = name_el.text if name_el is not None else href.split("/")[-2]
|
||||
|
||||
# Absolute URL aufbauen
|
||||
if href.startswith("/"):
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(self.base_url)
|
||||
full_url = f"{parsed.scheme}://{parsed.netloc}{href}"
|
||||
else:
|
||||
full_url = href
|
||||
|
||||
results.append({"name": name, "url": full_url, "href": href})
|
||||
except Exception as e:
|
||||
logger.error(f"list_address_books error: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def get_contacts(self, addressbook_url: str) -> list[dict]:
|
||||
"""Alle vCards aus einem Adressbuch laden.
|
||||
|
||||
Verwendet REPORT addressbook-query (RFC 6352) — funktioniert mit Radicale,
|
||||
SOGo, Nextcloud, Baikal und allen anderen RFC-konformen CardDAV-Servern.
|
||||
"""
|
||||
contacts = []
|
||||
|
||||
# REPORT addressbook-query holt URLs + vCard-Daten in einem Call
|
||||
report_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<card:address-data/>
|
||||
</d:prop>
|
||||
<card:filter/>
|
||||
</card:addressbook-query>"""
|
||||
|
||||
try:
|
||||
with self._client() as client:
|
||||
resp = client.request(
|
||||
"REPORT",
|
||||
addressbook_url,
|
||||
headers={
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
},
|
||||
content=report_body,
|
||||
)
|
||||
|
||||
if resp.status_code == 207:
|
||||
contacts = self._parse_vcard_response(resp.text)
|
||||
logger.info(f"REPORT returned {len(contacts)} contacts")
|
||||
if contacts:
|
||||
return contacts
|
||||
else:
|
||||
logger.warning(f"REPORT failed ({resp.status_code}), falling back to PROPFIND+GET")
|
||||
|
||||
# Fallback 1: PROPFIND mit address-data
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<card:address-data/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
resp = client.request(
|
||||
"PROPFIND",
|
||||
addressbook_url,
|
||||
headers={"Depth": "1", "Content-Type": "application/xml; charset=utf-8"},
|
||||
content=propfind_body,
|
||||
)
|
||||
if resp.status_code == 207:
|
||||
contacts = self._parse_vcard_response(resp.text)
|
||||
if contacts:
|
||||
logger.info(f"PROPFIND returned {len(contacts)} contacts")
|
||||
return contacts
|
||||
|
||||
# Fallback 2: PROPFIND nur für hrefs + einzelne GET-Requests
|
||||
logger.info("Falling back to PROPFIND + individual GETs")
|
||||
href_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<d:getcontenttype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
resp = client.request(
|
||||
"PROPFIND",
|
||||
addressbook_url,
|
||||
headers={"Depth": "1", "Content-Type": "application/xml; charset=utf-8"},
|
||||
content=href_body,
|
||||
)
|
||||
if resp.status_code != 207:
|
||||
logger.error(f"href PROPFIND failed: {resp.status_code}")
|
||||
return contacts
|
||||
|
||||
# vCard URLs sammeln
|
||||
from urllib.parse import urlparse
|
||||
parsed_base = urlparse(self.base_url)
|
||||
root = ET.fromstring(resp.text)
|
||||
vcard_urls = []
|
||||
for response in root.findall(".//d:response", CARDDAV_NAMESPACES):
|
||||
href_el = response.find("d:href", CARDDAV_NAMESPACES)
|
||||
ctype_el = response.find(".//d:getcontenttype", CARDDAV_NAMESPACES)
|
||||
if href_el is None:
|
||||
continue
|
||||
href = href_el.text or ""
|
||||
ctype = (ctype_el.text or "") if ctype_el is not None else ""
|
||||
# Nur vCard-Ressourcen, keine Collection
|
||||
if not href.endswith(".vcf") and "vcard" not in ctype.lower():
|
||||
continue
|
||||
if href.startswith("/"):
|
||||
full_url = f"{parsed_base.scheme}://{parsed_base.netloc}{href}"
|
||||
elif href.startswith("http"):
|
||||
full_url = href
|
||||
else:
|
||||
full_url = f"{addressbook_url.rstrip('/')}/{href}"
|
||||
vcard_urls.append((href, full_url))
|
||||
|
||||
logger.info(f"Fetching {len(vcard_urls)} vCards individually")
|
||||
for href, url in vcard_urls:
|
||||
try:
|
||||
r = client.get(url)
|
||||
if r.status_code == 200 and "BEGIN:VCARD" in r.text:
|
||||
parsed = parse_vcard(r.text)
|
||||
if parsed:
|
||||
parsed["_href"] = href
|
||||
parsed["_raw"] = r.text
|
||||
contacts.append(parsed)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to GET vCard {url}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"get_contacts error: {e}", exc_info=True)
|
||||
|
||||
return contacts
|
||||
|
||||
def _parse_vcard_response(self, xml_text: str) -> list[dict]:
|
||||
"""Parst eine 207 Multi-Status Antwort und extrahiert vCards"""
|
||||
contacts = []
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError as e:
|
||||
logger.error(f"XML parse error: {e}")
|
||||
return contacts
|
||||
|
||||
for response in root.findall(".//d:response", CARDDAV_NAMESPACES):
|
||||
href_el = response.find("d:href", CARDDAV_NAMESPACES)
|
||||
etag_el = response.find(".//d:getetag", CARDDAV_NAMESPACES)
|
||||
vcard_el = response.find(".//card:address-data", CARDDAV_NAMESPACES)
|
||||
|
||||
if vcard_el is not None and vcard_el.text:
|
||||
vcard_data = vcard_el.text.strip()
|
||||
if "BEGIN:VCARD" in vcard_data:
|
||||
parsed = parse_vcard(vcard_data)
|
||||
if parsed:
|
||||
parsed["_href"] = href_el.text if href_el is not None else ""
|
||||
parsed["_etag"] = etag_el.text if etag_el is not None else ""
|
||||
parsed["_raw"] = vcard_data
|
||||
contacts.append(parsed)
|
||||
return contacts
|
||||
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Verbindungstest"""
|
||||
try:
|
||||
books = self.list_address_books()
|
||||
if books is not None:
|
||||
return True, f"{len(books)} Adressbuch/Adressbücher gefunden"
|
||||
return False, "Keine Adressbücher gefunden"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def parse_vcard(vcard_text: str) -> Optional[dict]:
|
||||
"""vCard → Dictionary für Microsoft Graph"""
|
||||
try:
|
||||
vcard = vobject.readOne(vcard_text)
|
||||
except Exception as e:
|
||||
logger.warning(f"vCard parse error: {e}")
|
||||
return None
|
||||
|
||||
def get(attr):
|
||||
try:
|
||||
return getattr(vcard, attr).value
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
def get_all(attr):
|
||||
try:
|
||||
return [x.value for x in getattr(vcard, attr + "_list", [])]
|
||||
except (AttributeError, TypeError):
|
||||
return []
|
||||
|
||||
result = {}
|
||||
|
||||
# Name
|
||||
name = get("n")
|
||||
if name:
|
||||
result["givenName"] = name.given or ""
|
||||
result["surname"] = name.family or ""
|
||||
result["middleName"] = name.additional or ""
|
||||
result["title"] = name.prefix or ""
|
||||
result["generation"] = name.suffix or ""
|
||||
|
||||
fn = get("fn")
|
||||
if fn:
|
||||
result["displayName"] = fn
|
||||
elif "givenName" in result or "surname" in result:
|
||||
result["displayName"] = f"{result.get('givenName', '')} {result.get('surname', '')}".strip()
|
||||
|
||||
# E-Mails
|
||||
emails = []
|
||||
try:
|
||||
for email_obj in getattr(vcard, "email_list", []):
|
||||
addr = email_obj.value
|
||||
params = email_obj.params
|
||||
types = [t.lower() for t in params.get("TYPE", [])]
|
||||
address_type = "work" if "work" in types else ("home" if "home" in types else "other")
|
||||
emails.append({"address": addr, "name": result.get("displayName", ""), "type": address_type})
|
||||
except Exception:
|
||||
pass
|
||||
if emails:
|
||||
result["emailAddresses"] = emails
|
||||
|
||||
# Telefon
|
||||
phones_business = []
|
||||
phones_home = []
|
||||
mobile = None
|
||||
try:
|
||||
for tel_obj in getattr(vcard, "tel_list", []):
|
||||
num = tel_obj.value
|
||||
types = [t.lower() for t in tel_obj.params.get("TYPE", [])]
|
||||
if "cell" in types or "mobile" in types:
|
||||
mobile = num
|
||||
elif "home" in types:
|
||||
phones_home.append(num)
|
||||
else:
|
||||
phones_business.append(num)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if phones_business:
|
||||
result["businessPhones"] = phones_business
|
||||
if phones_home:
|
||||
result["homePhones"] = phones_home
|
||||
if mobile:
|
||||
result["mobilePhone"] = mobile
|
||||
|
||||
# Organisation
|
||||
org = get("org")
|
||||
if org:
|
||||
if isinstance(org, list):
|
||||
result["companyName"] = org[0] if org else ""
|
||||
result["department"] = org[1] if len(org) > 1 else ""
|
||||
else:
|
||||
result["companyName"] = str(org)
|
||||
|
||||
title = get("title")
|
||||
if title:
|
||||
result["jobTitle"] = title
|
||||
|
||||
# Adresse
|
||||
try:
|
||||
for adr_obj in getattr(vcard, "adr_list", []):
|
||||
adr = adr_obj.value
|
||||
types = [t.lower() for t in adr_obj.params.get("TYPE", [])]
|
||||
addr_dict = {
|
||||
"street": adr.street or "",
|
||||
"city": adr.city or "",
|
||||
"state": adr.region or "",
|
||||
"postalCode": adr.code or "",
|
||||
"countryOrRegion": adr.country or "",
|
||||
}
|
||||
if "home" in types:
|
||||
result["homeAddress"] = addr_dict
|
||||
else:
|
||||
result["businessAddress"] = addr_dict
|
||||
break # nur erste Adresse
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Notizen
|
||||
note = get("note")
|
||||
if note:
|
||||
result["personalNotes"] = note
|
||||
|
||||
# URL
|
||||
url = get("url")
|
||||
if url:
|
||||
result["businessHomePage"] = url
|
||||
|
||||
# UID für Identifizierung
|
||||
uid = get("uid")
|
||||
result["_uid"] = uid or ""
|
||||
|
||||
return result
|
||||
122
backend/group_sync.py
Normal file
122
backend/group_sync.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Synchronisation von Azure AD Gruppenmitgliedern → CardSync User-Tabelle.
|
||||
|
||||
Holt alle Mitglieder einer Azure AD Gruppe via Microsoft Graph (Application Permissions)
|
||||
und legt sie als User in CardSync an. Bestehende User werden aktualisiert,
|
||||
nicht mehr in der Gruppe vorhandene User werden NICHT automatisch gelöscht
|
||||
(nur deren Sync deaktiviert, damit es kein Datenverlust gibt).
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import User, AzureGroupConfig
|
||||
from ms_graph import get_group_info, get_group_members
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def sync_group_members(db: Session) -> dict:
|
||||
"""Holt alle Gruppenmitglieder und legt User in der DB an / aktualisiert sie."""
|
||||
config = db.query(AzureGroupConfig).first()
|
||||
if not config:
|
||||
return {"status": "error", "message": "Keine Azure-Gruppen-Konfiguration vorhanden"}
|
||||
|
||||
started = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
# Gruppen-Info aktualisieren
|
||||
group_info = await get_group_info(config.group_id)
|
||||
if group_info:
|
||||
config.group_name = group_info.get("displayName", config.group_name)
|
||||
|
||||
members = await get_group_members(config.group_id)
|
||||
logger.info(f"Azure AD: {len(members)} Mitglieder in Gruppe '{config.group_name}' gefunden")
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
deactivated = 0
|
||||
|
||||
# Set für die Erkennung entfernter Mitglieder
|
||||
current_member_ids = set()
|
||||
|
||||
for m in members:
|
||||
ms_user_id = m.get("id")
|
||||
email = (m.get("mail") or m.get("userPrincipalName") or "").lower()
|
||||
display_name = m.get("displayName", "")
|
||||
|
||||
if not ms_user_id or not email:
|
||||
continue
|
||||
|
||||
current_member_ids.add(ms_user_id)
|
||||
|
||||
user = db.query(User).filter(User.ms_user_id == ms_user_id).first()
|
||||
if not user:
|
||||
# Mit E-Mail als Fallback suchen
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
if user:
|
||||
# Existierender User → Stammdaten aktualisieren
|
||||
if user.ms_user_id != ms_user_id:
|
||||
user.ms_user_id = ms_user_id
|
||||
if user.email != email:
|
||||
user.email = email
|
||||
if user.display_name != display_name and display_name:
|
||||
user.display_name = display_name
|
||||
|
||||
# Auto-Aktivierung NUR für reine Group-Import-User
|
||||
# OAuth-User behalten ihre individuellen Einstellungen
|
||||
if user.source == "group_import":
|
||||
if not user.sync_enabled and config.default_sync_enabled:
|
||||
user.sync_enabled = True
|
||||
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
updated += 1
|
||||
else:
|
||||
# Neuer User
|
||||
new_user = User(
|
||||
ms_user_id=ms_user_id,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
source="group_import",
|
||||
sync_enabled=config.default_sync_enabled,
|
||||
carddav_folder=config.default_carddav_folder,
|
||||
sync_interval_minutes=config.default_sync_interval_minutes,
|
||||
)
|
||||
db.add(new_user)
|
||||
created += 1
|
||||
logger.info(f"Neuer User aus Gruppe importiert: {email}")
|
||||
|
||||
# User die nicht mehr in der Gruppe sind → Sync deaktivieren
|
||||
# (nur für group_import User, OAuth-User nicht anfassen)
|
||||
all_imported = db.query(User).filter(User.source == "group_import").all()
|
||||
for u in all_imported:
|
||||
if u.ms_user_id not in current_member_ids and u.sync_enabled:
|
||||
u.sync_enabled = False
|
||||
u.last_sync_message = "Aus Azure AD Gruppe entfernt — Sync automatisch deaktiviert"
|
||||
u.updated_at = datetime.now(timezone.utc)
|
||||
deactivated += 1
|
||||
logger.info(f"Sync deaktiviert für {u.email} (nicht mehr in Gruppe)")
|
||||
|
||||
config.last_member_sync_at = started
|
||||
config.last_member_sync_status = "success"
|
||||
config.last_member_sync_message = (
|
||||
f"{created} neu, {updated} aktualisiert, {deactivated} deaktiviert"
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": config.last_member_sync_message,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"deactivated": deactivated,
|
||||
"total_members": len(members),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Group member sync failed: {e}", exc_info=True)
|
||||
config.last_member_sync_at = started
|
||||
config.last_member_sync_status = "error"
|
||||
config.last_member_sync_message = str(e)
|
||||
db.commit()
|
||||
return {"status": "error", "message": str(e)}
|
||||
572
backend/main.py
Normal file
572
backend/main.py
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
import os
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import jwt, JWTError
|
||||
|
||||
from models import create_tables, get_db, User, SynologyConfig, SyncLog, AzureGroupConfig
|
||||
from ms_graph import (
|
||||
get_auth_url, exchange_code_for_tokens, get_ms_user_info, get_valid_token,
|
||||
get_app_token, get_group_info,
|
||||
)
|
||||
from carddav_client import CardDAVClient
|
||||
from sync_engine import sync_user_contacts, invalidate_carddav_cache
|
||||
from group_sync import sync_group_members
|
||||
from scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "fallback_secret")
|
||||
ADMIN_EMAILS = [e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()]
|
||||
APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
TOKEN_EXPIRE_HOURS = 24
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
create_tables()
|
||||
start_scheduler()
|
||||
logger.info("CardSync gestartet")
|
||||
yield
|
||||
stop_scheduler()
|
||||
logger.info("CardSync gestoppt")
|
||||
|
||||
|
||||
app = FastAPI(title="CardSync", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ── JWT Utilities ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_session_token(email: str, is_admin: bool) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRE_HOURS)
|
||||
return jwt.encode({"sub": email, "admin": is_admin, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
|
||||
|
||||
def get_current_user_email(request: Request) -> Optional[str]:
|
||||
token = request.cookies.get("session") or request.headers.get("X-Session-Token")
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
return payload.get("sub")
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def require_admin(request: Request):
|
||||
token = request.cookies.get("session") or request.headers.get("X-Session-Token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
if not payload.get("admin"):
|
||||
raise HTTPException(status_code=403, detail="Nur Admins haben Zugriff")
|
||||
return payload.get("sub")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Ungültiger Token")
|
||||
|
||||
|
||||
# ── Auth Routen ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/auth/login")
|
||||
async def login(mode: str = Query(default="user")):
|
||||
"""Startet Microsoft OAuth Flow"""
|
||||
state = f"{mode}:{secrets.token_urlsafe(16)}"
|
||||
url = get_auth_url(state=state)
|
||||
return RedirectResponse(url)
|
||||
|
||||
|
||||
@app.get("/auth/callback")
|
||||
async def auth_callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Microsoft OAuth Callback"""
|
||||
try:
|
||||
tokens = await exchange_code_for_tokens(code)
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
return RedirectResponse(f"{APP_BASE_URL}/?error=auth_failed")
|
||||
|
||||
try:
|
||||
user_info = await get_ms_user_info(tokens["access_token"])
|
||||
except Exception as e:
|
||||
logger.error(f"Get user info failed: {e}")
|
||||
return RedirectResponse(f"{APP_BASE_URL}/?error=userinfo_failed")
|
||||
|
||||
email = (user_info.get("mail") or user_info.get("userPrincipalName", "")).lower()
|
||||
ms_user_id = user_info.get("id", "")
|
||||
display_name = user_info.get("displayName", "")
|
||||
|
||||
# Benutzer anlegen oder aktualisieren
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
user = User(email=email, ms_user_id=ms_user_id, display_name=display_name)
|
||||
db.add(user)
|
||||
|
||||
user.ms_access_token = tokens.get("access_token")
|
||||
user.ms_refresh_token = tokens.get("refresh_token")
|
||||
expires_in = tokens.get("expires_in", 3600)
|
||||
user.token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
is_admin = email in ADMIN_EMAILS
|
||||
session_token = create_session_token(email, is_admin)
|
||||
|
||||
# Weiterleitung
|
||||
mode = state.split(":")[0] if ":" in state else "user"
|
||||
if is_admin and mode == "admin":
|
||||
redirect_url = f"{APP_BASE_URL}/admin.html"
|
||||
else:
|
||||
redirect_url = f"{APP_BASE_URL}/success.html"
|
||||
|
||||
response = RedirectResponse(redirect_url)
|
||||
response.set_cookie(
|
||||
"session", session_token,
|
||||
httponly=True, samesite="lax",
|
||||
max_age=TOKEN_EXPIRE_HOURS * 3600
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/auth/me")
|
||||
async def get_me(request: Request, db: Session = Depends(get_db)):
|
||||
"""Gibt Info zum aktuell angemeldeten Benutzer zurück"""
|
||||
email = get_current_user_email(request)
|
||||
if not email:
|
||||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
|
||||
|
||||
token = request.cookies.get("session") or request.headers.get("X-Session-Token")
|
||||
payload = decode_token(token)
|
||||
|
||||
return {
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"is_admin": payload.get("admin", False),
|
||||
"sync_enabled": user.sync_enabled,
|
||||
"carddav_folder": user.carddav_folder,
|
||||
"sync_interval_minutes": user.sync_interval_minutes,
|
||||
"last_sync_at": user.last_sync_at.isoformat() if user.last_sync_at else None,
|
||||
"last_sync_status": user.last_sync_status,
|
||||
"last_sync_message": user.last_sync_message,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/auth/logout")
|
||||
async def logout():
|
||||
response = JSONResponse({"ok": True})
|
||||
response.delete_cookie("session")
|
||||
return response
|
||||
|
||||
|
||||
# ── CardDAV Routen ─────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/carddav/folders")
|
||||
async def list_folders(request: Request, db: Session = Depends(get_db)):
|
||||
"""Listet verfügbare CardDAV-Ordner auf"""
|
||||
email = get_current_user_email(request)
|
||||
if not email:
|
||||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||||
|
||||
config = db.query(SynologyConfig).first()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="Keine Synology-Konfiguration vorhanden")
|
||||
|
||||
client = CardDAVClient(config.server_url, config.username, config.password)
|
||||
folders = client.list_address_books()
|
||||
return {"folders": folders}
|
||||
|
||||
|
||||
# ── Benutzer Einstellungen ─────────────────────────────────────────────────
|
||||
|
||||
class UserSettings(BaseModel):
|
||||
carddav_folder: Optional[str] = None
|
||||
sync_interval_minutes: Optional[int] = 60
|
||||
sync_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@app.post("/api/user/settings")
|
||||
async def update_user_settings(
|
||||
settings: UserSettings,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
email = get_current_user_email(request)
|
||||
if not email:
|
||||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
if settings.carddav_folder is not None:
|
||||
user.carddav_folder = settings.carddav_folder
|
||||
if settings.sync_interval_minutes is not None:
|
||||
user.sync_interval_minutes = max(5, settings.sync_interval_minutes)
|
||||
if settings.sync_enabled is not None:
|
||||
user.sync_enabled = settings.sync_enabled
|
||||
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/user/sync-now")
|
||||
async def trigger_sync_now(request: Request, db: Session = Depends(get_db)):
|
||||
"""Manuellen Sync für den angemeldeten Benutzer starten"""
|
||||
email = get_current_user_email(request)
|
||||
if not email:
|
||||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
result = await sync_user_contacts(user.id, db)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/user/sync-logs")
|
||||
async def get_sync_logs(request: Request, db: Session = Depends(get_db)):
|
||||
email = get_current_user_email(request)
|
||||
if not email:
|
||||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
logs = db.query(SyncLog).filter(SyncLog.user_id == user.id)\
|
||||
.order_by(SyncLog.started_at.desc()).limit(20).all()
|
||||
|
||||
return {"logs": [
|
||||
{
|
||||
"started_at": l.started_at.isoformat() if l.started_at else None,
|
||||
"finished_at": l.finished_at.isoformat() if l.finished_at else None,
|
||||
"status": l.status,
|
||||
"contacts_synced": l.contacts_synced,
|
||||
"contacts_created": l.contacts_created,
|
||||
"contacts_updated": l.contacts_updated,
|
||||
"contacts_deleted": l.contacts_deleted,
|
||||
"error_message": l.error_message,
|
||||
}
|
||||
for l in logs
|
||||
]}
|
||||
|
||||
|
||||
# ── Admin Routen ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/admin/config")
|
||||
async def get_admin_config(admin=Depends(require_admin), db: Session = Depends(get_db)):
|
||||
config = db.query(SynologyConfig).first()
|
||||
if not config:
|
||||
return {"configured": False}
|
||||
return {
|
||||
"configured": True,
|
||||
"server_url": config.server_url,
|
||||
"username": config.username,
|
||||
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class SynologyConfigInput(BaseModel):
|
||||
server_url: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@app.post("/api/admin/config")
|
||||
async def save_admin_config(
|
||||
data: SynologyConfigInput,
|
||||
admin=Depends(require_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
config = db.query(SynologyConfig).first()
|
||||
if config:
|
||||
config.server_url = data.server_url
|
||||
config.username = data.username
|
||||
config.password = data.password
|
||||
config.updated_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
config = SynologyConfig(
|
||||
server_url=data.server_url,
|
||||
username=data.username,
|
||||
password=data.password,
|
||||
)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/admin/config/test")
|
||||
async def test_config(
|
||||
data: SynologyConfigInput,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
client = CardDAVClient(data.server_url, data.username, data.password)
|
||||
ok, msg = client.test_connection()
|
||||
return {"ok": ok, "message": msg}
|
||||
|
||||
|
||||
# ── Azure AD Gruppen-Konfiguration ─────────────────────────────────────────
|
||||
|
||||
class AzureGroupConfigInput(BaseModel):
|
||||
group_id: str
|
||||
auto_sync_members: Optional[bool] = True
|
||||
member_sync_interval_hours: Optional[int] = 6
|
||||
default_carddav_folder: Optional[str] = None
|
||||
default_sync_interval_minutes: Optional[int] = 60
|
||||
default_sync_enabled: Optional[bool] = True
|
||||
|
||||
|
||||
@app.get("/api/admin/azure-group")
|
||||
async def get_azure_group(admin=Depends(require_admin), db: Session = Depends(get_db)):
|
||||
cfg = db.query(AzureGroupConfig).first()
|
||||
if not cfg:
|
||||
return {"configured": False}
|
||||
return {
|
||||
"configured": True,
|
||||
"group_id": cfg.group_id,
|
||||
"group_name": cfg.group_name,
|
||||
"auto_sync_members": cfg.auto_sync_members,
|
||||
"member_sync_interval_hours": cfg.member_sync_interval_hours,
|
||||
"default_carddav_folder": cfg.default_carddav_folder,
|
||||
"default_sync_interval_minutes": cfg.default_sync_interval_minutes,
|
||||
"default_sync_enabled": cfg.default_sync_enabled,
|
||||
"last_member_sync_at": cfg.last_member_sync_at.isoformat() if cfg.last_member_sync_at else None,
|
||||
"last_member_sync_status": cfg.last_member_sync_status,
|
||||
"last_member_sync_message": cfg.last_member_sync_message,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/admin/azure-group")
|
||||
async def save_azure_group(
|
||||
data: AzureGroupConfigInput,
|
||||
admin=Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
cfg = db.query(AzureGroupConfig).first()
|
||||
if cfg:
|
||||
cfg.group_id = data.group_id
|
||||
cfg.auto_sync_members = data.auto_sync_members
|
||||
cfg.member_sync_interval_hours = max(1, data.member_sync_interval_hours or 6)
|
||||
cfg.default_carddav_folder = data.default_carddav_folder
|
||||
cfg.default_sync_interval_minutes = max(5, data.default_sync_interval_minutes or 60)
|
||||
cfg.default_sync_enabled = data.default_sync_enabled
|
||||
cfg.updated_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
cfg = AzureGroupConfig(
|
||||
group_id=data.group_id,
|
||||
auto_sync_members=data.auto_sync_members,
|
||||
member_sync_interval_hours=max(1, data.member_sync_interval_hours or 6),
|
||||
default_carddav_folder=data.default_carddav_folder,
|
||||
default_sync_interval_minutes=max(5, data.default_sync_interval_minutes or 60),
|
||||
default_sync_enabled=data.default_sync_enabled,
|
||||
)
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/admin/azure-group/test")
|
||||
async def test_azure_group(
|
||||
data: AzureGroupConfigInput,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
"""Testet ob die Gruppe per App-Token erreichbar ist + holt Anzahl Mitglieder"""
|
||||
token = await get_app_token()
|
||||
if not token:
|
||||
return {"ok": False, "message": "App-Token konnte nicht abgerufen werden. Sind die Application Permissions in Azure konfiguriert + Admin-Consent erteilt?"}
|
||||
|
||||
info = await get_group_info(data.group_id)
|
||||
if not info:
|
||||
return {"ok": False, "message": "Gruppe nicht gefunden oder keine Berechtigung. GroupMember.Read.All erforderlich."}
|
||||
|
||||
from ms_graph import get_group_members
|
||||
members = await get_group_members(data.group_id)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"Gruppe '{info.get('displayName', '?')}' mit {len(members)} Mitgliedern gefunden",
|
||||
"group_name": info.get("displayName"),
|
||||
"member_count": len(members),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/admin/azure-group/sync-now")
|
||||
async def trigger_group_sync(admin=Depends(require_admin), db: Session = Depends(get_db)):
|
||||
"""Manuell die Gruppenmitglieder importieren"""
|
||||
result = await sync_group_members(db)
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/admin/azure-group")
|
||||
async def delete_azure_group(admin=Depends(require_admin), db: Session = Depends(get_db)):
|
||||
"""Gruppenkonfiguration entfernen (User bleiben bestehen)"""
|
||||
cfg = db.query(AzureGroupConfig).first()
|
||||
if cfg:
|
||||
db.delete(cfg)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User-Liste (erweitert um "source") ──────────────────────────────────────
|
||||
|
||||
@app.get("/api/admin/users")
|
||||
async def list_users(admin=Depends(require_admin), db: Session = Depends(get_db)):
|
||||
users = db.query(User).order_by(User.email).all()
|
||||
return {"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"display_name": u.display_name,
|
||||
"source": getattr(u, "source", "oauth"),
|
||||
"sync_enabled": u.sync_enabled,
|
||||
"carddav_folder": u.carddav_folder,
|
||||
"sync_interval_minutes": u.sync_interval_minutes,
|
||||
"last_sync_at": u.last_sync_at.isoformat() if u.last_sync_at else None,
|
||||
"last_sync_status": u.last_sync_status,
|
||||
"last_sync_message": u.last_sync_message,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
}
|
||||
for u in users
|
||||
]}
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
sync_enabled: Optional[bool] = None
|
||||
carddav_folder: Optional[str] = None
|
||||
sync_interval_minutes: Optional[int] = None
|
||||
|
||||
|
||||
@app.patch("/api/admin/users/{user_id}")
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
data: AdminUserUpdate,
|
||||
admin=Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
|
||||
|
||||
if data.sync_enabled is not None:
|
||||
user.sync_enabled = data.sync_enabled
|
||||
if data.carddav_folder is not None:
|
||||
user.carddav_folder = data.carddav_folder
|
||||
if data.sync_interval_minutes is not None:
|
||||
user.sync_interval_minutes = max(5, data.sync_interval_minutes)
|
||||
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/admin/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
admin=Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/admin/users/{user_id}/sync-now")
|
||||
async def admin_sync_user(
|
||||
user_id: int,
|
||||
admin=Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
result = await sync_user_contacts(user_id, db)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/admin/users/{user_id}/logs")
|
||||
async def admin_user_logs(
|
||||
user_id: int,
|
||||
admin=Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
logs = db.query(SyncLog).filter(SyncLog.user_id == user_id)\
|
||||
.order_by(SyncLog.started_at.desc()).limit(20).all()
|
||||
return {"logs": [
|
||||
{
|
||||
"started_at": l.started_at.isoformat() if l.started_at else None,
|
||||
"finished_at": l.finished_at.isoformat() if l.finished_at else None,
|
||||
"status": l.status,
|
||||
"contacts_synced": l.contacts_synced,
|
||||
"contacts_created": l.contacts_created,
|
||||
"contacts_updated": l.contacts_updated,
|
||||
"contacts_deleted": l.contacts_deleted,
|
||||
"error_message": l.error_message,
|
||||
}
|
||||
for l in logs
|
||||
]}
|
||||
|
||||
|
||||
@app.get("/api/admin/stats")
|
||||
async def admin_stats(admin=Depends(require_admin), db: Session = Depends(get_db)):
|
||||
total_users = db.query(User).count()
|
||||
sync_enabled = db.query(User).filter(User.sync_enabled == True).count()
|
||||
recent_logs = db.query(SyncLog).order_by(SyncLog.started_at.desc()).limit(5).all()
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"sync_enabled_users": sync_enabled,
|
||||
"recent_logs": [
|
||||
{
|
||||
"user_id": l.user_id,
|
||||
"started_at": l.started_at.isoformat() if l.started_at else None,
|
||||
"status": l.status,
|
||||
"contacts_synced": l.contacts_synced,
|
||||
}
|
||||
for l in recent_logs
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/admin/cache/clear")
|
||||
async def clear_cache(admin=Depends(require_admin)):
|
||||
"""CardDAV-Cache komplett leeren — nächste Syncs holen frische Daten."""
|
||||
invalidate_carddav_cache()
|
||||
return {"ok": True, "message": "CardDAV-Cache geleert"}
|
||||
|
||||
|
||||
# ── Health ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "CardSync"}
|
||||
109
backend/models.py
Normal file
109
backend/models.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from sqlalchemy import (
|
||||
Column, String, Integer, Boolean, DateTime, Text, ForeignKey, create_engine
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://cardsync:changeme@localhost:5432/cardsync")
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class SynologyConfig(Base):
|
||||
"""Zentrale Synology-Serverkonfiguration (Admin-seitig)"""
|
||||
__tablename__ = "synology_config"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
server_url = Column(String(500), nullable=False)
|
||||
username = Column(String(255), nullable=False)
|
||||
password = Column(Text, nullable=False)
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AzureGroupConfig(Base):
|
||||
"""Konfiguration für Azure AD Gruppen-basiertes User-Management"""
|
||||
__tablename__ = "azure_group_config"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
group_id = Column(String(255), nullable=False) # Azure AD Group Object ID
|
||||
group_name = Column(String(255)) # Anzeigename (cached)
|
||||
auto_sync_members = Column(Boolean, default=True) # Mitglieder regelmäßig abgleichen
|
||||
member_sync_interval_hours = Column(Integer, default=6) # Wie oft Gruppenmitgliedschaft prüfen
|
||||
default_carddav_folder = Column(String(500)) # Default für neue User
|
||||
default_sync_interval_minutes = Column(Integer, default=60)
|
||||
default_sync_enabled = Column(Boolean, default=True) # Neue User direkt aktiviert?
|
||||
last_member_sync_at = Column(DateTime)
|
||||
last_member_sync_status = Column(String(50))
|
||||
last_member_sync_message = Column(Text)
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Benutzer (per OAuth oder per Azure-AD-Gruppen-Import angelegt)"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ms_user_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
display_name = Column(String(255))
|
||||
|
||||
# Delegated-Mode (OAuth) Tokens — optional, nur wenn User sich selbst angemeldet hat
|
||||
ms_access_token = Column(Text)
|
||||
ms_refresh_token = Column(Text)
|
||||
token_expires_at = Column(DateTime)
|
||||
|
||||
# Wie wurde der User angelegt? "oauth" oder "group_import"
|
||||
source = Column(String(50), default="oauth")
|
||||
|
||||
sync_enabled = Column(Boolean, default=False)
|
||||
carddav_folder = Column(String(500))
|
||||
sync_interval_minutes = Column(Integer, default=60)
|
||||
last_sync_at = Column(DateTime)
|
||||
last_sync_status = Column(String(50))
|
||||
last_sync_message = Column(Text)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
sync_logs = relationship("SyncLog", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class SyncLog(Base):
|
||||
"""Protokoll aller Sync-Vorgänge"""
|
||||
__tablename__ = "sync_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
finished_at = Column(DateTime)
|
||||
status = Column(String(50))
|
||||
contacts_synced = Column(Integer, default=0)
|
||||
contacts_created = Column(Integer, default=0)
|
||||
contacts_updated = Column(Integer, default=0)
|
||||
contacts_deleted = Column(Integer, default=0)
|
||||
error_message = Column(Text)
|
||||
|
||||
user = relationship("User", back_populates="sync_logs")
|
||||
|
||||
|
||||
def create_tables():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Migration für bestehende DBs: neue Spalte "source" ergänzen falls sie fehlt
|
||||
from sqlalchemy import inspect, text
|
||||
inspector = inspect(engine)
|
||||
if "users" in inspector.get_table_names():
|
||||
cols = [c["name"] for c in inspector.get_columns("users")]
|
||||
if "source" not in cols:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN source VARCHAR(50) DEFAULT 'oauth'"))
|
||||
275
backend/ms_graph.py
Normal file
275
backend/ms_graph.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import os
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MS_CLIENT_ID = os.getenv("MS_CLIENT_ID")
|
||||
MS_CLIENT_SECRET = os.getenv("MS_CLIENT_SECRET")
|
||||
MS_TENANT_ID = os.getenv("MS_TENANT_ID", "common")
|
||||
APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost")
|
||||
|
||||
REDIRECT_URI = f"{APP_BASE_URL}/auth/callback"
|
||||
SCOPES = "openid profile email offline_access Contacts.ReadWrite"
|
||||
|
||||
AUTH_URL = f"https://login.microsoftonline.com/{MS_TENANT_ID}/oauth2/v2.0/authorize"
|
||||
TOKEN_URL = f"https://login.microsoftonline.com/{MS_TENANT_ID}/oauth2/v2.0/token"
|
||||
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||
|
||||
# ── App-Token-Cache (in-memory) ────────────────────────────────────────────
|
||||
_app_token_cache: dict = {"token": None, "expires_at": None}
|
||||
|
||||
|
||||
def get_auth_url(state: str = "") -> str:
|
||||
params = {
|
||||
"client_id": MS_CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": SCOPES,
|
||||
"response_mode": "query",
|
||||
"state": state,
|
||||
}
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"{AUTH_URL}?{query}"
|
||||
|
||||
|
||||
async def exchange_code_for_tokens(code: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(TOKEN_URL, data={
|
||||
"client_id": MS_CLIENT_ID,
|
||||
"client_secret": MS_CLIENT_SECRET,
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"grant_type": "authorization_code",
|
||||
})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def refresh_access_token(refresh_token: str) -> Optional[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(TOKEN_URL, data={
|
||||
"client_id": MS_CLIENT_ID,
|
||||
"client_secret": MS_CLIENT_SECRET,
|
||||
"refresh_token": refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
"scope": SCOPES,
|
||||
})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_ms_user_info(access_token: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{GRAPH_BASE}/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_valid_token(user, db) -> Optional[str]:
|
||||
"""Gibt ein gültiges Access Token zurück.
|
||||
|
||||
1. Wenn der User per Gruppen-Import angelegt wurde: App-Token (Application Permissions)
|
||||
2. Wenn der User OAuth-Login gemacht hat: User-Token (mit Refresh)
|
||||
"""
|
||||
# Gruppen-Import User → App-Token nutzen
|
||||
if getattr(user, "source", "oauth") == "group_import":
|
||||
return await get_app_token()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = user.token_expires_at
|
||||
if expires and expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expires and now < expires - timedelta(minutes=5):
|
||||
return user.ms_access_token
|
||||
|
||||
if not user.ms_refresh_token:
|
||||
# Kein Refresh-Token? Versuche App-Token als Fallback
|
||||
return await get_app_token()
|
||||
|
||||
tokens = await refresh_access_token(user.ms_refresh_token)
|
||||
if not tokens:
|
||||
return await get_app_token()
|
||||
|
||||
user.ms_access_token = tokens["access_token"]
|
||||
if "refresh_token" in tokens:
|
||||
user.ms_refresh_token = tokens["refresh_token"]
|
||||
user.token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=tokens.get("expires_in", 3600))
|
||||
db.commit()
|
||||
return user.ms_access_token
|
||||
|
||||
|
||||
# ── Application Permissions (Client Credentials Flow) ──────────────────────
|
||||
|
||||
async def get_app_token() -> Optional[str]:
|
||||
"""Holt ein App-Token via Client Credentials Flow.
|
||||
|
||||
Wird gecacht und automatisch erneuert.
|
||||
Voraussetzung: In Azure App-Registrierung Application Permissions vergeben
|
||||
+ Admin-Consent erteilt:
|
||||
- Contacts.ReadWrite (Application)
|
||||
- User.Read.All (Application)
|
||||
- GroupMember.Read.All (Application)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
cached = _app_token_cache.get("token")
|
||||
expires = _app_token_cache.get("expires_at")
|
||||
|
||||
if cached and expires and now < expires - timedelta(minutes=5):
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(TOKEN_URL, data={
|
||||
"client_id": MS_CLIENT_ID,
|
||||
"client_secret": MS_CLIENT_SECRET,
|
||||
"scope": "https://graph.microsoft.com/.default",
|
||||
"grant_type": "client_credentials",
|
||||
})
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"App-Token Anfrage fehlgeschlagen: {resp.status_code} {resp.text}")
|
||||
return None
|
||||
data = resp.json()
|
||||
token = data["access_token"]
|
||||
_app_token_cache["token"] = token
|
||||
_app_token_cache["expires_at"] = now + timedelta(seconds=data.get("expires_in", 3600))
|
||||
return token
|
||||
except Exception as e:
|
||||
logger.error(f"App-Token Fehler: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Group / User Lookup (App-Token) ─────────────────────────────────────────
|
||||
|
||||
async def get_group_info(group_id: str) -> Optional[dict]:
|
||||
"""Liest Gruppen-Metadaten (Name etc.)"""
|
||||
token = await get_app_token()
|
||||
if not token:
|
||||
return None
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{GRAPH_BASE}/groups/{group_id}?$select=id,displayName,description",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"get_group_info failed: {resp.status_code} {resp.text}")
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_group_members(group_id: str) -> list[dict]:
|
||||
"""Liest alle Mitglieder einer Azure AD Gruppe.
|
||||
|
||||
Folgt automatisch der Paginierung (@odata.nextLink).
|
||||
Filtert auf User-Objekte (keine Gruppen-in-Gruppen, keine Service Principals).
|
||||
"""
|
||||
token = await get_app_token()
|
||||
if not token:
|
||||
return []
|
||||
|
||||
members = []
|
||||
url = f"{GRAPH_BASE}/groups/{group_id}/members?$select=id,displayName,mail,userPrincipalName&$top=100"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
while url:
|
||||
resp = await client.get(url, headers={"Authorization": f"Bearer {token}"})
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"get_group_members failed: {resp.status_code} {resp.text}")
|
||||
break
|
||||
data = resp.json()
|
||||
for m in data.get("value", []):
|
||||
# Nur echte User
|
||||
if m.get("@odata.type", "").lower().endswith("user"):
|
||||
members.append(m)
|
||||
elif "userPrincipalName" in m:
|
||||
members.append(m)
|
||||
url = data.get("@odata.nextLink")
|
||||
|
||||
return members
|
||||
|
||||
|
||||
# ── Microsoft Graph: Contacts ──────────────────────────────────────────────
|
||||
|
||||
def _contacts_endpoint(user_principal: Optional[str]) -> str:
|
||||
"""Wählt den richtigen Contacts-Endpoint.
|
||||
|
||||
user_principal=None → /me/contacts (Delegated)
|
||||
user_principal=email/id → /users/{principal}/contacts (Application)
|
||||
"""
|
||||
if user_principal:
|
||||
return f"{GRAPH_BASE}/users/{user_principal}/contacts"
|
||||
return f"{GRAPH_BASE}/me/contacts"
|
||||
|
||||
|
||||
CONTACT_SELECT = "id,displayName,emailAddresses,businessPhones,mobilePhone,homePhones,jobTitle,companyName,department,businessAddress,homeAddress,birthday,personalNotes,givenName,surname,middleName,nickName,title,generation,imAddresses,fileAs,initials,businessHomePage,spouseName,categories"
|
||||
|
||||
|
||||
async def graph_get_contacts(access_token: str, user_principal: Optional[str] = None) -> list[dict]:
|
||||
"""Alle Kontakte abrufen. user_principal nur bei App-Token nötig."""
|
||||
contacts = []
|
||||
url = f"{_contacts_endpoint(user_principal)}?$top=100&$select={CONTACT_SELECT}"
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
while url:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
contacts.extend(data.get("value", []))
|
||||
url = data.get("@odata.nextLink")
|
||||
|
||||
return contacts
|
||||
|
||||
|
||||
async def graph_create_contact(access_token: str, contact_data: dict, user_principal: Optional[str] = None) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
_contacts_endpoint(user_principal),
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=contact_data,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
logger.error(f"Graph create_contact failed ({resp.status_code}): {resp.text}")
|
||||
logger.error(f"Payload was: {contact_data}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def graph_update_contact(access_token: str, contact_id: str, contact_data: dict, user_principal: Optional[str] = None) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.patch(
|
||||
f"{_contacts_endpoint(user_principal)}/{contact_id}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=contact_data,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
logger.error(f"Graph update_contact failed ({resp.status_code}): {resp.text}")
|
||||
logger.error(f"Payload was: {contact_data}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def graph_delete_contact(access_token: str, contact_id: str, user_principal: Optional[str] = None):
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.delete(
|
||||
f"{_contacts_endpoint(user_principal)}/{contact_id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
sqlalchemy==2.0.36
|
||||
psycopg2-binary==2.9.10
|
||||
alembic==1.14.0
|
||||
httpx==0.28.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-multipart==0.0.20
|
||||
apscheduler==3.10.4
|
||||
vobject==0.9.6.1
|
||||
requests==2.32.3
|
||||
python-dotenv==1.0.1
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.6.1
|
||||
79
backend/scheduler.py
Normal file
79
backend/scheduler.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import SessionLocal, User, AzureGroupConfig
|
||||
from sync_engine import sync_user_contacts
|
||||
from group_sync import sync_group_members
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
|
||||
|
||||
async def run_due_syncs():
|
||||
"""Prüft welche Benutzer synchronisiert werden sollen und startet den Sync"""
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
users = db.query(User).filter(User.sync_enabled == True).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for user in users:
|
||||
interval = user.sync_interval_minutes or 60
|
||||
last = user.last_sync_at
|
||||
|
||||
if last:
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
next_sync = last + timedelta(minutes=interval)
|
||||
if now < next_sync:
|
||||
continue
|
||||
|
||||
logger.info(f"Starte Sync für {user.email}")
|
||||
await sync_user_contacts(user.id, db)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler run_due_syncs error: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def run_due_group_sync():
|
||||
"""Prüft ob Azure AD Gruppenmitgliedschaft erneut synchronisiert werden soll"""
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
config = db.query(AzureGroupConfig).first()
|
||||
if not config or not config.auto_sync_members:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
interval = config.member_sync_interval_hours or 6
|
||||
|
||||
last = config.last_member_sync_at
|
||||
if last:
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
if now < last + timedelta(hours=interval):
|
||||
return
|
||||
|
||||
logger.info(f"Starte Azure AD Gruppen-Sync (alle {interval}h)")
|
||||
await sync_group_members(db)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler group sync error: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
scheduler.add_job(run_due_syncs, "interval", minutes=1, id="sync_job", replace_existing=True)
|
||||
scheduler.add_job(run_due_group_sync, "interval", minutes=10, id="group_sync_job", replace_existing=True)
|
||||
scheduler.start()
|
||||
logger.info("Sync-Scheduler gestartet (Kontakte: 1 Min., Gruppen: 10 Min.)")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
if scheduler.running:
|
||||
scheduler.shutdown()
|
||||
398
backend/sync_engine.py
Normal file
398
backend/sync_engine.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import User, SyncLog, SynologyConfig
|
||||
from carddav_client import CardDAVClient
|
||||
from ms_graph import get_valid_token, graph_get_contacts, graph_create_contact, graph_update_contact, graph_delete_contact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Trennzeichen für UID im personalNotes-Feld (um Synology-UID in MS zu speichern)
|
||||
UID_MARKER = "[cardsync_uid:"
|
||||
|
||||
# ── CardDAV Cache ──────────────────────────────────────────────────────────
|
||||
# Bei vielen Usern die dasselbe Adressbuch synchronisieren wird die Synology
|
||||
# sonst pro Sync-Zyklus N-mal abgefragt. Cache reduziert das auf 1× pro TTL.
|
||||
# Cache pro (server_url, folder_url) — pro Adressbuch separater Eintrag.
|
||||
_carddav_cache: dict = {}
|
||||
_carddav_cache_locks: dict = {}
|
||||
CARDDAV_CACHE_TTL_SECONDS = 300 # 5 Minuten
|
||||
|
||||
|
||||
def _cache_key(server_url: str, folder_url: str) -> str:
|
||||
return f"{server_url}||{folder_url}"
|
||||
|
||||
|
||||
async def _get_cached_carddav_contacts(server_url: str, username: str, password: str, folder_url: str) -> list[dict]:
|
||||
"""Lädt CardDAV-Kontakte aus dem Cache oder frisch — pro (Server, Folder)
|
||||
nur ein paralleler Abruf dank Lock. Folge-Requests warten und bekommen das Ergebnis."""
|
||||
key = _cache_key(server_url, folder_url)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Cache-Hit?
|
||||
entry = _carddav_cache.get(key)
|
||||
if entry:
|
||||
expires_at = entry["expires_at"]
|
||||
if now < expires_at:
|
||||
logger.info(f"CardDAV Cache HIT für {folder_url} (age: {(now - entry['cached_at']).total_seconds():.0f}s)")
|
||||
return entry["contacts"]
|
||||
|
||||
# Lock pro Schlüssel — verhindert dass 170 User gleichzeitig CardDAV abfragen
|
||||
lock = _carddav_cache_locks.setdefault(key, asyncio.Lock())
|
||||
async with lock:
|
||||
# Nochmal prüfen — ein anderer Task hat eventuell schon geladen
|
||||
entry = _carddav_cache.get(key)
|
||||
if entry and now < entry["expires_at"]:
|
||||
logger.info(f"CardDAV Cache HIT (nach Lock) für {folder_url}")
|
||||
return entry["contacts"]
|
||||
|
||||
logger.info(f"CardDAV Cache MISS für {folder_url} — lade frisch")
|
||||
client = CardDAVClient(server_url, username, password)
|
||||
# client.get_contacts ist synchron — in Thread auslagern damit asyncio nicht blockiert
|
||||
contacts = await asyncio.to_thread(client.get_contacts, folder_url)
|
||||
|
||||
_carddav_cache[key] = {
|
||||
"contacts": contacts,
|
||||
"cached_at": now,
|
||||
"expires_at": now + timedelta(seconds=CARDDAV_CACHE_TTL_SECONDS),
|
||||
}
|
||||
return contacts
|
||||
|
||||
|
||||
def invalidate_carddav_cache(folder_url: str = None):
|
||||
"""Cache invalidieren — entweder komplett oder nur für ein Adressbuch."""
|
||||
if folder_url is None:
|
||||
_carddav_cache.clear()
|
||||
logger.info("CardDAV-Cache komplett geleert")
|
||||
else:
|
||||
keys_to_remove = [k for k in _carddav_cache if k.endswith(f"||{folder_url}")]
|
||||
for k in keys_to_remove:
|
||||
del _carddav_cache[k]
|
||||
logger.info(f"CardDAV-Cache invalidiert für {folder_url}")
|
||||
|
||||
|
||||
def _embed_uid(uid: str, notes: str = "") -> str:
|
||||
"""UID in personalNotes einbetten, damit wir MS-Kontakte mit vCards matchen können"""
|
||||
if not uid:
|
||||
return notes
|
||||
marker = f"{UID_MARKER}{uid}]"
|
||||
if marker in (notes or ""):
|
||||
return notes
|
||||
return f"{notes}\n{marker}".strip() if notes else marker
|
||||
|
||||
|
||||
def _extract_uid(notes: str) -> str:
|
||||
"""UID aus personalNotes extrahieren"""
|
||||
if not notes or UID_MARKER not in notes:
|
||||
return ""
|
||||
start = notes.index(UID_MARKER) + len(UID_MARKER)
|
||||
end = notes.index("]", start)
|
||||
return notes[start:end]
|
||||
|
||||
|
||||
def _clean_payload(contact: dict) -> dict:
|
||||
"""Bereinigt das Kontakt-Dict für die Microsoft Graph API.
|
||||
|
||||
- Entfernt interne Felder (mit _ prefix)
|
||||
- Entfernt leere Strings (Graph erwartet null oder weglassen)
|
||||
- Entfernt leere Adress-Objekte (alle Felder leer -> komplettes Objekt weg)
|
||||
- Stellt sicher dass Listen wirklich Listen mit Inhalt sind
|
||||
"""
|
||||
payload = {}
|
||||
|
||||
for k, v in contact.items():
|
||||
if k.startswith("_"):
|
||||
continue
|
||||
|
||||
# Leere Strings -> weg
|
||||
if isinstance(v, str):
|
||||
if v.strip():
|
||||
payload[k] = v
|
||||
continue
|
||||
|
||||
# Adress-Objekte: nur senden wenn min. 1 Feld gefüllt
|
||||
if isinstance(v, dict):
|
||||
cleaned = {sk: sv for sk, sv in v.items() if isinstance(sv, str) and sv.strip()}
|
||||
if cleaned:
|
||||
payload[k] = cleaned
|
||||
continue
|
||||
|
||||
# Listen: leere raus
|
||||
if isinstance(v, list):
|
||||
if k == "emailAddresses":
|
||||
# nur Einträge mit gültiger address behalten
|
||||
cleaned_list = [e for e in v if isinstance(e, dict) and e.get("address", "").strip()]
|
||||
# Graph-Schema: email braucht nur "address", "name" ist optional; "type" entfernen!
|
||||
cleaned_list = [{"address": e["address"], "name": e.get("name", "") or e["address"]} for e in cleaned_list]
|
||||
# Microsoft Graph erlaubt max. 3 E-Mail-Adressen
|
||||
if len(cleaned_list) > 3:
|
||||
cleaned_list = cleaned_list[:3]
|
||||
if cleaned_list:
|
||||
payload[k] = cleaned_list
|
||||
elif k in ("businessPhones", "homePhones"):
|
||||
cleaned_list = [p for p in v if isinstance(p, str) and p.strip()]
|
||||
# Microsoft Graph erlaubt max. 2 Einträge pro Telefon-Liste
|
||||
if len(cleaned_list) > 2:
|
||||
cleaned_list = cleaned_list[:2]
|
||||
if cleaned_list:
|
||||
payload[k] = cleaned_list
|
||||
else:
|
||||
if v:
|
||||
payload[k] = v
|
||||
continue
|
||||
|
||||
# Sonst: nur setzen wenn Wert da
|
||||
if v is not None and v != "":
|
||||
payload[k] = v
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _embed_uid(uid: str, notes: str = "") -> str:
|
||||
"""UID in personalNotes einbetten, damit wir MS-Kontakte mit vCards matchen können"""
|
||||
if not uid:
|
||||
return notes
|
||||
marker = f"{UID_MARKER}{uid}]"
|
||||
if marker in (notes or ""):
|
||||
return notes
|
||||
return f"{notes}\n{marker}".strip() if notes else marker
|
||||
|
||||
|
||||
def _extract_uid(notes: str) -> str:
|
||||
"""UID aus personalNotes extrahieren"""
|
||||
if not notes or UID_MARKER not in notes:
|
||||
return ""
|
||||
start = notes.index(UID_MARKER) + len(UID_MARKER)
|
||||
end = notes.index("]", start)
|
||||
return notes[start:end]
|
||||
|
||||
|
||||
def _normalize_addr(addr: dict) -> dict:
|
||||
"""Normalisiert ein Adress-Dict für stabilen Vergleich"""
|
||||
if not isinstance(addr, dict):
|
||||
return {}
|
||||
return {
|
||||
"street": (addr.get("street") or "").strip(),
|
||||
"city": (addr.get("city") or "").strip(),
|
||||
"state": (addr.get("state") or "").strip(),
|
||||
"postalCode": (addr.get("postalCode") or "").strip(),
|
||||
"countryOrRegion": (addr.get("countryOrRegion") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _contacts_equal(carddav_contact: dict, ms_contact: dict) -> bool:
|
||||
"""Prüft ob sich der Kontakt geändert hat.
|
||||
|
||||
Vergleicht ALLE Felder die wir auch hochladen — string-Felder, Listen, Adressen.
|
||||
Wenn diese Funktion True liefert, wird der Kontakt nicht erneut hochgeladen.
|
||||
"""
|
||||
# Bereinigte Versionen vergleichen, damit Vergleich konsistent ist
|
||||
cv_clean = _clean_payload(carddav_contact)
|
||||
|
||||
# MS-Kontakt: gleicher Cleanup für fairen Vergleich
|
||||
# Notizen: cardsync-UID-Marker ausblenden, da der in MS bewusst gesetzt wird
|
||||
def strip_uid_marker(notes: str) -> str:
|
||||
if not notes:
|
||||
return ""
|
||||
if UID_MARKER not in notes:
|
||||
return notes.strip()
|
||||
# Marker entfernen
|
||||
start = notes.index(UID_MARKER)
|
||||
end = notes.index("]", start) + 1
|
||||
return (notes[:start] + notes[end:]).strip()
|
||||
|
||||
# Einfache String-Felder
|
||||
string_fields = [
|
||||
"givenName", "surname", "middleName", "displayName", "title", "generation",
|
||||
"jobTitle", "companyName", "department", "mobilePhone",
|
||||
"businessHomePage", "nickName",
|
||||
]
|
||||
for f in string_fields:
|
||||
if (cv_clean.get(f, "") or "") != (ms_contact.get(f, "") or ""):
|
||||
return False
|
||||
|
||||
# Notizen vergleichen — UID-Marker rausrechnen
|
||||
cv_notes = strip_uid_marker(cv_clean.get("personalNotes", ""))
|
||||
ms_notes = strip_uid_marker(ms_contact.get("personalNotes", ""))
|
||||
if cv_notes != ms_notes:
|
||||
return False
|
||||
|
||||
# E-Mails
|
||||
cv_emails = set((e.get("address") or "").lower() for e in cv_clean.get("emailAddresses", []))
|
||||
ms_emails = set((e.get("address") or "").lower() for e in ms_contact.get("emailAddresses", []))
|
||||
if cv_emails != ms_emails:
|
||||
return False
|
||||
|
||||
# Telefone (geordnete Listen, da Graph nur max. 2 erlaubt sollte das robust sein)
|
||||
if (cv_clean.get("businessPhones") or []) != (ms_contact.get("businessPhones") or []):
|
||||
return False
|
||||
if (cv_clean.get("homePhones") or []) != (ms_contact.get("homePhones") or []):
|
||||
return False
|
||||
|
||||
# Adressen
|
||||
if _normalize_addr(cv_clean.get("businessAddress", {})) != _normalize_addr(ms_contact.get("businessAddress", {})):
|
||||
return False
|
||||
if _normalize_addr(cv_clean.get("homeAddress", {})) != _normalize_addr(ms_contact.get("homeAddress", {})):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def sync_user_contacts(user_id: int, db: Session) -> dict:
|
||||
"""Synchronisiert Kontakte für einen Benutzer: Synology → MS365"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return {"status": "error", "message": "Benutzer nicht gefunden"}
|
||||
|
||||
if not user.sync_enabled:
|
||||
return {"status": "skipped", "message": "Sync deaktiviert"}
|
||||
|
||||
if not user.carddav_folder:
|
||||
return {"status": "error", "message": "Kein CardDAV-Ordner konfiguriert"}
|
||||
|
||||
# Sync-Log anlegen
|
||||
log = SyncLog(user_id=user.id, started_at=datetime.now(timezone.utc))
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
user.last_sync_status = "running"
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
# 1. Synology-Konfiguration laden
|
||||
config = db.query(SynologyConfig).first()
|
||||
if not config:
|
||||
raise ValueError("Keine Synology-Konfiguration vorhanden")
|
||||
|
||||
# 2. CardDAV-Kontakte laden (mit Cache — bei vielen Usern mit gleichem
|
||||
# Adressbuch wird Synology nur einmal pro Cache-TTL abgefragt)
|
||||
carddav_contacts = await _get_cached_carddav_contacts(
|
||||
config.server_url, config.username, config.password, user.carddav_folder
|
||||
)
|
||||
logger.info(f"[{user.email}] {len(carddav_contacts)} Kontakte von CardDAV geladen")
|
||||
|
||||
# 3. MS365-Kontakte laden
|
||||
access_token = await get_valid_token(user, db)
|
||||
if not access_token:
|
||||
raise ValueError("Kein gültiges Microsoft-Token – Benutzer muss sich neu anmelden")
|
||||
|
||||
# Bei Gruppen-Import-Usern: über /users/{upn}/contacts gehen (App-Token)
|
||||
user_principal = user.email if getattr(user, "source", "oauth") == "group_import" else None
|
||||
|
||||
ms_contacts = await graph_get_contacts(access_token, user_principal)
|
||||
logger.info(f"[{user.email}] {len(ms_contacts)} Kontakte von MS365 geladen")
|
||||
|
||||
# 4. Index aufbauen: UID → MS-Kontakt
|
||||
ms_by_uid = {}
|
||||
ms_by_display = {}
|
||||
for ms_c in ms_contacts:
|
||||
uid = _extract_uid(ms_c.get("personalNotes", ""))
|
||||
if uid:
|
||||
ms_by_uid[uid] = ms_c
|
||||
name = ms_c.get("displayName", "").strip().lower()
|
||||
if name:
|
||||
ms_by_display[name] = ms_c
|
||||
|
||||
# 5. CardDAV → MS365 synchronisieren
|
||||
carddav_uids = set()
|
||||
created = updated = skipped = failed = 0
|
||||
|
||||
for contact in carddav_contacts:
|
||||
uid = contact.get("_uid", "")
|
||||
display = contact.get("displayName", "").strip().lower()
|
||||
|
||||
if not contact.get("displayName"):
|
||||
continue # Kontakte ohne Namen überspringen
|
||||
|
||||
carddav_uids.add(uid)
|
||||
|
||||
# UID in Notes einbetten
|
||||
notes = contact.get("personalNotes", "")
|
||||
contact["personalNotes"] = _embed_uid(uid, notes)
|
||||
|
||||
# Felder für Graph API bereinigen
|
||||
ms_payload = _clean_payload(contact)
|
||||
if not ms_payload.get("displayName") and not ms_payload.get("givenName") and not ms_payload.get("surname"):
|
||||
continue # Graph braucht mindestens einen Namen
|
||||
|
||||
try:
|
||||
if uid and uid in ms_by_uid:
|
||||
# Kontakt existiert schon → Update wenn nötig
|
||||
ms_c = ms_by_uid[uid]
|
||||
if not _contacts_equal(contact, ms_c):
|
||||
await graph_update_contact(access_token, ms_c["id"], ms_payload, user_principal)
|
||||
updated += 1
|
||||
logger.debug(f"[{user.email}] Updated: {contact.get('displayName')}")
|
||||
else:
|
||||
skipped += 1
|
||||
elif display and display in ms_by_display and not uid:
|
||||
# Kein UID aber gleicher Name → Update + UID nachpflegen
|
||||
ms_c = ms_by_display[display]
|
||||
if not _contacts_equal(contact, ms_c):
|
||||
await graph_update_contact(access_token, ms_c["id"], ms_payload, user_principal)
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
else:
|
||||
# Neuer Kontakt → erstellen
|
||||
await graph_create_contact(access_token, ms_payload, user_principal)
|
||||
created += 1
|
||||
logger.debug(f"[{user.email}] Created: {contact.get('displayName')}")
|
||||
except Exception as ce:
|
||||
failed += 1
|
||||
logger.warning(f"[{user.email}] Kontakt '{contact.get('displayName')}' übersprungen: {ce}")
|
||||
continue
|
||||
|
||||
# 6. Gelöschte Kontakte aus MS365 entfernen
|
||||
deleted = 0
|
||||
for uid, ms_c in ms_by_uid.items():
|
||||
if uid not in carddav_uids:
|
||||
try:
|
||||
await graph_delete_contact(access_token, ms_c["id"], user_principal)
|
||||
deleted += 1
|
||||
logger.debug(f"[{user.email}] Deleted: {ms_c.get('displayName')}")
|
||||
except Exception as de:
|
||||
logger.warning(f"[{user.email}] Delete failed for {ms_c.get('displayName')}: {de}")
|
||||
|
||||
total = created + updated
|
||||
message = f"{created} erstellt, {updated} aktualisiert, {deleted} gelöscht, {skipped} unverändert"
|
||||
if failed:
|
||||
message += f", {failed} fehlgeschlagen"
|
||||
logger.info(f"[{user.email}] Sync abgeschlossen: {message}")
|
||||
|
||||
# Log abschließen
|
||||
now = datetime.now(timezone.utc)
|
||||
log.finished_at = now
|
||||
log.status = "success"
|
||||
log.contacts_synced = len(carddav_contacts)
|
||||
log.contacts_created = created
|
||||
log.contacts_updated = updated
|
||||
log.contacts_deleted = deleted
|
||||
|
||||
user.last_sync_at = now
|
||||
user.last_sync_status = "success"
|
||||
user.last_sync_message = message
|
||||
user.updated_at = now
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": message, "created": created, "updated": updated, "deleted": deleted}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"[{user.email}] Sync failed: {error_msg}")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
log.finished_at = now
|
||||
log.status = "error"
|
||||
log.error_message = error_msg
|
||||
|
||||
user.last_sync_at = now
|
||||
user.last_sync_status = "error"
|
||||
user.last_sync_message = error_msg
|
||||
user.updated_at = now
|
||||
db.commit()
|
||||
|
||||
return {"status": "error", "message": error_msg}
|
||||
78
docker-compose.yml
Normal file
78
docker-compose.yml
Normal 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
1257
frontend/admin.html
Normal file
File diff suppressed because it is too large
Load diff
766
frontend/index.html
Normal file
766
frontend/index.html
Normal 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 — 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
29
frontend/success.html
Normal 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
50
nginx/certs/README.md
Normal 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
44
nginx/entrypoint.sh
Executable 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
58
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue