Add application, Dockerfile, CI workflow and documentation
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 5s
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:
38
.gitea/workflows/docker-publish.yml
Normal file
38
.gitea/workflows/docker-publish.yml
Normal 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
18
Dockerfile
Normal 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
37
LICENSE
@@ -1,26 +1,25 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
MIT License
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
---
|
Copyright (c) Patrick Asmus (scriptos)
|
||||||
|
|
||||||
Copyright ©
|
|
||||||
|
|
||||||
Name: Patrick Asmus (scriptos)
|
|
||||||
Email: support@techniverse.net
|
|
||||||
Website: https://www.patrick-asmus.de
|
Website: https://www.patrick-asmus.de
|
||||||
Blog: https://www.cleveradmin.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
|
The above copyright notice and this permission notice shall be included in all
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
copies or substantial portions of the Software.
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
GNU Affero General Public License for more details.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
You should have received a copy of the GNU Affero General Public License
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
SOFTWARE.
|
||||||
|
|||||||
58
README.md
58
README.md
@@ -4,10 +4,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Name des Projekts</h1>
|
<h1 align="center">Matrix Chat Export</h1>
|
||||||
|
|
||||||
<h4 align="center">
|
<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>
|
</h4>
|
||||||
|
|
||||||
<h6 align="center">
|
<h6 align="center">
|
||||||
@@ -22,7 +22,59 @@
|
|||||||
<br><br>
|
<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>
|
<br><br>
|
||||||
|
|||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
837
app/exporter.py
Normal file
837
app/exporter.py
Normal 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("&", "&")
|
||||||
|
.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
|
||||||
242
app/main.py
Normal file
242
app/main.py
Normal 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")
|
||||||
266
app/templates/chat_export.html
Normal file
266
app/templates/chat_export.html
Normal 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">💬</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">↑</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">📎</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">📍 <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">📍 ' + 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">🔒 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
654
app/templates/index.html
Normal 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>▩</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 → Sicherheit → "E2E-Raumschlüssel exportieren".
|
||||||
|
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>📦 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">🔒 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 · " + 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">📦 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, "\\'") + '\')">🗑 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
26
docker-compose.yml
Normal 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
7
requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user