feat: initial ImapSync Manager setup
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /data/logs
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
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())
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
python-multipart==0.0.12
|
||||
pydantic==2.9.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib==1.7.4
|
||||
croniter==3.0.3
|
||||
aiofiles==24.1.0
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user