Files
IMAPSYNC/backend/main.py
T
2026-04-21 15:10:35 +02:00

445 lines
18 KiB
Python

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())