572 lines
20 KiB
Python
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"}
|