CardSync/backend/main.py

572 lines
20 KiB
Python

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"}