From 4dae5477ce479ab9363dd75b6caf0dec23fbe745 Mon Sep 17 00:00:00 2001
From: Patrick Asmus
Date: Fri, 3 Jul 2026 08:04:27 +0200
Subject: [PATCH] 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
---
.gitea/workflows/docker-publish.yml | 38 ++
Dockerfile | 18 +
LICENSE | 37 +-
README.md | 58 +-
app/__init__.py | 0
app/exporter.py | 837 ++++++++++++++++++++++++++++
app/main.py | 242 ++++++++
app/templates/chat_export.html | 266 +++++++++
app/templates/index.html | 654 ++++++++++++++++++++++
docker-compose.yml | 26 +
requirements.txt | 7 +
11 files changed, 2161 insertions(+), 22 deletions(-)
create mode 100644 .gitea/workflows/docker-publish.yml
create mode 100644 Dockerfile
create mode 100644 app/__init__.py
create mode 100644 app/exporter.py
create mode 100644 app/main.py
create mode 100644 app/templates/chat_export.html
create mode 100644 app/templates/index.html
create mode 100644 docker-compose.yml
create mode 100644 requirements.txt
diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml
new file mode 100644
index 0000000..20b6396
--- /dev/null
+++ b/.gitea/workflows/docker-publish.yml
@@ -0,0 +1,38 @@
+name: Build & Push Docker Image
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+
+ - name: Version aus Tag extrahieren
+ id: version
+ run: |
+ VERSION=$(echo "${{ gitea.ref_name }}" | sed 's/^v//')
+ echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
+
+ - name: Bei Registry einloggen
+ run: |
+ echo "${{ secrets.REGISTRY_TOKEN }}" | \
+ docker login ${{ vars.REGISTRY_URL }} \
+ --username "${{ secrets.REGISTRY_USER }}" \
+ --password-stdin
+
+ - name: Docker Image bauen
+ run: |
+ docker build \
+ -t ${{ vars.REGISTRY_URL }}/${{ gitea.repository }}:${{ steps.version.outputs.VERSION }} \
+ -t ${{ vars.REGISTRY_URL }}/${{ gitea.repository }}:latest \
+ .
+
+ - name: Docker Image pushen
+ run: |
+ docker push ${{ vars.REGISTRY_URL }}/${{ gitea.repository }}:${{ steps.version.outputs.VERSION }}
+ docker push ${{ vars.REGISTRY_URL }}/${{ gitea.repository }}:latest
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c89bbb4
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,18 @@
+FROM python:3.12-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libolm-dev gcc python3-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app/ ./app/
+
+RUN mkdir -p /app/exports /app/store
+
+EXPOSE 8080
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--log-level", "info"]
diff --git a/LICENSE b/LICENSE
index 100a0a5..9e6581c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,26 +1,25 @@
-GNU AFFERO GENERAL PUBLIC LICENSE
-Version 3, 19 November 2007
+MIT License
----
+Copyright (c) Patrick Asmus (scriptos)
-Copyright ©
-
-Name: Patrick Asmus (scriptos)
-Email: support@techniverse.net
Website: https://www.patrick-asmus.de
Blog: https://www.cleveradmin.de
+Email: support@techniverse.net
----
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see .
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index dc14603..5f57e8c 100644
--- a/README.md
+++ b/README.md
@@ -4,10 +4,10 @@
-Name des Projekts
+Matrix Chat Export
- Kurzbeschreibung des Projekts/Anwendung, um die es geht
+ Web-basiertes Tool zum Exportieren von Matrix-Chaträumen inkl. Medien und E2E-Verschlüsselung
@@ -22,7 +22,59 @@
-CONTENT BEREICH
+## Funktionen
+
+- **Matrix-Chaträume exportieren** — vollständiger Nachrichtenverlauf als HTML-Datei
+- **Medien-Download** — Bilder, Videos, Audio, Dateien und Sticker werden mitgespeichert
+- **E2E-Verschlüsselung** — Import von Megolm-Schlüsseln zum Entschlüsseln verschlüsselter Nachrichten
+- **Inkrementeller Export** — bei erneutem Export werden nur neue Nachrichten geladen
+- **ZIP-Download** — fertigen Export als ZIP-Archiv herunterladen
+- **Zwei Login-Methoden** — Anmeldung per Access Token oder Benutzername/Passwort
+- **Session-Wiederherstellung** — bestehende Sitzungen werden automatisch wiederhergestellt
+
+## Voraussetzungen
+
+- Docker & Docker Compose
+
+## Installation
+
+```bash
+git clone https://git.techniverse.net/scriptos/matrix-chat-export.git
+cd matrix-chat-export
+docker compose up -d
+```
+
+Die Anwendung ist anschließend unter `http://:8092` erreichbar.
+
+## Konfiguration
+
+| Variable | Beschreibung | Standard |
+|----------|-------------|----------|
+| `TZ` | Zeitzone | `Europe/Berlin` |
+| `LOG_LEVEL` | Log-Level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | `INFO` |
+
+### Volumes
+
+| Pfad (Container) | Beschreibung |
+|-------------------|-------------|
+| `/app/exports` | Gespeicherte Chat-Exports |
+| `/app/store` | Session-Daten und importierte Schlüssel |
+
+## Verwendung
+
+1. Anwendung im Browser öffnen
+2. Mit dem Matrix-Homeserver verbinden (Token oder Passwort)
+3. Optional: E2E-Schlüssel importieren (für verschlüsselte Räume)
+4. Raum auswählen und Export starten
+5. Export als HTML ansehen oder als ZIP herunterladen
+
+> **Hinweis:** Es wird kein Admin-Token benötigt. Ein normaler Benutzer-Access-Token ist ausreichend.
+> Die Anwendung greift ausschließlich auf Räume zu, in denen der Benutzer Mitglied ist.
+
+## Technologie
+
+- **Backend:** Python 3.12, FastAPI, matrix-nio
+- **Container:** Docker mit Python-Slim Base Image
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/exporter.py b/app/exporter.py
new file mode 100644
index 0000000..be4691d
--- /dev/null
+++ b/app/exporter.py
@@ -0,0 +1,837 @@
+import asyncio
+import json
+import logging
+import hashlib
+import zipfile
+from pathlib import Path
+from datetime import datetime, timezone
+from typing import AsyncGenerator
+import shutil
+from urllib.parse import quote
+
+import aiohttp
+import aiofiles
+
+from nio import (
+ AsyncClient,
+ AsyncClientConfig,
+ LoginResponse,
+ LoginError,
+ RoomMessagesResponse,
+ RoomMessagesError,
+ MegolmEvent,
+ DownloadResponse,
+ DownloadError,
+)
+
+logger = logging.getLogger(__name__)
+
+SENDER_COLORS = [
+ "#ff6b6b", "#ffa502", "#7bed9f", "#70a1ff", "#5352ed", "#ff6348",
+ "#2ed573", "#1e90ff", "#ff4757", "#eccc68", "#a29bfe", "#fd79a8",
+ "#00cec9", "#e17055", "#6c5ce7", "#fab1a0",
+]
+
+
+def _color_for_user(user_id: str) -> str:
+ h = int(hashlib.md5(user_id.encode()).hexdigest()[:8], 16)
+ return SENDER_COLORS[h % len(SENDER_COLORS)]
+
+
+def _ts_to_datetime(ts_ms: int) -> datetime:
+ return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
+
+
+def _format_size(size: int) -> str:
+ if size > 1_048_576:
+ return f"{size / 1_048_576:.1f} MB"
+ if size > 1024:
+ return f"{size / 1024:.0f} KB"
+ return f"{size} B"
+
+
+EXT_MAP = {
+ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
+ "image/webp": ".webp", "video/mp4": ".mp4", "video/webm": ".webm",
+ "video/quicktime": ".mov", "audio/ogg": ".ogg", "audio/mpeg": ".mp3",
+ "audio/mp4": ".m4a", "audio/webm": ".weba", "audio/aac": ".aac",
+ "application/pdf": ".pdf",
+}
+
+
+MEDIA_PURGED = "__PURGED__"
+
+
+class MatrixExporter:
+
+ def __init__(self, store_path: str, export_path: str):
+ self.store_path = Path(store_path)
+ self.export_path = Path(export_path)
+ self.client: AsyncClient | None = None
+ self.user_id: str | None = None
+ self.homeserver: str = ""
+ self.access_token: str = ""
+ self.credentials_file = self.store_path / "credentials.json"
+ self.store_path.mkdir(parents=True, exist_ok=True)
+ self.export_path.mkdir(parents=True, exist_ok=True)
+ self._keys_imported = False
+
+ # ------------------------------------------------------------------
+ # Connection
+ # ------------------------------------------------------------------
+
+ async def connect_password(self, homeserver: str, username: str, password: str) -> dict:
+ self.homeserver = homeserver.rstrip("/")
+ nio_store = self.store_path / "nio_store"
+ nio_store.mkdir(parents=True, exist_ok=True)
+
+ cfg = AsyncClientConfig(store_sync_tokens=True, encryption_enabled=True)
+ self.client = AsyncClient(self.homeserver, username, store_path=str(nio_store), config=cfg)
+
+ resp = await self.client.login(password)
+ if isinstance(resp, LoginError):
+ raise Exception(f"Login fehlgeschlagen: {resp.message}")
+
+ self.user_id = resp.user_id
+ self.access_token = resp.access_token
+
+ self.credentials_file.write_text(json.dumps({
+ "homeserver": self.homeserver,
+ "user_id": resp.user_id,
+ "device_id": resp.device_id,
+ "access_token": resp.access_token,
+ }, indent=2))
+
+ return {"user_id": resp.user_id, "device_id": resp.device_id, "new_session": True}
+
+ async def connect_token(self, homeserver: str, token: str) -> dict:
+ self.homeserver = homeserver.rstrip("/")
+ self.access_token = token
+
+ async with aiohttp.ClientSession() as s:
+ async with s.get(
+ f"{self.homeserver}/_matrix/client/v3/account/whoami",
+ headers={"Authorization": f"Bearer {token}"},
+ ) as r:
+ if r.status != 200:
+ raise Exception(f"Token ungültig (HTTP {r.status})")
+ data = await r.json()
+ self.user_id = data["user_id"]
+ device_id = data.get("device_id", "EXPORT")
+
+ nio_store = self.store_path / "nio_store"
+ nio_store.mkdir(parents=True, exist_ok=True)
+
+ cfg = AsyncClientConfig(store_sync_tokens=True, encryption_enabled=True)
+ self.client = AsyncClient(
+ self.homeserver, self.user_id,
+ store_path=str(nio_store), config=cfg,
+ )
+ self.client.restore_login(self.user_id, device_id, token)
+
+ self.credentials_file.write_text(json.dumps({
+ "homeserver": self.homeserver,
+ "user_id": self.user_id,
+ "device_id": device_id,
+ "access_token": token,
+ }, indent=2))
+
+ return {"user_id": self.user_id, "device_id": device_id, "new_session": False}
+
+ async def restore_session(self) -> dict | None:
+ if not self.credentials_file.exists():
+ return None
+ creds = json.loads(self.credentials_file.read_text())
+ self.homeserver = creds["homeserver"]
+ self.user_id = creds["user_id"]
+ self.access_token = creds["access_token"]
+
+ nio_store = self.store_path / "nio_store"
+ nio_store.mkdir(parents=True, exist_ok=True)
+ cfg = AsyncClientConfig(store_sync_tokens=True, encryption_enabled=True)
+ self.client = AsyncClient(
+ self.homeserver, self.user_id,
+ store_path=str(nio_store), config=cfg,
+ )
+ self.client.restore_login(creds["user_id"], creds["device_id"], creds["access_token"])
+ return {
+ "user_id": self.user_id,
+ "device_id": creds["device_id"],
+ "homeserver": self.homeserver,
+ "restored": True,
+ }
+
+ async def disconnect(self):
+ if self.client:
+ await self.client.close()
+ self.client = None
+
+ # ------------------------------------------------------------------
+ # E2E Key import
+ # ------------------------------------------------------------------
+
+ async def import_keys(self, key_file: str, passphrase: str) -> dict:
+ if not self.client:
+ raise Exception("Nicht verbunden")
+ self.client.load_store()
+ keys = await self.client.import_keys(key_file, passphrase)
+ self._keys_imported = True
+ return {"success": True, "message": f"Schlüssel importiert ({keys} Sessions)"}
+
+ @property
+ def keys_imported(self) -> bool:
+ return self._keys_imported
+
+ # ------------------------------------------------------------------
+ # Room listing
+ # ------------------------------------------------------------------
+
+ async def _fetch_room_info(self, session: aiohttp.ClientSession, room_id: str, headers: dict) -> dict:
+ encoded = quote(room_id, safe="")
+ name = room_id
+ member_count = 0
+ encrypted = False
+ topic = ""
+ members: list[str] = []
+
+ async with session.get(
+ f"{self.homeserver}/_matrix/client/v3/rooms/{encoded}/state",
+ headers=headers,
+ ) as r:
+ if r.status == 200:
+ states = await r.json()
+ for st in states:
+ t = st.get("type", "")
+ c = st.get("content", {})
+ if t == "m.room.name":
+ name = c.get("name", room_id)
+ elif t == "m.room.encryption":
+ encrypted = True
+ elif t == "m.room.topic":
+ topic = c.get("topic", "")
+ elif t == "m.room.member" and c.get("membership") == "join":
+ member_count += 1
+ if st.get("state_key") != self.user_id:
+ members.append(c.get("displayname", st.get("state_key", "?")))
+
+ if name == room_id and members:
+ name = ", ".join(members[:3])
+ if len(members) > 3:
+ name += f" +{len(members) - 3}"
+
+ return {
+ "room_id": room_id,
+ "name": name,
+ "member_count": member_count,
+ "encrypted": encrypted,
+ "topic": topic,
+ }
+
+ async def list_rooms(self) -> list[dict]:
+ if not self.access_token:
+ raise Exception("Nicht verbunden")
+
+ headers = {"Authorization": f"Bearer {self.access_token}"}
+ rooms = []
+
+ async with aiohttp.ClientSession() as s:
+ async with s.get(f"{self.homeserver}/_matrix/client/v3/joined_rooms", headers=headers) as r:
+ data = await r.json()
+ room_ids = data.get("joined_rooms", [])
+
+ for room_id in room_ids:
+ try:
+ info = await self._fetch_room_info(s, room_id, headers)
+ rooms.append(info)
+ except Exception:
+ pass
+
+ rooms.sort(key=lambda r: r["name"].lower())
+ return rooms
+
+ async def list_rooms_stream(self) -> AsyncGenerator[dict, None]:
+ if not self.access_token:
+ raise Exception("Nicht verbunden")
+
+ headers = {"Authorization": f"Bearer {self.access_token}"}
+
+ async with aiohttp.ClientSession() as s:
+ async with s.get(f"{self.homeserver}/_matrix/client/v3/joined_rooms", headers=headers) as r:
+ data = await r.json()
+ room_ids = data.get("joined_rooms", [])
+
+ yield {"type": "total", "count": len(room_ids)}
+
+ for room_id in room_ids:
+ try:
+ info = await self._fetch_room_info(s, room_id, headers)
+ yield {"type": "room", "data": info}
+ except Exception as e:
+ logger.warning("Raum-Info fehlgeschlagen für %s: %s", room_id, e)
+
+ # ------------------------------------------------------------------
+ # Export
+ # ------------------------------------------------------------------
+
+ async def export_room(self, room_id: str) -> AsyncGenerator[dict, None]:
+ if not self.client:
+ yield _log("error", "Nicht verbunden")
+ return
+
+ safe_name = room_id.replace("!", "").replace(":", "_").replace(".", "_")
+ room_dir = self.export_path / safe_name
+ room_dir.mkdir(parents=True, exist_ok=True)
+ media_dir = room_dir / "media"
+ media_dir.mkdir(exist_ok=True)
+
+ yield _log("info", "Starte Export …")
+
+ # --- sync to get pagination token ---
+ yield _log("info", "Synchronisiere mit Server …", progress=2)
+ try:
+ sync_filter = json.dumps({
+ "room": {"timeline": {"limit": 1}, "state": {"lazy_load_members": True}},
+ "presence": {"types": []},
+ "account_data": {"types": []},
+ })
+ await self.client.sync(timeout=30000, sync_filter=sync_filter)
+ start_token = self.client.next_batch
+ except Exception as e:
+ yield _log("error", f"Sync fehlgeschlagen: {e}")
+ return
+
+ if not start_token:
+ yield _log("error", "Kein Sync-Token erhalten")
+ return
+
+ # --- load state for incremental ---
+ state_file = room_dir / ".state.json"
+ existing_ids: set[str] = set()
+ existing_events: list[dict] = []
+ events_file = room_dir / "events.jsonl"
+
+ if events_file.exists():
+ try:
+ for line in events_file.read_text(encoding="utf-8").splitlines():
+ if line.strip():
+ ev = json.loads(line)
+ existing_ids.add(ev.get("event_id", ""))
+ existing_events.append(ev)
+ yield _log("info", f"{len(existing_events)} vorhandene Events geladen (inkrementell)", progress=3)
+ except Exception:
+ existing_events = []
+ existing_ids = set()
+
+ # --- fetch messages ---
+ yield _log("info", "Lade Nachrichten …", progress=5)
+
+ raw_events: list[dict] = []
+ from_token = start_token
+ page = 0
+ decrypted_count = 0
+ encrypted_fail = 0
+
+ while True:
+ page += 1
+ try:
+ resp = await self.client.room_messages(room_id, start=from_token, limit=200)
+ except Exception as e:
+ yield _log("warning", f"Fehler bei Seite {page}: {e}")
+ break
+
+ if isinstance(resp, RoomMessagesError):
+ yield _log("error", f"API-Fehler: {resp.message}")
+ break
+
+ if not resp.chunk:
+ break
+
+ for event in resp.chunk:
+ source = getattr(event, "source", {})
+ eid = source.get("event_id", "")
+ if eid in existing_ids:
+ continue
+
+ if isinstance(event, MegolmEvent):
+ try:
+ decrypted = self.client.decrypt_event(event)
+ if decrypted:
+ source = decrypted.source
+ decrypted_count += 1
+ else:
+ encrypted_fail += 1
+ except Exception:
+ encrypted_fail += 1
+
+ raw_events.append(source)
+ existing_ids.add(eid)
+
+ from_token = resp.end
+ if page % 3 == 0:
+ yield _log("progress", f"Seite {page}: {len(raw_events)} neue Nachrichten", progress=min(5 + page, 40))
+
+ if not resp.end:
+ break
+
+ raw_events.reverse()
+
+ yield _log("info",
+ f"{len(raw_events)} neue Nachrichten ({decrypted_count} entschlüsselt, "
+ f"{encrypted_fail} nicht entschlüsselbar)", progress=42)
+
+ # --- merge with existing ---
+ all_events = existing_events + raw_events
+ all_events.sort(key=lambda e: e.get("origin_server_ts", 0))
+
+ # --- save events ---
+ async with aiofiles.open(events_file, "w", encoding="utf-8") as f:
+ for ev in all_events:
+ await f.write(json.dumps(ev, ensure_ascii=False) + "\n")
+
+ yield _log("info", f"Gesamt: {len(all_events)} Events", progress=45)
+
+ # --- download media ---
+ yield _log("info", "Lade Mediendateien …", progress=46)
+
+ media_map: dict[str, str] = {}
+ media_items = [e for e in all_events if _has_media(e)]
+ media_total = len(media_items)
+ media_errors = 0
+ media_purged = 0
+
+ async with aiohttp.ClientSession() as http_session:
+ for i, ev in enumerate(media_items):
+ eid = ev.get("event_id", "")
+ fname = await self._download_media(ev, media_dir, http_session)
+ if fname == MEDIA_PURGED:
+ media_purged += 1
+ elif fname:
+ media_map[eid] = fname
+ else:
+ media_errors += 1
+
+ if (i + 1) % 10 == 0 or i == media_total - 1:
+ yield _log("progress",
+ f"Medien: {i + 1}/{media_total} ({len(media_map)} OK, "
+ f"{media_purged} gelöscht, {media_errors} Fehler)",
+ progress=int(46 + 40 * (i + 1) / max(media_total, 1)))
+
+ parts = [f"{len(media_map)}/{media_total} Medien heruntergeladen"]
+ if media_purged:
+ parts.append(f"{media_purged} nicht mehr verfügbar (Server-Löschpolicy)")
+ if media_errors:
+ parts.append(f"{media_errors} Fehler")
+ yield _log("info", ", ".join(parts), progress=88)
+
+ # --- get room name + members ---
+ room_name = room_id
+ member_names: dict[str, str] = {}
+ try:
+ headers = {"Authorization": f"Bearer {self.access_token}"}
+ encoded_rid = quote(room_id, safe="")
+ async with aiohttp.ClientSession() as s:
+ async with s.get(
+ f"{self.homeserver}/_matrix/client/v3/rooms/{encoded_rid}/state",
+ headers=headers,
+ ) as r:
+ if r.status == 200:
+ for st in await r.json():
+ t = st.get("type", "")
+ c = st.get("content", {})
+ if t == "m.room.name":
+ room_name = c.get("name", room_id)
+ elif t == "m.room.member" and c.get("membership") == "join":
+ uid = st.get("state_key", "")
+ member_names[uid] = c.get("displayname", uid.split(":")[0].lstrip("@"))
+ except Exception:
+ pass
+
+ if room_name == room_id and member_names:
+ others = [n for uid, n in member_names.items() if uid != self.user_id]
+ if others:
+ room_name = ", ".join(others[:3])
+
+ # --- generate HTML ---
+ yield _log("info", "Generiere HTML …", progress=90)
+ html_path = room_dir / "chat.html"
+ await self._generate_html(all_events, media_map, member_names, room_name, self.user_id or "", html_path)
+
+ # --- create ZIP ---
+ yield _log("info", "Erstelle ZIP-Archiv …", progress=95)
+ zip_result = self.create_zip(safe_name)
+ zip_api_path = f"/api/exports/{safe_name}/zip" if zip_result else None
+
+ # --- save state ---
+ state = {
+ "room_id": room_id,
+ "room_name": room_name,
+ "total_events": len(all_events),
+ "total_media": len(media_map),
+ "last_export": datetime.now().isoformat(),
+ "last_token": from_token,
+ }
+ state_file.write_text(json.dumps(state, indent=2))
+
+ rel_path = f"/exports/{safe_name}/chat.html"
+ yield _log("done",
+ f"Export abgeschlossen! {len(all_events)} Nachrichten, {len(media_map)} Medien",
+ progress=100, html_path=rel_path, zip_path=zip_api_path)
+
+ # ------------------------------------------------------------------
+ # Media download
+ # ------------------------------------------------------------------
+
+ async def _download_media(self, ev: dict, media_dir: Path,
+ session: aiohttp.ClientSession | None = None) -> str | None:
+ content = ev.get("content", {})
+ file_info = content.get("file", {})
+ encrypted = bool(file_info and file_info.get("url"))
+
+ mxc_url = file_info.get("url", "") if encrypted else content.get("url", "")
+ if not mxc_url or not mxc_url.startswith("mxc://"):
+ mxc_url = content.get("avatar_url") or ""
+ if not mxc_url or not mxc_url.startswith("mxc://"):
+ return None
+ encrypted = False
+
+ parts = mxc_url[6:].split("/", 1)
+ if len(parts) != 2:
+ return None
+ server_name, media_id = parts
+
+ mimetype = content.get("info", {}).get("mimetype", "")
+ ext = EXT_MAP.get(mimetype, "")
+ filename = f"{media_id}{ext}"
+ filepath = media_dir / filename
+
+ if filepath.exists():
+ return filename
+
+ data = None
+ eid = ev.get("event_id", "?")
+
+ try:
+ resp = await self.client.download(mxc_url)
+ if isinstance(resp, DownloadResponse):
+ data = resp.body
+ elif isinstance(resp, DownloadError):
+ logger.debug("nio download fehlgeschlagen für %s (event %s): %s", mxc_url, eid, resp.message)
+ except Exception as e:
+ logger.debug("nio download exception für %s (event %s): %s", mxc_url, eid, e)
+
+ if data is None:
+ headers = {"Authorization": f"Bearer {self.access_token}"}
+ endpoints = [
+ f"/_matrix/client/v1/media/download/{server_name}/{media_id}",
+ f"/_matrix/media/v3/download/{server_name}/{media_id}",
+ ]
+ own_session = session is None
+ s = session or aiohttp.ClientSession()
+ ep_404_count = 0
+ try:
+ for ep in endpoints:
+ try:
+ async with s.get(f"{self.homeserver}{ep}", headers=headers) as r:
+ if r.status == 200:
+ data = await r.read()
+ break
+ elif r.status == 404:
+ ep_404_count += 1
+ logger.debug("HTTP 404 für %s%s (event %s)", self.homeserver, ep, eid)
+ else:
+ logger.debug("HTTP %s für %s%s (event %s)", r.status, self.homeserver, ep, eid)
+ except Exception as e:
+ logger.debug("HTTP exception für %s%s (event %s): %s", self.homeserver, ep, eid, e)
+ continue
+ finally:
+ if own_session:
+ await s.close()
+
+ if data is None and ep_404_count == len(endpoints):
+ logger.info("Media gelöscht (404 auf allen Endpoints): %s (event %s)", mxc_url, eid)
+ return MEDIA_PURGED
+
+ if data is None:
+ logger.warning("Media-Download fehlgeschlagen: %s (event %s)", mxc_url, eid)
+ return None
+
+ if encrypted and file_info:
+ try:
+ from nio.crypto.attachments import decrypt_attachment
+ data = decrypt_attachment(
+ data,
+ file_info["key"]["k"],
+ file_info["hashes"]["sha256"],
+ file_info["iv"],
+ )
+ except Exception as e:
+ logger.warning("Entschlüsselung fehlgeschlagen für %s: %s", mxc_url, e)
+ return None
+
+ async with aiofiles.open(filepath, "wb") as f:
+ await f.write(data)
+ return filename
+
+ # ------------------------------------------------------------------
+ # Preprocessing for client-side rendering
+ # ------------------------------------------------------------------
+
+ def _preprocess_messages(self, events: list[dict], media_map: dict[str, str],
+ member_names: dict[str, str], own_user_id: str) -> list[dict]:
+ messages: list[dict] = []
+ last_date = ""
+
+ for ev in events:
+ ts = ev.get("origin_server_ts", 0)
+ dt = _ts_to_datetime(ts)
+ date_str = dt.strftime("%A, %d. %B %Y")
+ time_str = dt.strftime("%H:%M")
+ etype = ev.get("type", "")
+ content = ev.get("content", {})
+
+ if date_str != last_date:
+ messages.append({"t": "date", "text": date_str})
+ last_date = date_str
+
+ if etype == "m.room.message":
+ sender = ev.get("sender", "")
+ display = member_names.get(sender, sender.split(":")[0].lstrip("@"))
+ msgtype = content.get("msgtype", "m.text")
+ eid = ev.get("event_id", "")
+ mfile = media_map.get(eid, "")
+
+ msg: dict = {
+ "t": "msg",
+ "s": sender,
+ "n": display,
+ "o": sender == own_user_id,
+ "ts": time_str,
+ "mt": msgtype,
+ "b": content.get("body", ""),
+ "c": _color_for_user(sender),
+ }
+
+ fb = content.get("formatted_body", "")
+ if fb:
+ msg["fb"] = fb
+ if mfile:
+ msg["m"] = mfile
+ if ev.get("unsigned", {}).get("redacted_because"):
+ msg["del"] = True
+ if msgtype == "m.audio":
+ if "org.matrix.msc3245.voice" in content or "org.matrix.msc1767.audio" in content:
+ msg["voice"] = True
+ if msgtype == "m.file":
+ msg["fs"] = _format_size(content.get("info", {}).get("size", 0))
+ if msgtype == "m.location":
+ msg["geo"] = content.get("geo_uri", "")
+
+ messages.append(msg)
+
+ elif etype == "m.room.encrypted":
+ sender = ev.get("sender", "")
+ messages.append({
+ "t": "enc",
+ "o": sender == own_user_id,
+ "ts": time_str,
+ })
+
+ elif etype == "m.room.member":
+ membership = content.get("membership", "")
+ display = content.get("displayname", ev.get("state_key", "?"))
+ prev = ev.get("unsigned", {}).get("prev_content", {})
+ prev_membership = prev.get("membership", "")
+
+ if membership == "join" and prev_membership == "join":
+ old_name = prev.get("displayname") or ""
+ new_name = content.get("displayname") or ""
+ old_avatar = prev.get("avatar_url") or ""
+ new_avatar = content.get("avatar_url") or ""
+
+ if old_name != new_name:
+ if old_name and new_name:
+ messages.append({"t": "sys", "text": f"{old_name} heißt jetzt {new_name}"})
+ elif new_name:
+ messages.append({"t": "sys", "text": f"{ev.get('state_key', '?')} heißt jetzt {new_name}"})
+
+ if old_avatar != new_avatar:
+ eid = ev.get("event_id", "")
+ mfile = media_map.get(eid, "")
+ text = f"{display} hat das Profilbild geändert" if new_avatar else f"{display} hat das Profilbild entfernt"
+ messages.append({"t": "avatar", "text": text, "m": mfile, "ts": time_str})
+
+ elif membership == "join":
+ messages.append({"t": "sys", "text": f"{display} ist beigetreten"})
+ elif membership == "leave":
+ sender = ev.get("sender", "")
+ if sender != ev.get("state_key", ""):
+ messages.append({"t": "sys", "text": f"{display} wurde entfernt"})
+ else:
+ messages.append({"t": "sys", "text": f"{display} hat den Raum verlassen"})
+ elif membership == "invite":
+ messages.append({"t": "sys", "text": f"{display} wurde eingeladen"})
+ elif membership == "ban":
+ messages.append({"t": "sys", "text": f"{display} wurde gebannt"})
+
+ elif etype == "m.room.topic":
+ topic = content.get("topic", "")
+ if topic:
+ messages.append({"t": "sys", "text": f"Thema geändert: {topic}"})
+ else:
+ messages.append({"t": "sys", "text": "Thema entfernt"})
+
+ elif etype == "m.room.name":
+ rname = content.get("name", "")
+ if rname:
+ messages.append({"t": "sys", "text": f"Raumname geändert zu: {rname}"})
+
+ elif etype == "m.room.avatar":
+ eid = ev.get("event_id", "")
+ mfile = media_map.get(eid, "")
+ messages.append({"t": "avatar", "text": "Raumbild wurde geändert", "m": mfile, "ts": time_str})
+
+ elif etype == "m.sticker":
+ sender = ev.get("sender", "")
+ eid = ev.get("event_id", "")
+ mfile = media_map.get(eid, "")
+ if mfile:
+ messages.append({
+ "t": "sticker",
+ "o": sender == own_user_id,
+ "ts": time_str,
+ "b": content.get("body", "Sticker"),
+ "m": mfile,
+ })
+
+ return messages
+
+ # ------------------------------------------------------------------
+ # HTML generation (client-side rendered)
+ # ------------------------------------------------------------------
+
+ async def _generate_html(
+ self,
+ events: list[dict],
+ media_map: dict[str, str],
+ member_names: dict[str, str],
+ room_name: str,
+ own_user_id: str,
+ output_path: Path,
+ ):
+ messages = self._preprocess_messages(events, media_map, member_names, own_user_id)
+
+ total = len(events)
+ first_ts = events[0].get("origin_server_ts", 0) if events else 0
+ last_ts = events[-1].get("origin_server_ts", 0) if events else 0
+ first_date = _ts_to_datetime(first_ts).strftime("%d.%m.%Y") if first_ts else ""
+ last_date = _ts_to_datetime(last_ts).strftime("%d.%m.%Y") if last_ts else ""
+ now = datetime.now().strftime("%d.%m.%Y %H:%M")
+
+ data = {
+ "roomName": room_name,
+ "total": total,
+ "firstDate": first_date,
+ "lastDate": last_date,
+ "exportDate": now,
+ "messages": messages,
+ }
+
+ json_str = json.dumps(data, ensure_ascii=False).replace("", "<\\/")
+
+ template_path = Path(__file__).parent / "templates" / "chat_export.html"
+ template = template_path.read_text(encoding="utf-8")
+ html = template.replace("__DATA__", json_str)
+
+ async with aiofiles.open(output_path, "w", encoding="utf-8") as f:
+ await f.write(html)
+
+ # ------------------------------------------------------------------
+ # ZIP creation
+ # ------------------------------------------------------------------
+
+ def create_zip(self, room_dir_name: str) -> Path | None:
+ room_dir = self.export_path / room_dir_name
+ if not room_dir.exists():
+ return None
+
+ html_file = room_dir / "chat.html"
+ if not html_file.exists():
+ return None
+
+ zip_path = room_dir / "export.zip"
+
+ with zipfile.ZipFile(zip_path, "w") as zf:
+ zf.write(html_file, "chat.html", compress_type=zipfile.ZIP_DEFLATED)
+
+ media_dir = room_dir / "media"
+ if media_dir.exists():
+ for fpath in sorted(media_dir.iterdir()):
+ if fpath.is_file():
+ zf.write(fpath, f"media/{fpath.name}", compress_type=zipfile.ZIP_STORED)
+
+ return zip_path
+
+ # ------------------------------------------------------------------
+ # Existing exports
+ # ------------------------------------------------------------------
+
+ def delete_export(self, room_dir_name: str) -> bool:
+ room_dir = self.export_path / room_dir_name
+ if not room_dir.exists():
+ return False
+ shutil.rmtree(room_dir)
+ return True
+
+ def list_exports(self) -> list[dict]:
+ exports = []
+ if not self.export_path.exists():
+ return exports
+ for d in sorted(self.export_path.iterdir()):
+ if d.is_dir() and (d / "chat.html").exists():
+ state = {}
+ sf = d / ".state.json"
+ if sf.exists():
+ try:
+ state = json.loads(sf.read_text())
+ except Exception:
+ pass
+ exports.append({
+ "dir_name": d.name,
+ "room_id": state.get("room_id", d.name),
+ "room_name": state.get("room_name", d.name),
+ "last_export": state.get("last_export", "?"),
+ "total_events": state.get("total_events", 0),
+ "total_media": state.get("total_media", 0),
+ "html_path": f"/exports/{d.name}/chat.html",
+ "zip_path": f"/api/exports/{d.name}/zip",
+ })
+ return exports
+
+
+# ======================================================================
+# Helper functions
+# ======================================================================
+
+def _log(typ: str, msg: str, progress: int = 0, **extra) -> dict:
+ d = {"type": typ, "message": msg, "progress": progress}
+ d.update(extra)
+ return d
+
+
+def _esc(text: str) -> str:
+ return (text
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """))
+
+
+def _has_media(ev: dict) -> bool:
+ c = ev.get("content", {})
+ if c.get("url") or c.get("file", {}).get("url"):
+ return True
+ if ev.get("type") == "m.room.member" and (c.get("avatar_url") or "").startswith("mxc://"):
+ prev = ev.get("unsigned", {}).get("prev_content", {})
+ if c.get("avatar_url") != prev.get("avatar_url", ""):
+ return True
+ return False
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..da12632
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,242 @@
+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")
diff --git a/app/templates/chat_export.html b/app/templates/chat_export.html
new file mode 100644
index 0000000..5be9700
--- /dev/null
+++ b/app/templates/chat_export.html
@@ -0,0 +1,266 @@
+
+
+
+
+
+Chat Export
+
+
+
+
+
+
+
Nachrichten werden geladen...
+
+
+
+
+
+
+↑
+
+
+
+
+
diff --git a/app/templates/index.html b/app/templates/index.html
new file mode 100644
index 0000000..2280cbf
--- /dev/null
+++ b/app/templates/index.html
@@ -0,0 +1,654 @@
+
+
+
+
+
+Matrix Chat Export
+
+
+
+
+
▩ Matrix Chat Export
+
Exportiere verschlüsselte Matrix-Chats als schöne HTML-Dateien mit Medien
+
+
+
+
1 Verbinden
+
+
+
Passwort
+
Access Token
+
+
+
+
+
+
+
+ Verbinden
+ Trennen
+
+
+
+
+
+
+
2 E2E-Schlüssel importieren
+
+
+ Wichtig: Exportiere deine E2E-Schlüssel aus Element:
+ Einstellungen → Sicherheit → "E2E-Raumschlüssel exportieren".
+ Die exportierte Datei und das Passwort werden hier benötigt.
+
+
+
+ Schlüsseldatei (.txt)
+
+
+
+ Export-Passphrase
+
+
+
+
+ Schlüssel importieren
+ Überspringen (ohne E2E)
+
+
+
+
+
+
+
3 Raum auswählen
+
+
+
+
+
+
Lade Räume …
+
+
+
+
+ Räume neu laden
+
+
+
+
+
+
+
+
+
Vorherige Exports
+
Lade …
+
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..8f46f5d
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,26 @@
+services:
+ matrix-chat-export:
+ build: .
+ container_name: matrix-chat-export
+ restart: unless-stopped
+ ports:
+ - "8092:8080"
+ volumes:
+ - ./data/exports:/app/exports
+ - ./data/store:/app/store
+ environment:
+ - TZ=Europe/Berlin
+ - LOG_LEVEL=INFO
+ networks:
+ matrixexport_net:
+ ipv4_address: 172.27.83.10
+
+networks:
+ matrixexport_net:
+ name: matrixexport.dockernetwork.local
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.27.83.0/24
+ gateway: 172.27.83.1
+ ip_range: 172.27.83.128/25
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..7105965
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+fastapi==0.115.6
+uvicorn[standard]==0.34.0
+matrix-nio[e2e]==0.25.2
+python-multipart==0.0.20
+aiofiles==24.1.0
+jinja2==3.1.5
+aiohttp==3.11.11