Files
matrix-chat-export/app/main.py
Patrick Asmus 4dae5477ce
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 5s
Add application, Dockerfile, CI workflow and documentation
- 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>
2026-07-03 08:04:27 +02:00

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