Add application, Dockerfile, CI workflow and documentation
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>
This commit is contained in:
Patrick Asmus
2026-07-03 08:04:27 +02:00
parent 6c3bea273a
commit 4dae5477ce
11 changed files with 2161 additions and 22 deletions

View File

@@ -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

18
Dockerfile Normal file
View File

@@ -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"]

37
LICENSE
View File

@@ -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 <https://www.gnu.org/licenses/>.
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.

View File

@@ -4,10 +4,10 @@
</a>
</p>
<h1 align="center">Name des Projekts</h1>
<h1 align="center">Matrix Chat Export</h1>
<h4 align="center">
Kurzbeschreibung des Projekts/Anwendung, um die es geht
Web-basiertes Tool zum Exportieren von Matrix-Chaträumen inkl. Medien und E2E-Verschlüsselung
</h4>
<h6 align="center">
@@ -22,7 +22,59 @@
<br><br>
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://<host>: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
<br><br>

0
app/__init__.py Normal file
View File

837
app/exporter.py Normal file
View File

@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
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

242
app/main.py Normal file
View File

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

View File

@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Chat Export</title>
<style>
:root {
--bg:#0b141a;--bg-chat:#0b141a;--msg-own:#005c4b;--msg-other:#202c33;
--msg-own-h:#006d5a;--msg-other-h:#263845;--text:#e9edef;--text2:#8696a0;
--border:#222d34;--accent:#00a884;--accent2:#06cf9c;--pill-bg:#182229;
--header:#202c33;--link:#53bdeb;--shadow:0 1px 2px rgba(0,0,0,.3);
--sys-bg:#182229;--sys-text:#8696a0;
}
@media(prefers-color-scheme:light){:root{
--bg:#efeae2;--bg-chat:#efeae2;--msg-own:#d9fdd3;--msg-other:#fff;
--msg-own-h:#c8f0c2;--msg-other-h:#f5f5f5;--text:#111b21;--text2:#667781;
--border:#e9edef;--accent:#008069;--accent2:#00a884;--pill-bg:#fff;
--header:#008069;--link:#027eb5;--shadow:0 1px 3px rgba(0,0,0,.12);
--sys-bg:rgba(255,255,255,.9);--sys-text:#54656f;
}}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;
background:var(--bg);color:var(--text);line-height:1.45;font-size:14.2px}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-thumb{background:#374045;border-radius:3px}
.app{max-width:1200px;margin:0 auto;min-height:100vh;display:flex;flex-direction:column;
background:var(--bg-chat);box-shadow:0 0 60px rgba(0,0,0,.3)}
.header{background:var(--header);padding:14px 20px;display:flex;align-items:center;
gap:14px;position:sticky;top:0;z-index:100;border-bottom:1px solid var(--border)}
.header-avatar{width:42px;height:42px;border-radius:50%;background:var(--accent);
display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff;flex-shrink:0}
.header-info h1{font-size:16px;font-weight:600;color:#fff}
.header-info .sub{font-size:12px;color:rgba(255,255,255,.7);margin-top:1px}
.chat{flex:1;padding:8px 40px 20px;overflow-y:auto}
.date-separator{display:flex;justify-content:center;margin:16px 0 8px}
.date-pill{background:var(--pill-bg);color:var(--text2);padding:5px 12px;
border-radius:8px;font-size:12.5px;box-shadow:var(--shadow)}
.msg-group{margin-bottom:2px;display:flex}
.msg-group.own{justify-content:flex-end}
.msg-group.other{justify-content:flex-start}
.msg{max-width:75%;min-width:120px;padding:6px 7px 8px 9px;border-radius:8px;
position:relative;box-shadow:var(--shadow);word-wrap:break-word;overflow-wrap:break-word}
.msg-group.own .msg{background:var(--msg-own);border-top-right-radius:0}
.msg-group.other .msg{background:var(--msg-other);border-top-left-radius:0}
.msg-group.own .msg:hover{background:var(--msg-own-h)}
.msg-group.other .msg:hover{background:var(--msg-other-h)}
.msg .sender{font-size:13px;font-weight:600;margin-bottom:2px;display:block}
.msg .body{white-space:pre-wrap;font-size:14.2px;line-height:1.45;overflow-x:auto}
.msg .body pre{background:rgba(0,0,0,.2);padding:8px 10px;border-radius:4px;
overflow-x:auto;white-space:pre;font-size:13px;line-height:1.4;margin:4px 0}
.msg .body code{background:rgba(0,0,0,.15);padding:1px 4px;border-radius:3px;font-size:13px}
.msg .body pre code{background:none;padding:0}
.msg .body a{color:var(--link);text-decoration:none}
.msg .body a:hover{text-decoration:underline}
.msg .meta{display:flex;justify-content:flex-end;gap:4px;margin-top:2px;
float:right;margin-left:12px;position:relative;top:5px}
.msg .meta .time{font-size:11px;color:var(--text2)}
.msg .media-wrap{margin:4px -3px;border-radius:6px;overflow:hidden}
.msg .media-wrap img{max-width:100%;max-height:400px;display:block;cursor:pointer;
border-radius:6px;transition:opacity .15s}
.msg .media-wrap img:hover{opacity:.92}
.msg .media-wrap video{max-width:100%;max-height:400px;display:block;border-radius:6px;background:#000}
.msg .media-wrap audio{width:100%;min-width:240px;height:40px;margin:4px 0}
.msg .file-attach{display:flex;align-items:center;gap:10px;padding:8px 10px;
background:rgba(0,0,0,.1);border-radius:6px;margin:4px 0;text-decoration:none;
color:var(--text);transition:background .15s}
.msg .file-attach:hover{background:rgba(0,0,0,.18)}
.msg .file-attach .file-icon{font-size:28px;flex-shrink:0}
.msg .file-attach .file-name{font-size:13.5px;font-weight:500;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
.msg .file-attach .file-size{font-size:11.5px;color:var(--text2)}
.system-event{display:flex;justify-content:center;margin:6px 0}
.system-event span{background:var(--sys-bg);color:var(--sys-text);padding:4px 12px;
border-radius:6px;font-size:12px;box-shadow:var(--shadow);text-align:center}
.emote-msg{font-style:italic;color:var(--text2)}
.redacted,.encrypted-msg{font-style:italic;color:var(--text2);font-size:13px}
.footer{background:var(--header);padding:12px 20px;text-align:center;
font-size:12px;color:var(--text2);border-top:1px solid var(--border)}
.lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,.92);z-index:1000;cursor:zoom-out;justify-content:center;align-items:center}
.lightbox.active{display:flex}
.lightbox img{max-width:95%;max-height:95%;object-fit:contain;border-radius:4px}
.scroll-top{position:fixed;bottom:20px;right:20px;width:44px;height:44px;
border-radius:50%;background:var(--accent);color:#fff;border:none;cursor:pointer;
display:none;align-items:center;justify-content:center;font-size:20px;
box-shadow:0 2px 10px rgba(0,0,0,.3);z-index:50}
.scroll-top.visible{display:flex}
#loading{display:flex;justify-content:center;align-items:center;padding:40px;color:var(--text2);font-size:15px}
@media(max-width:600px){.chat{padding:8px 12px 20px}.msg{max-width:92%}}
</style>
</head>
<body>
<div class="app">
<header class="header">
<div class="header-avatar">&#128172;</div>
<div class="header-info">
<h1 id="room-name">Lade...</h1>
<div class="sub" id="room-info"></div>
</div>
</header>
<div class="chat" id="chat">
<div id="loading">Nachrichten werden geladen...</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightbox-img" src="" alt="">
</div>
<button class="scroll-top" id="scrollTop" title="Nach oben">&#8593;</button>
<div class="footer" id="footer"></div>
<script>
var D = __DATA__;
document.title = "Chat: " + D.roomName;
document.getElementById("room-name").textContent = D.roomName;
document.getElementById("room-info").textContent = D.total + " Nachrichten · " + D.firstDate + " bis " + D.lastDate;
document.getElementById("footer").innerHTML = "Matrix Chat Export · " + D.total + " Nachrichten · " + D.firstDate + " bis " + D.lastDate + " · Erstellt am " + D.exportDate;
var CHUNK = 200;
var rendered = 0;
var lastSender = "";
var chat = document.getElementById("chat");
var msgs = D.messages;
document.getElementById("loading").style.display = "none";
function esc(s) {
if (!s) return "";
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function linkify(t) {
return t.replace(/(https?:\/\/[^\s<>&]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
}
function renderBody(m) {
var mt = m.mt;
if (mt === "m.text" || mt === "m.notice") {
return m.fb ? '<div class="body">' + m.fb + "</div>"
: '<div class="body">' + linkify(esc(m.b)) + "</div>";
}
if (mt === "m.emote") {
return '<div class="body emote-msg">* ' + esc(m.b) + "</div>";
}
if (mt === "m.image") {
if (m.m) return '<div class="media-wrap"><a href="media/' + m.m + '" target="_blank"><img src="media/' + m.m + '" alt="' + esc(m.b) + '" loading="lazy" onclick="openLightbox(this.src);return false"></a></div>';
return '<div class="body">[Bild: ' + esc(m.b) + "]</div>";
}
if (mt === "m.video") {
if (m.m) return '<div class="media-wrap"><video controls preload="metadata" src="media/' + m.m + '"></video></div>';
return '<div class="body">[Video: ' + esc(m.b) + "]</div>";
}
if (mt === "m.audio") {
var label = m.voice ? "🎤 Sprachnachricht" : "🎵 " + esc(m.b);
if (m.m) return '<div class="body" style="font-size:12.5px;color:var(--text2);margin-bottom:2px">' + label + '</div><div class="media-wrap"><audio controls preload="metadata" src="media/' + m.m + '"></audio></div>';
return '<div class="body">[Audio: ' + esc(m.b) + "]</div>";
}
if (mt === "m.file") {
if (m.m) return '<a class="file-attach" href="media/' + m.m + '" download="' + esc(m.b) + '"><span class="file-icon">&#128206;</span><div class="file-info"><div class="file-name">' + esc(m.b) + '</div><div class="file-size">' + (m.fs || "") + "</div></div></a>";
return '<div class="body">[Datei: ' + esc(m.b) + "]</div>";
}
if (mt === "m.location") {
if (m.geo) {
var coords = m.geo.replace("geo:", "").split(";")[0];
var p = coords.split(",");
return '<div class="body">&#128205; <a href="https://www.openstreetmap.org/?mlat=' + p[0] + "&mlon=" + (p[1] || "0") + "#map=16/" + p[0] + "/" + (p[1] || "0") + '" target="_blank" rel="noopener">' + esc(m.b) + "</a></div>";
}
return '<div class="body">&#128205; ' + esc(m.b) + "</div>";
}
return '<div class="body">' + esc(m.b || "") + "</div>";
}
function renderMessage(m) {
if (m.t === "date") {
lastSender = "";
return '<div class="date-separator"><span class="date-pill">' + esc(m.text) + "</span></div>";
}
if (m.t === "sys") {
lastSender = "";
return '<div class="system-event"><span>' + esc(m.text) + "</span></div>";
}
if (m.t === "avatar") {
lastSender = "";
var inner = "";
if (m.m) {
inner += '<img src="media/' + m.m + '" style="width:28px;height:28px;border-radius:50%;vertical-align:middle;margin-right:6px" loading="lazy">';
}
inner += esc(m.text);
return '<div class="system-event"><span>' + inner + "</span></div>";
}
if (m.t === "enc") {
return '<div class="msg-group ' + (m.o ? "own" : "other") + '"><div class="msg"><div class="body encrypted-msg">&#128274; Verschlüsselte Nachricht</div><div class="meta"><span class="time">' + m.ts + "</span></div></div></div>";
}
if (m.t === "sticker") {
if (!m.m) return "";
return '<div class="msg-group ' + (m.o ? "own" : "other") + '"><div class="msg" style="background:transparent;box-shadow:none;padding:4px"><div class="media-wrap"><img src="media/' + m.m + '" alt="' + esc(m.b) + '" style="max-width:160px;max-height:160px" loading="lazy"></div><div class="meta"><span class="time">' + m.ts + "</span></div></div></div>";
}
if (m.t === "msg") {
var cls = m.o ? "own" : "other";
var sender = "";
if (!m.o && m.s !== lastSender) {
sender = '<span class="sender" style="color:' + m.c + '">' + esc(m.n) + "</span>";
}
lastSender = m.s;
var body = m.del ? '<div class="body redacted">Diese Nachricht wurde gelöscht</div>' : renderBody(m);
return '<div class="msg-group ' + cls + '"><div class="msg">' + sender + body + '<div class="meta"><span class="time">' + m.ts + "</span></div></div></div>";
}
return "";
}
function renderChunk() {
var end = Math.min(rendered + CHUNK, msgs.length);
var html = "";
for (var i = rendered; i < end; i++) {
html += renderMessage(msgs[i]);
}
var sentinel = document.getElementById("sentinel");
if (sentinel) sentinel.remove();
var frag = document.createElement("div");
frag.innerHTML = html;
while (frag.firstChild) chat.appendChild(frag.firstChild);
rendered = end;
if (rendered < msgs.length) {
var s = document.createElement("div");
s.id = "sentinel";
s.style.height = "1px";
chat.appendChild(s);
observer.observe(s);
}
}
var observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting) renderChunk();
}, {rootMargin: "1200px"});
renderChunk();
function openLightbox(s) {
var l = document.getElementById("lightbox");
document.getElementById("lightbox-img").src = s;
l.classList.add("active");
}
function closeLightbox() {
document.getElementById("lightbox").classList.remove("active");
}
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") closeLightbox();
});
var scrollBtn = document.getElementById("scrollTop");
window.addEventListener("scroll", function() {
scrollBtn.classList.toggle("visible", window.scrollY > 800);
});
scrollBtn.addEventListener("click", function() {
window.scrollTo({top: 0, behavior: "smooth"});
});
</script>
</body>
</html>

