import json, uuid, os from datetime import datetime, timedelta from typing import Optional, List from fastapi import FastAPI, Depends, HTTPException, status, Request, Body from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel from models import init_db, SessionLocal, User, AppData, LoginAttempt # --- App Setup --- app = FastAPI(title="Wochenplaner API", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- Security --- SECRET_KEY = os.environ.get("JWT_SECRET", "wochenplaner-jwt-secret-2024") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") security = HTTPBearer(auto_error=False) # --- Pydantic Models --- class LoginRequest(BaseModel): username: str password: str class TokenResponse(BaseModel): token: str user: dict data: Optional[dict] = None class UserCreate(BaseModel): id: Optional[str] = None username: str password: str role: str = "user" class AppDataModel(BaseModel): resources: Optional[list] = None tasks: Optional[dict] = None calendars: Optional[list] = None generalConfig: Optional[dict] = None views: Optional[list] = None printOptions: Optional[dict] = None globalRowHeight: Optional[int] = None baseFontSize: Optional[int] = None headerOptions: Optional[dict] = None hiddenResourceIds: Optional[list] = None updatedAt: Optional[str] = None _lastKnownVersion: Optional[str] = None # --- Helper Functions --- def create_access_token(data: dict) -> str: to_encode = data.copy() expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) def verify_token(token: str) -> dict: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return payload except JWTError: raise HTTPException(status_code=401, detail="Ungültiger Token") def get_current_user(request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> dict: token = None if credentials: token = credentials.credentials if not token: token = request.query_params.get("token") if not token: raise HTTPException(status_code=401, detail="Nicht authentifiziert") return verify_token(token) def require_role(role: str): def checker(payload: dict = Depends(get_current_user)): user_role = payload.get("role", "user") if user_role == "admin": return payload if user_role != role: raise HTTPException(status_code=403, detail=f"Rolle '{role}' erforderlich") return payload return checker def get_db(): db = SessionLocal() try: yield db finally: db.close() # --- Init DB --- init_db() # Create default users if not exist db = SessionLocal() try: if not db.query(User).filter(User.username == "admin").first(): db.add(User(id=str(uuid.uuid4()), username="admin", password_hash=pwd_context.hash("admin"), role="admin")) db.add(User(id=str(uuid.uuid4()), username="user", password_hash=pwd_context.hash("user"), role="user")) db.add(User(id=str(uuid.uuid4()), username="viewer", password_hash=pwd_context.hash("viewer"), role="viewer")) db.commit() # Init AppData row if not db.query(AppData).filter(AppData.key == "main").first(): default_config = json.dumps({ "appTitle": "Wochenplaner", "resourceColTitle": "Bereich", "dayColors": {"1": "#ffffff", "2": "#ffffff", "3": "#ffffff", "4": "#ffffff", "5": "#ffffff", "6": "#f8fafc", "0": "#f8fafc"} }) default_header = json.dumps({ "weekday": {"fontSize": 10, "color": "#64748b", "bold": False}, "date": {"fontSize": 12, "color": "#0f172a", "bold": True} }) app_data = AppData( key="main", resources="[]", tasks="{}", calendars="[]", general_config=default_config, views="[]", print_options=json.dumps({"fontSize": 12, "showCalendars": True, "printRowHeight": 28}), global_row_height=36, base_font_size=14, header_options=default_header, hidden_resource_ids="[]", updated_at=datetime.utcnow() ) db.add(app_data) db.commit() finally: db.close() def format_app_data(app_data: AppData) -> dict: return { "resources": json.loads(app_data.resources or "[]"), "tasks": json.loads(app_data.tasks or "{}"), "calendars": json.loads(app_data.calendars or "[]"), "generalConfig": json.loads(app_data.general_config or "{}"), "views": json.loads(app_data.views or "[]"), "printOptions": json.loads(app_data.print_options or "{}"), "globalRowHeight": app_data.global_row_height or 36, "baseFontSize": app_data.base_font_size or 14, "headerOptions": json.loads(app_data.header_options or "{}"), "hiddenResourceIds": json.loads(app_data.hidden_resource_ids or "[]"), "updatedAt": app_data.updated_at.isoformat() if app_data.updated_at else None } # === AUTH ENDPOINTS === @app.post("/api/login") def login(req: LoginRequest, request: Request): db = SessionLocal() try: user = db.query(User).filter(User.username == req.username).first() # Rate limiting recent_fails = db.query(LoginAttempt).filter( LoginAttempt.username == req.username, LoginAttempt.attempted_at > datetime.utcnow() - timedelta(minutes=10), LoginAttempt.success == False ).count() if recent_fails >= 5: db.add(LoginAttempt(username=req.username, success=False)) db.commit() raise HTTPException(status_code=429, detail="Zu viele Fehlversuche. Bitte 10 Minuten warten.") if not user or not pwd_context.verify(req.password, user.password_hash): db.add(LoginAttempt(username=req.username, success=False)) db.commit() raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten") db.add(LoginAttempt(username=req.username, success=True)) db.commit() token = create_access_token({"sub": user.id, "username": user.username, "role": user.role}) app_data = db.query(AppData).filter(AppData.key == "main").first() data = format_app_data(app_data) if app_data else {} return TokenResponse( token=token, user={"id": user.id, "username": user.username, "role": user.role}, data=data ) finally: db.close() @app.get("/api/me") def get_me(payload: dict = Depends(get_current_user)): return {"user": payload} # === USER MANAGEMENT === @app.get("/api/users") def get_users(payload: dict = Depends(require_role("admin"))): db = SessionLocal() try: users = db.query(User).all() return [{"id": u.id, "username": u.username, "role": u.role} for u in users] finally: db.close() @app.post("/api/users") def create_user(user_data: UserCreate, payload: dict = Depends(require_role("admin"))): db = SessionLocal() try: if db.query(User).filter(User.username == user_data.username).first(): raise HTTPException(400, "Benutzername existiert bereits") new_user = User( id=user_data.id or str(uuid.uuid4()), username=user_data.username, password_hash=pwd_context.hash(user_data.password), role=user_data.role if user_data.role in ["admin", "user", "viewer"] else "user" ) db.add(new_user) db.commit() return {"id": new_user.id, "username": new_user.username, "role": new_user.role} finally: db.close() @app.put("/api/users/{user_id}") def update_user(user_id: str, user_data: UserCreate, payload: dict = Depends(require_role("admin"))): db = SessionLocal() try: user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(404, "Benutzer nicht gefunden") if user_data.username: user.username = user_data.username if user_data.password: user.password_hash = pwd_context.hash(user_data.password) if user_data.role: user.role = user_data.role db.commit() return {"id": user.id, "username": user.username, "role": user.role} finally: db.close() @app.delete("/api/users/{user_id}") def delete_user(user_id: str, payload: dict = Depends(require_role("admin"))): db = SessionLocal() try: user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(404, "Benutzer nicht gefunden") admin_count = db.query(User).filter(User.role == "admin").count() if user.role == "admin" and admin_count <= 1: raise HTTPException(400, "Kann letzten Admin nicht löschen") db.delete(user) db.commit() return {"message": "Benutzer gelöscht"} finally: db.close() @app.put("/api/profile") def update_profile(data: dict = Body(...), payload: dict = Depends(get_current_user)): db = SessionLocal() try: user = db.query(User).filter(User.id == payload["sub"]).first() if not user: raise HTTPException(404, "Benutzer nicht gefunden") if "username" in data: user.username = data["username"] if "password" in data: user.password_hash = pwd_context.hash(data["password"]) db.commit() return {"id": user.id, "username": user.username, "role": user.role} finally: db.close() # === DATA ENDPOINTS === @app.get("/api/data") def get_all_data(payload: dict = Depends(get_current_user)): db = SessionLocal() try: app_data = db.query(AppData).filter(AppData.key == "main").first() return format_app_data(app_data) finally: db.close() @app.post("/api/data") def save_all_data(data: AppDataModel, payload: dict = Depends(get_current_user)): user_role = payload.get("role", "user") if user_role == "viewer": raise HTTPException(status_code=403, detail="Viewer dürfen nicht speichern") db = SessionLocal() try: app_data = db.query(AppData).filter(AppData.key == "main").first() if not app_data: raise HTTPException(404) if data._lastKnownVersion and app_data.updated_at: if data._lastKnownVersion != app_data.updated_at.isoformat(): raise HTTPException(409, detail="Konflikt: Daten auf Server sind neuer!") if data.resources is not None: app_data.resources = json.dumps(data.resources) if data.tasks is not None: app_data.tasks = json.dumps(data.tasks) if data.calendars is not None: app_data.calendars = json.dumps(data.calendars) if data.generalConfig is not None: app_data.general_config = json.dumps(data.generalConfig) if data.views is not None: app_data.views = json.dumps(data.views) if data.printOptions is not None: app_data.print_options = json.dumps(data.printOptions) if data.globalRowHeight is not None: app_data.global_row_height = data.globalRowHeight if data.baseFontSize is not None: app_data.base_font_size = data.baseFontSize if data.headerOptions is not None: app_data.header_options = json.dumps(data.headerOptions) if data.hiddenResourceIds is not None: app_data.hidden_resource_ids = json.dumps(data.hiddenResourceIds) app_data.updated_at = datetime.utcnow() app_data.updated_by = payload.get("username") db.commit() return format_app_data(app_data) finally: db.close() # === HEALTH === @app.get("/health") def health(): return {"status": "ok", "time": datetime.utcnow().isoformat()} # === STARTUP === if __name__ == "__main__": import uvicorn port = int(os.environ.get("PORT", 8000)) uvicorn.run(app, host="0.0.0.0", port=port)