import os import sqlite3 import hashlib import secrets from datetime import datetime, timedelta from typing import Optional from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Depends, status, Request from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel from jose import JWTError, jwt # ── Config ────────────────────────────────────────────────────────────────── DB_PATH = os.environ.get("DB_PATH", "/data/imapsync.db") LOG_DIR = os.environ.get("LOG_DIR", "/data/logs") SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me") ALGORITHM = "HS256" TOKEN_EXPIRE_HOURS = 12 os.makedirs(LOG_DIR, exist_ok=True) # ── DB ─────────────────────────────────────────────────────────────────────── def get_db(): conn = sqlite3.connect(DB_PATH, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") return conn def init_db(): conn = get_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_md5 TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS sync_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, src_host TEXT NOT NULL, src_port INTEGER DEFAULT 993, src_ssl INTEGER DEFAULT 1, src_user TEXT NOT NULL, src_password TEXT NOT NULL, dst_host TEXT NOT NULL, dst_port INTEGER DEFAULT 993, dst_ssl INTEGER DEFAULT 1, dst_user TEXT NOT NULL, dst_password TEXT NOT NULL, extra_args TEXT DEFAULT '', schedule TEXT DEFAULT NULL, enabled INTEGER DEFAULT 1, status TEXT DEFAULT 'idle', last_run DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_by TEXT ); CREATE TABLE IF NOT EXISTS job_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id INTEGER NOT NULL, started_at DATETIME DEFAULT CURRENT_TIMESTAMP, finished_at DATETIME, status TEXT DEFAULT 'running', log_file TEXT, messages_synced INTEGER DEFAULT 0, messages_skipped INTEGER DEFAULT 0, errors INTEGER DEFAULT 0, duration_sec INTEGER DEFAULT 0, FOREIGN KEY (job_id) REFERENCES sync_jobs(id) ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, username TEXT NOT NULL, expires_at DATETIME NOT NULL ); """) # Create default admin if no users exist cur = conn.execute("SELECT COUNT(*) FROM users") if cur.fetchone()[0] == 0: pw_md5 = hashlib.md5("admin".encode()).hexdigest() conn.execute( "INSERT INTO users (username, password_md5, role) VALUES (?, ?, ?)", ("admin", pw_md5, "admin") ) conn.commit() conn.close() # ── Auth ────────────────────────────────────────────────────────────────────── def md5_hash(pw: str) -> str: return hashlib.md5(pw.encode()).hexdigest() def create_token(username: str, role: str) -> str: expire = datetime.utcnow() + timedelta(hours=TOKEN_EXPIRE_HOURS) payload = {"sub": username, "role": role, "exp": expire} return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) def decode_token(token: str) -> dict: try: return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) except JWTError: raise HTTPException(status_code=401, detail="Token ungültig oder abgelaufen") security = HTTPBearer() def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): return decode_token(credentials.credentials) def require_admin(user=Depends(get_current_user)): if user.get("role") != "admin": raise HTTPException(status_code=403, detail="Nur Admins erlaubt") return user # ── App ─────────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): init_db() yield app = FastAPI(title="ImapSync Manager", lifespan=lifespan) # ── Schemas ─────────────────────────────────────────────────────────────────── class LoginRequest(BaseModel): username: str password: str class UserCreate(BaseModel): username: str password: str role: str = "viewer" # admin | operator | viewer class UserUpdate(BaseModel): password: Optional[str] = None role: Optional[str] = None class SyncJobCreate(BaseModel): name: str src_host: str src_port: int = 993 src_ssl: bool = True src_user: str src_password: str dst_host: str dst_port: int = 993 dst_ssl: bool = True dst_user: str dst_password: str extra_args: str = "" schedule: Optional[str] = None enabled: bool = True class SyncJobUpdate(BaseModel): name: Optional[str] = None src_host: Optional[str] = None src_port: Optional[int] = None src_ssl: Optional[bool] = None src_user: Optional[str] = None src_password: Optional[str] = None dst_host: Optional[str] = None dst_port: Optional[int] = None dst_ssl: Optional[bool] = None dst_user: Optional[str] = None dst_password: Optional[str] = None extra_args: Optional[str] = None schedule: Optional[str] = None enabled: Optional[bool] = None # ── Health ──────────────────────────────────────────────────────────────────── @app.get("/api/health") def health(): return {"status": "ok", "time": datetime.utcnow().isoformat()} # ── Auth Endpoints ──────────────────────────────────────────────────────────── @app.post("/api/auth/login") def login(req: LoginRequest): conn = get_db() row = conn.execute( "SELECT * FROM users WHERE username = ? AND password_md5 = ?", (req.username, md5_hash(req.password)) ).fetchone() conn.close() if not row: raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten") token = create_token(row["username"], row["role"]) return {"token": token, "username": row["username"], "role": row["role"]} @app.get("/api/auth/me") def me(user=Depends(get_current_user)): return user # ── User Endpoints ──────────────────────────────────────────────────────────── @app.get("/api/users") def list_users(user=Depends(require_admin)): conn = get_db() rows = conn.execute( "SELECT id, username, role, created_at FROM users ORDER BY created_at DESC" ).fetchall() conn.close() return [dict(r) for r in rows] @app.post("/api/users", status_code=201) def create_user(req: UserCreate, user=Depends(require_admin)): conn = get_db() try: conn.execute( "INSERT INTO users (username, password_md5, role) VALUES (?, ?, ?)", (req.username, md5_hash(req.password), req.role) ) conn.commit() except sqlite3.IntegrityError: raise HTTPException(status_code=409, detail="Benutzername bereits vergeben") finally: conn.close() return {"message": f"Benutzer '{req.username}' erstellt"} @app.put("/api/users/{user_id}") def update_user(user_id: int, req: UserUpdate, user=Depends(require_admin)): conn = get_db() row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") updates = {} if req.password: updates["password_md5"] = md5_hash(req.password) if req.role: updates["role"] = req.role if updates: set_clause = ", ".join(f"{k} = ?" for k in updates) conn.execute( f"UPDATE users SET {set_clause} WHERE id = ?", (*updates.values(), user_id) ) conn.commit() conn.close() return {"message": "Benutzer aktualisiert"} @app.delete("/api/users/{user_id}") def delete_user(user_id: int, user=Depends(require_admin)): conn = get_db() row = conn.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") if row["username"] == user["sub"]: raise HTTPException(status_code=400, detail="Eigenen Account nicht löschbar") conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) conn.commit() conn.close() return {"message": "Benutzer gelöscht"} # ── Sync Job Endpoints ──────────────────────────────────────────────────────── @app.get("/api/jobs") def list_jobs(user=Depends(get_current_user)): conn = get_db() rows = conn.execute(""" SELECT j.*, (SELECT COUNT(*) FROM job_runs r WHERE r.job_id = j.id) as run_count, (SELECT SUM(messages_synced) FROM job_runs r WHERE r.job_id = j.id) as total_synced FROM sync_jobs j ORDER BY j.created_at DESC """).fetchall() conn.close() result = [] for r in rows: d = dict(r) d.pop("src_password", None) d.pop("dst_password", None) result.append(d) return result @app.post("/api/jobs", status_code=201) def create_job(req: SyncJobCreate, user=Depends(get_current_user)): if user.get("role") == "viewer": raise HTTPException(status_code=403, detail="Keine Berechtigung") conn = get_db() cur = conn.execute(""" INSERT INTO sync_jobs (name,src_host,src_port,src_ssl,src_user,src_password, dst_host,dst_port,dst_ssl,dst_user,dst_password, extra_args,schedule,enabled,created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, (req.name, req.src_host, req.src_port, int(req.src_ssl), req.src_user, req.src_password, req.dst_host, req.dst_port, int(req.dst_ssl), req.dst_user, req.dst_password, req.extra_args, req.schedule, int(req.enabled), user["sub"])) conn.commit() job_id = cur.lastrowid conn.close() return {"message": "Job erstellt", "id": job_id} @app.get("/api/jobs/{job_id}") def get_job(job_id: int, user=Depends(get_current_user)): conn = get_db() row = conn.execute("SELECT * FROM sync_jobs WHERE id = ?", (job_id,)).fetchone() conn.close() if not row: raise HTTPException(status_code=404, detail="Job nicht gefunden") d = dict(row) if user.get("role") == "viewer": d.pop("src_password", None) d.pop("dst_password", None) return d @app.put("/api/jobs/{job_id}") def update_job(job_id: int, req: SyncJobUpdate, user=Depends(get_current_user)): if user.get("role") == "viewer": raise HTTPException(status_code=403, detail="Keine Berechtigung") conn = get_db() row = conn.execute("SELECT id FROM sync_jobs WHERE id = ?", (job_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail="Job nicht gefunden") fields = {k: v for k, v in req.model_dump().items() if v is not None} if "src_ssl" in fields: fields["src_ssl"] = int(fields["src_ssl"]) if "dst_ssl" in fields: fields["dst_ssl"] = int(fields["dst_ssl"]) if "enabled" in fields: fields["enabled"] = int(fields["enabled"]) if fields: set_clause = ", ".join(f"{k} = ?" for k in fields) conn.execute( f"UPDATE sync_jobs SET {set_clause} WHERE id = ?", (*fields.values(), job_id) ) conn.commit() conn.close() return {"message": "Job aktualisiert"} @app.delete("/api/jobs/{job_id}") def delete_job(job_id: int, user=Depends(get_current_user)): if user.get("role") == "viewer": raise HTTPException(status_code=403, detail="Keine Berechtigung") conn = get_db() conn.execute("DELETE FROM job_runs WHERE job_id = ?", (job_id,)) conn.execute("DELETE FROM sync_jobs WHERE id = ?", (job_id,)) conn.commit() conn.close() return {"message": "Job gelöscht"} @app.post("/api/jobs/{job_id}/trigger") def trigger_job(job_id: int, user=Depends(get_current_user)): if user.get("role") == "viewer": raise HTTPException(status_code=403, detail="Keine Berechtigung") conn = get_db() row = conn.execute("SELECT status FROM sync_jobs WHERE id = ?", (job_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail="Job nicht gefunden") if row["status"] in ("queued", "running"): raise HTTPException(status_code=409, detail="Job läuft bereits oder ist in der Warteschlange") conn.execute("UPDATE sync_jobs SET status = 'queued' WHERE id = ?", (job_id,)) conn.commit() conn.close() return {"message": "Job in Warteschlange eingereiht"} @app.post("/api/jobs/{job_id}/stop") def stop_job(job_id: int, user=Depends(get_current_user)): if user.get("role") == "viewer": raise HTTPException(status_code=403, detail="Keine Berechtigung") conn = get_db() conn.execute( "UPDATE sync_jobs SET status = 'idle' WHERE id = ? AND status = 'queued'", (job_id,) ) conn.commit() conn.close() return {"message": "Job aus Warteschlange entfernt (wenn möglich)"} # ── Run / Log Endpoints ─────────────────────────────────────────────────────── @app.get("/api/jobs/{job_id}/runs") def get_job_runs(job_id: int, limit: int = 50, user=Depends(get_current_user)): conn = get_db() rows = conn.execute(""" SELECT * FROM job_runs WHERE job_id = ? ORDER BY started_at DESC LIMIT ? """, (job_id, limit)).fetchall() conn.close() return [dict(r) for r in rows] @app.get("/api/runs/{run_id}/log") def get_run_log(run_id: int, user=Depends(get_current_user)): conn = get_db() row = conn.execute("SELECT log_file FROM job_runs WHERE id = ?", (run_id,)).fetchone() conn.close() if not row or not row["log_file"]: raise HTTPException(status_code=404, detail="Kein Log gefunden") log_path = os.path.join(LOG_DIR, row["log_file"]) if not os.path.exists(log_path): raise HTTPException(status_code=404, detail="Logdatei nicht vorhanden") with open(log_path, "r", errors="replace") as f: return {"content": f.read()} # ── Stats Endpoint ──────────────────────────────────────────────────────────── @app.get("/api/stats") def get_stats(user=Depends(get_current_user)): conn = get_db() total_jobs = conn.execute("SELECT COUNT(*) FROM sync_jobs").fetchone()[0] active_jobs = conn.execute("SELECT COUNT(*) FROM sync_jobs WHERE enabled=1").fetchone()[0] running = conn.execute("SELECT COUNT(*) FROM sync_jobs WHERE status='running'").fetchone()[0] queued = conn.execute("SELECT COUNT(*) FROM sync_jobs WHERE status='queued'").fetchone()[0] total_runs = conn.execute("SELECT COUNT(*) FROM job_runs").fetchone()[0] failed_runs = conn.execute("SELECT COUNT(*) FROM job_runs WHERE status='failed'").fetchone()[0] total_synced = conn.execute("SELECT COALESCE(SUM(messages_synced),0) FROM job_runs").fetchone()[0] total_errors = conn.execute("SELECT COALESCE(SUM(errors),0) FROM job_runs").fetchone()[0] # Last 14 days activity daily = conn.execute(""" SELECT DATE(started_at) as day, COUNT(*) as runs, COALESCE(SUM(messages_synced),0) as synced, COALESCE(SUM(errors),0) as errors FROM job_runs WHERE started_at >= DATE('now', '-14 days') GROUP BY day ORDER BY day """).fetchall() # Recent runs recent = conn.execute(""" SELECT r.id, r.job_id, j.name as job_name, r.started_at, r.finished_at, r.status, r.messages_synced, r.errors, r.duration_sec FROM job_runs r JOIN sync_jobs j ON j.id = r.job_id ORDER BY r.started_at DESC LIMIT 10 """).fetchall() conn.close() return { "jobs": {"total": total_jobs, "active": active_jobs, "running": running, "queued": queued}, "runs": {"total": total_runs, "failed": failed_runs}, "messages": {"synced": total_synced, "errors": total_errors}, "daily": [dict(d) for d in daily], "recent_runs": [dict(r) for r in recent], } # ── Static Frontend ─────────────────────────────────────────────────────────── app.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/{full_path:path}", response_class=HTMLResponse) async def serve_spa(full_path: str): with open("static/index.html", "r") as f: return HTMLResponse(f.read())