654
app/templates/index.html Normal file
View File

@@ -0,0 +1,654 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Matrix Chat Export</title>
<style>
:root {
--bg: #0a0e14;
--surface: #131920;
--surface2: #1a2230;
--surface3: #222d3a;
--text: #e6edf3;
--text2: #8b949e;
--accent: #00a884;
--accent2: #06cf9c;
--danger: #f85149;
--warning: #d29922;
--border: #21262d;
--radius: 12px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
min-height: 100vh;
}
.container { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
h1 { font-size: 28px; font-weight: 700; margin-bottom: 4px; }
h1 span { color: var(--accent); }
.subtitle { color: var(--text2); font-size: 14px; margin-bottom: 32px; }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px; margin-bottom: 16px;
transition: opacity .3s;
}
.card.disabled { opacity: .4; pointer-events: none; }
.card h2 {
font-size: 16px; font-weight: 600; margin-bottom: 16px;
display: flex; align-items: center; gap: 10px;
}
.card h2 .num {
background: var(--accent); color: #fff; width: 28px; height: 28px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 13px; flex-shrink: 0;
}
.card h2 .check {
background: var(--accent2); color: #000; width: 28px; height: 28px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 15px; flex-shrink: 0;
}
.form-row { margin-bottom: 12px; }
.form-row label { display: block; font-size: 13px; color: var(--text2); margin-bottom: 4px; }
.form-row input, .form-row select {
width: 100%; padding: 10px 14px; background: var(--surface2);
border: 1px solid var(--border); border-radius: 8px; color: var(--text);
font-size: 14px; outline: none; transition: border-color .2s;
}
.form-row input:focus { border-color: var(--accent); }
.form-row input[type="file"] { padding: 8px; }
.tabs { display: flex; gap: 0; margin-bottom: 16px; }
.tab {
flex: 1; padding: 8px; text-align: center; font-size: 13px; font-weight: 500;
cursor: pointer; background: var(--surface2); border: 1px solid var(--border);
color: var(--text2); transition: all .2s;
}
.tab:first-child { border-radius: 8px 0 0 8px; }
.tab:last-child { border-radius: 0 8px 8px 0; }
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn {
display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px;
background: var(--accent); color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all .2s;
text-decoration: none;
}
.btn:hover { background: var(--accent2); color: #000; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.secondary { background: var(--surface3); color: var(--text); }
.btn.secondary:hover { background: var(--surface2); }
.btn.danger { background: var(--danger); }
.btn-row { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.status {
margin-top: 12px; padding: 10px 14px; border-radius: 8px;
font-size: 13px; display: none;
}
.status.success { display: block; background: rgba(0,168,132,.15); color: var(--accent2); border: 1px solid rgba(0,168,132,.3); }
.status.error { display: block; background: rgba(248,81,73,.15); color: var(--danger); border: 1px solid rgba(248,81,73,.3); }
.status.info { display: block; background: rgba(56,139,253,.15); color: #58a6ff; border: 1px solid rgba(56,139,253,.3); }
.room-search {
margin-bottom: 12px;
}
.room-search input {
width: 100%; padding: 10px 14px; background: var(--surface2);
border: 1px solid var(--border); border-radius: 8px; color: var(--text);
font-size: 14px; outline: none;
}
.room-search input:focus { border-color: var(--accent); }
.room-list { display: flex; flex-direction: column; gap: 4px; max-height: 400px; overflow-y: auto; }
.room-card {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; cursor: pointer; transition: all .15s; flex-shrink: 0;
}
.room-card:hover { border-color: var(--accent); background: var(--surface3); }
.room-card .name {
font-weight: 500; font-size: 14px; flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.room-card .meta { font-size: 12px; color: var(--text2); white-space: nowrap; }
.room-card .badge {
font-size: 11px; padding: 2px 8px; border-radius: 20px;
background: var(--surface3); color: var(--text2); white-space: nowrap;
}
.room-card .badge.enc { background: rgba(210,153,34,.2); color: var(--warning); }
.log-box {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 16px; max-height: 400px; overflow-y: auto; font-family: 'JetBrains Mono',
'Cascadia Code', 'Fira Code', monospace; font-size: 12.5px; line-height: 1.7;
}
.log-line { padding: 1px 0; }
.log-line.info { color: #58a6ff; }
.log-line.progress { color: var(--accent2); }
.log-line.warning { color: var(--warning); }
.log-line.error { color: var(--danger); }
.log-line.done { color: var(--accent2); font-weight: 600; }
.progress-bar {
width: 100%; height: 6px; background: var(--surface3); border-radius: 3px;
margin: 12px 0; overflow: hidden;
}
.progress-bar .fill {
height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2));
border-radius: 3px; transition: width .3s ease; width: 0%;
}
.export-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; background: var(--surface2); border-radius: 8px;
margin-bottom: 8px; border: 1px solid var(--border); gap: 8px;
}
.export-item .info { flex: 1; min-width: 0; }
.export-item .info .name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.export-item .info .meta { font-size: 12px; color: var(--text2); }
.export-item .actions { display: flex; gap: 6px; flex-shrink: 0; }
.note {
background: var(--surface2); border-left: 3px solid var(--warning);
padding: 12px 16px; border-radius: 0 8px 8px 0; margin-bottom: 16px;
font-size: 13px; color: var(--text2);
}
.note strong { color: var(--text); }
.hidden { display: none !important; }
@media (max-width: 600px) {
.container { padding: 16px 12px; }
}
</style>
</head>
<body>
<div class="container">
<h1><span>&#9641;</span> Matrix Chat Export</h1>
<p class="subtitle">Exportiere verschlüsselte Matrix-Chats als schöne HTML-Dateien mit Medien</p>
<!-- STEP 1: Connect -->
<div class="card" id="card-connect">
<h2><span class="num" id="step1-icon">1</span> Verbinden</h2>
<div class="tabs">
<div class="tab active" onclick="switchTab('password')">Passwort</div>
<div class="tab" onclick="switchTab('token')">Access Token</div>
</div>
<div id="tab-password">
<div class="form-row">
<label>Homeserver URL</label>
<input type="url" id="hs-url" placeholder="https://matrix.example.com" value="">
</div>
<div class="form-row">
<label>Benutzername (ohne @...)</label>
<input type="text" id="username" placeholder="benutzername" autocomplete="username">
</div>
<div class="form-row">
<label>Passwort</label>
<input type="password" id="password" placeholder="Passwort" autocomplete="current-password">
</div>
</div>
<div id="tab-token" class="hidden">
<div class="form-row">
<label>Homeserver URL</label>
<input type="url" id="hs-url2" placeholder="https://matrix.example.com">
</div>
<div class="form-row">
<label>Access Token</label>
<input type="password" id="access-token" placeholder="syt_...">
</div>
</div>
<div class="btn-row">
<button class="btn" id="btn-connect" onclick="doConnect()">Verbinden</button>
<button class="btn danger hidden" id="btn-disconnect" onclick="doDisconnect()">Trennen</button>
</div>
<div class="status" id="connect-status"></div>
</div>
<!-- STEP 2: Import Keys -->
<div class="card disabled" id="card-keys">
<h2><span class="num" id="step2-icon">2</span> E2E-Schlüssel importieren</h2>
<div class="note">
<strong>Wichtig:</strong> Exportiere deine E2E-Schlüssel aus Element:
Einstellungen &rarr; Sicherheit &rarr; &quot;E2E-Raumschlüssel exportieren&quot;.
Die exportierte Datei und das Passwort werden hier benötigt.
</div>
<div class="form-row">
<label>Schlüsseldatei (.txt)</label>
<input type="file" id="key-file" accept=".txt,.key">
</div>
<div class="form-row">
<label>Export-Passphrase</label>
<input type="password" id="key-passphrase" placeholder="Passphrase vom Key-Export">
</div>
<div class="btn-row">
<button class="btn" id="btn-import" onclick="doImportKeys()">Schlüssel importieren</button>
<button class="btn secondary" onclick="skipKeys()">Überspringen (ohne E2E)</button>
</div>
<div class="status" id="keys-status"></div>
</div>
<!-- STEP 3: Rooms -->
<div class="card disabled" id="card-rooms">
<h2><span class="num" id="step3-icon">3</span> Raum auswählen</h2>
<div class="room-search">
<input type="search" id="room-search" placeholder="Raum suchen..." oninput="filterRooms()">
</div>
<div id="rooms-loading" class="hidden" style="color:var(--text2);font-size:14px">Lade Räume …</div>
<div class="room-list" id="room-list"></div>
<div class="status" id="rooms-status"></div>
<div class="btn-row" style="margin-top:12px">
<button class="btn secondary" onclick="loadRooms()">Räume neu laden</button>
</div>
</div>
<!-- STEP 4: Export Progress -->
<div class="card hidden" id="card-export">
<h2><span class="num" id="step4-icon">4</span> Export</h2>
<div class="progress-bar"><div class="fill" id="progress-fill"></div></div>
<div class="log-box" id="log-box"></div>
<div class="btn-row hidden" id="export-actions">
<a class="btn" id="btn-open-export" href="#" target="_blank">Export öffnen</a>
<a class="btn secondary hidden" id="btn-download-zip" href="#" download>&#128230; ZIP herunterladen</a>
<button class="btn secondary" onclick="location.reload()">Neuen Export starten</button>
</div>
</div>
<!-- Previous Exports -->
<div class="card" id="card-exports">
<h2>Vorherige Exports</h2>
<div id="exports-list"><span style="color:var(--text2);font-size:14px">Lade …</span></div>
</div>
</div>
<script>
var currentTab = "password";
var connected = false;
var keysImported = false;
var allRooms = [];
var roomsTotal = 0;
var roomsES = null;
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll(".tabs .tab").forEach(function(t) { t.classList.remove("active"); });
event.target.classList.add("active");
document.getElementById("tab-password").classList.toggle("hidden", tab !== "password");
document.getElementById("tab-token").classList.toggle("hidden", tab !== "token");
}
function setStatus(id, type, msg) {
var el = document.getElementById(id);
el.className = "status " + type;
el.textContent = msg;
}
function stepDone(num) {
var icon = document.getElementById("step" + num + "-icon");
icon.className = "check";
icon.textContent = "✓";
}
async function doConnect() {
var btn = document.getElementById("btn-connect");
btn.disabled = true;
btn.textContent = "Verbinde …";
try {
var body;
if (currentTab === "password") {
body = {
method: "password",
homeserver: document.getElementById("hs-url").value,
username: document.getElementById("username").value,
password: document.getElementById("password").value
};
} else {
body = {
method: "token",
homeserver: document.getElementById("hs-url2").value,
token: document.getElementById("access-token").value
};
}
var resp = await fetch("/api/connect", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body)
});
if (!resp.ok) {
var err = await resp.json();
throw new Error(err.detail || "Verbindung fehlgeschlagen");
}
var data = await resp.json();
connected = true;
setStatus("connect-status", "success",
"Verbunden als " + data.user_id + (data.device_id ? " (Device: " + data.device_id + ")" : ""));
stepDone(1);
document.getElementById("btn-disconnect").classList.remove("hidden");
document.getElementById("card-keys").classList.remove("disabled");
if (data.new_session) {
setStatus("keys-status", "info",
"Neues Device erstellt. Bitte importiere deine E2E-Schlüssel für entschlüsselte Exports.");
}
} catch (e) {
setStatus("connect-status", "error", e.message);
}
btn.disabled = false;
btn.textContent = "Verbinden";
}
async function doDisconnect() {
await fetch("/api/disconnect", {method: "POST"});
location.reload();
}
async function doImportKeys() {
var btn = document.getElementById("btn-import");
var fileInput = document.getElementById("key-file");
var passphrase = document.getElementById("key-passphrase").value;
if (!fileInput.files.length) {
setStatus("keys-status", "error", "Bitte Schlüsseldatei auswählen");
return;
}
btn.disabled = true;
btn.textContent = "Importiere …";
try {
var form = new FormData();
form.append("file", fileInput.files[0]);
form.append("passphrase", passphrase);
var resp = await fetch("/api/import-keys", {method: "POST", body: form});
if (!resp.ok) {
var err = await resp.json();
throw new Error(err.detail || "Import fehlgeschlagen");
}
var data = await resp.json();
keysImported = true;
setStatus("keys-status", "success", data.message);
stepDone(2);
enableRooms();
} catch (e) {
setStatus("keys-status", "error", e.message);
}
btn.disabled = false;
btn.textContent = "Schlüssel importieren";
}
function skipKeys() {
keysImported = false;
setStatus("keys-status", "info", "Übersprungen. Verschlüsselte Nachrichten werden nicht entschlüsselt.");
stepDone(2);
enableRooms();
}
function enableRooms() {
document.getElementById("card-rooms").classList.remove("disabled");
loadRooms();
}
function filterRooms() {
var q = document.getElementById("room-search").value.toLowerCase();
var cards = document.querySelectorAll("#room-list .room-card");
cards.forEach(function(card) {
var name = card.dataset.name.toLowerCase();
card.style.display = name.includes(q) ? "" : "none";
});
}
function addRoomCard(room) {
allRooms.push(room);
var list = document.getElementById("room-list");
var card = document.createElement("div");
card.className = "room-card";
card.dataset.name = room.name;
card.onclick = function() { startExport(room.room_id, room.name); };
card.innerHTML =
'<div class="name">' + escHtml(room.name) + "</div>" +
'<div class="meta">' + room.member_count + " Mitgl.</div>" +
(room.encrypted ? '<div class="badge enc">&#128274; E2E</div>' : '<div class="badge">Offen</div>');
var q = document.getElementById("room-search").value.toLowerCase();
if (q && !room.name.toLowerCase().includes(q)) {
card.style.display = "none";
}
list.appendChild(card);
}
function loadRooms() {
if (roomsES) { roomsES.close(); roomsES = null; }
var list = document.getElementById("room-list");
var loading = document.getElementById("rooms-loading");
list.innerHTML = "";
allRooms = [];
roomsTotal = 0;
loading.classList.remove("hidden");
loading.textContent = "Lade Räume …";
roomsES = new EventSource("/api/rooms/stream");
roomsES.onmessage = function(e) {
var data = JSON.parse(e.data);
if (data.type === "total") {
roomsTotal = data.count;
loading.textContent = "Lade 0/" + roomsTotal + " Räume …";
} else if (data.type === "room") {
setTimeout(function() {
addRoomCard(data.data);
loading.textContent = "Lade " + allRooms.length + "/" + roomsTotal + " Räume …";
}, 0);
} else if (data.type === "done") {
roomsES.close();
roomsES = null;
setTimeout(function() {
loading.classList.add("hidden");
if (allRooms.length === 0) {
list.innerHTML = '<p style="color:var(--text2)">Keine Räume gefunden</p>';
}
}, 50);
} else if (data.type === "error") {
roomsES.close();
roomsES = null;
loading.classList.add("hidden");
setStatus("rooms-status", "error", data.message);
}
};
roomsES.onerror = function() {
roomsES.close();
roomsES = null;
loading.classList.add("hidden");
};
}
async function startExport(roomId, roomName) {
if (!confirm('Chat "' + roomName + '" wirklich exportieren?')) return;
document.getElementById("card-export").classList.remove("hidden");
document.getElementById("card-export").scrollIntoView({behavior: "smooth"});
var logBox = document.getElementById("log-box");
logBox.innerHTML = "";
document.getElementById("progress-fill").style.width = "0%";
document.getElementById("export-actions").classList.add("hidden");
document.getElementById("btn-download-zip").classList.add("hidden");
addLog("info", "Starte Export für: " + roomName);
try {
var resp = await fetch("/api/export", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({room_id: roomId})
});
if (!resp.ok) {
var err = await resp.json();
addLog("error", err.detail || "Fehler");
return;
}
var es = new EventSource("/api/export/progress/" + encodeURIComponent(roomId));
es.onmessage = function(e) {
var data = JSON.parse(e.data);
if (data.type === "stream_end") {
es.close();
return;
}
addLog(data.type, data.message);
if (data.progress) {
document.getElementById("progress-fill").style.width = data.progress + "%";
}
if (data.type === "done") {
es.close();
stepDone(4);
document.getElementById("export-actions").classList.remove("hidden");
if (data.html_path) {
document.getElementById("btn-open-export").href = data.html_path;
}
if (data.zip_path) {
var zipBtn = document.getElementById("btn-download-zip");
zipBtn.href = data.zip_path;
zipBtn.classList.remove("hidden");
}
loadExports();
}
};
es.onerror = function() {
es.close();
addLog("error", "Verbindung zum Server verloren");
};
} catch (e) {
addLog("error", e.message);
}
}
function addLog(type, message) {
var box = document.getElementById("log-box");
var line = document.createElement("div");
line.className = "log-line " + type;
var prefix = {info: "[i]", progress: "[>]", warning: "[!]", error: "[x]", done: "[OK]"};
line.textContent = (prefix[type] || "[?]") + " " + message;
box.appendChild(line);
box.scrollTop = box.scrollHeight;
}
async function loadExports() {
var list = document.getElementById("exports-list");
try {
var resp = await fetch("/api/exports");
var exports = await resp.json();
if (exports.length === 0) {
list.innerHTML = '<span style="color:var(--text2);font-size:14px">Noch keine Exports vorhanden</span>';
return;
}
list.innerHTML = "";
exports.forEach(function(ex) {
var item = document.createElement("div");
item.className = "export-item";
item.innerHTML =
'<div class="info">' +
'<div class="name">' + escHtml(ex.room_name) + "</div>" +
'<div class="meta">' + ex.total_events + " Nachrichten, " + ex.total_media + " Medien &middot; " + ex.last_export + "</div>" +
"</div>" +
'<div class="actions">' +
'<a class="btn secondary" href="' + ex.html_path + '" target="_blank" style="font-size:13px;padding:6px 14px">Öffnen</a>' +
(ex.zip_path ? '<a class="btn secondary" href="' + ex.zip_path + '" download style="font-size:13px;padding:6px 14px">&#128230; ZIP</a>' : "") +
'<button class="btn danger" style="font-size:13px;padding:6px 14px" onclick="deleteExport(\'' + ex.dir_name.replace(/'/g, "\\'") + '\', \'' + escHtml(ex.room_name).replace(/'/g, "\\'") + '\')">&#128465; Löschen</button>' +
"</div>";
list.appendChild(item);
});
} catch (e) {
list.innerHTML = '<span style="color:var(--text2)">Fehler beim Laden</span>';
}
}
async function deleteExport(dirName, roomName) {
if (!confirm('Export "' + roomName + '" wirklich löschen?')) return;
try {
var resp = await fetch("/api/exports/" + encodeURIComponent(dirName), {method: "DELETE"});
if (!resp.ok) throw new Error("Löschen fehlgeschlagen");
loadExports();
} catch (e) {
alert("Fehler: " + e.message);
}
}
function escHtml(s) {
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function onEnter(ids, fn) {
ids.forEach(function(id) {
document.getElementById(id).addEventListener("keydown", function(e) {
if (e.key === "Enter") { e.preventDefault(); fn(); }
});
});
}
onEnter(["hs-url", "username", "password", "hs-url2", "access-token"], doConnect);
onEnter(["key-passphrase"], doImportKeys);
// Init
(async function() {
try {
var resp = await fetch("/api/session");
var data = await resp.json();
if (data.user_id) {
connected = true;
setStatus("connect-status", "success", "Session wiederhergestellt: " + data.user_id);
stepDone(1);
document.getElementById("btn-disconnect").classList.remove("hidden");
document.getElementById("card-keys").classList.remove("disabled");
if (data.homeserver) {
document.getElementById("hs-url").value = data.homeserver;
document.getElementById("hs-url2").value = data.homeserver;
}
}
} catch(e) {}
loadExports();
})();
</script>
</body>
</html>

26
docker-compose.yml Normal file
View File

@@ -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

7
requirements.txt Normal file
View File

@@ -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