Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 5s
- FastAPI-based Matrix chat export tool with E2E support - Dockerfile with Python 3.12-slim base image - Docker Compose with dedicated network configuration - Gitea Actions workflow for automated image builds - README with setup instructions and feature overview - MIT license Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
7.6 KiB
Python
243 lines
7.6 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Request, UploadFile, File, Form, HTTPException
|
|
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from app.exporter import MatrixExporter
|
|
|
|
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
logging.basicConfig(level=getattr(logging, LOG_LEVEL, logging.INFO),
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
EXPORTS_DIR = Path("/app/exports")
|
|
STORE_DIR = Path("/app/store")
|
|
EXPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
STORE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
app = FastAPI(title="Matrix Chat Export")
|
|
|
|
exporter = MatrixExporter(store_path=str(STORE_DIR), export_path=str(EXPORTS_DIR))
|
|
|
|
export_tasks: dict[str, asyncio.Task] = {}
|
|
export_logs: dict[str, list[dict]] = {}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Web UI
|
|
# ------------------------------------------------------------------
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def index():
|
|
html_path = Path(__file__).parent / "templates" / "index.html"
|
|
return HTMLResponse(html_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# API: Connection
|
|
# ------------------------------------------------------------------
|
|
|
|
@app.post("/api/connect")
|
|
async def connect(request: Request):
|
|
data = await request.json()
|
|
method = data.get("method", "token")
|
|
homeserver = data.get("homeserver", "").strip().rstrip("/")
|
|
|
|
if not homeserver:
|
|
raise HTTPException(400, "Homeserver URL fehlt")
|
|
|
|
try:
|
|
if method == "password":
|
|
username = data.get("username", "").strip()
|
|
password = data.get("password", "")
|
|
if not username or not password:
|
|
raise HTTPException(400, "Benutzername und Passwort erforderlich")
|
|
result = await exporter.connect_password(homeserver, username, password)
|
|
else:
|
|
token = data.get("token", "").strip()
|
|
if not token:
|
|
raise HTTPException(400, "Access Token erforderlich")
|
|
result = await exporter.connect_token(homeserver, token)
|
|
|
|
return result
|
|
except Exception as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
@app.get("/api/session")
|
|
async def check_session():
|
|
result = await exporter.restore_session()
|
|
if result:
|
|
return result
|
|
return JSONResponse({"connected": False}, status_code=200)
|
|
|
|
|
|
@app.post("/api/disconnect")
|
|
async def disconnect():
|
|
await exporter.disconnect()
|
|
creds = STORE_DIR / "credentials.json"
|
|
if creds.exists():
|
|
creds.unlink()
|
|
return {"ok": True}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# API: E2E Keys
|
|
# ------------------------------------------------------------------
|
|
|
|
@app.post("/api/import-keys")
|
|
async def import_keys(file: UploadFile = File(...), passphrase: str = Form(...)):
|
|
if not exporter.client:
|
|
raise HTTPException(400, "Nicht verbunden")
|
|
|
|
key_path = STORE_DIR / "imported_keys.txt"
|
|
content = await file.read()
|
|
key_path.write_bytes(content)
|
|
|
|
try:
|
|
result = await exporter.import_keys(str(key_path), passphrase)
|
|
return result
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Import fehlgeschlagen: {e}")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# API: Rooms
|
|
# ------------------------------------------------------------------
|
|
|
|
@app.get("/api/rooms")
|
|
async def list_rooms():
|
|
if not exporter.access_token:
|
|
raise HTTPException(400, "Nicht verbunden")
|
|
try:
|
|
rooms = await exporter.list_rooms()
|
|
return rooms
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/api/rooms/stream")
|
|
async def rooms_stream():
|
|
if not exporter.access_token:
|
|
raise HTTPException(400, "Nicht verbunden")
|
|
|
|
async def stream():
|
|
try:
|
|
async for item in exporter.list_rooms_stream():
|
|
yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
|
|
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
|
except Exception as e:
|
|
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
|
|
|
return StreamingResponse(stream(), media_type="text/event-stream", headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
})
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# API: Export
|
|
# ------------------------------------------------------------------
|
|
|
|
@app.post("/api/export")
|
|
async def start_export(request: Request):
|
|
data = await request.json()
|
|
room_id = data.get("room_id", "")
|
|
if not room_id:
|
|
raise HTTPException(400, "room_id fehlt")
|
|
|
|
if room_id in export_tasks and not export_tasks[room_id].done():
|
|
raise HTTPException(409, "Export läuft bereits")
|
|
|
|
export_logs[room_id] = []
|
|
|
|
async def run():
|
|
try:
|
|
async for entry in exporter.export_room(room_id):
|
|
export_logs.setdefault(room_id, []).append(entry)
|
|
except Exception as e:
|
|
export_logs.setdefault(room_id, []).append({
|
|
"type": "error", "message": str(e), "progress": 0,
|
|
})
|
|
|
|
task = asyncio.create_task(run())
|
|
export_tasks[room_id] = task
|
|
return {"status": "started", "room_id": room_id}
|
|
|
|
|
|
@app.get("/api/export/progress/{room_id:path}")
|
|
async def export_progress(room_id: str):
|
|
async def stream():
|
|
last_idx = 0
|
|
while True:
|
|
logs = export_logs.get(room_id, [])
|
|
while last_idx < len(logs):
|
|
yield f"data: {json.dumps(logs[last_idx], ensure_ascii=False)}\n\n"
|
|
last_idx += 1
|
|
|
|
task = export_tasks.get(room_id)
|
|
if task and task.done():
|
|
if last_idx >= len(export_logs.get(room_id, [])):
|
|
yield f"data: {json.dumps({'type': 'stream_end'})}\n\n"
|
|
break
|
|
|
|
await asyncio.sleep(0.3)
|
|
|
|
return StreamingResponse(stream(), media_type="text/event-stream", headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
})
|
|
|
|
|
|
@app.get("/api/exports")
|
|
async def list_exports():
|
|
return exporter.list_exports()
|
|
|
|
|
|
@app.delete("/api/exports/{dir_name}")
|
|
async def delete_export(dir_name: str):
|
|
if not exporter.delete_export(dir_name):
|
|
raise HTTPException(404, "Export nicht gefunden")
|
|
return {"ok": True}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# API: ZIP download
|
|
# ------------------------------------------------------------------
|
|
|
|
@app.get("/api/exports/{dir_name}/zip")
|
|
async def download_zip(dir_name: str):
|
|
zip_path = exporter.create_zip(dir_name)
|
|
if not zip_path or not zip_path.exists():
|
|
raise HTTPException(404, "Export nicht gefunden")
|
|
|
|
state_file = EXPORTS_DIR / dir_name / ".state.json"
|
|
filename = dir_name
|
|
if state_file.exists():
|
|
try:
|
|
state = json.loads(state_file.read_text())
|
|
room_name = state.get("room_name", dir_name)
|
|
safe = "".join(c for c in room_name if c.isalnum() or c in " -_äöüÄÖÜß").strip()
|
|
if safe:
|
|
filename = safe
|
|
except Exception:
|
|
pass
|
|
|
|
return FileResponse(
|
|
str(zip_path),
|
|
media_type="application/zip",
|
|
filename=f"Chat-Export {filename}.zip",
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Serve exported files
|
|
# ------------------------------------------------------------------
|
|
|
|
app.mount("/exports", StaticFiles(directory=str(EXPORTS_DIR)), name="exports")
|