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