diff --git a/.gitignore b/.gitignore index e87497d..af1f073 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -# Python -backend/.venv/ +# Python (auch Neben-Umgebungen wie .venv-test) +backend/.venv*/ **/__pycache__/ *.pyc diff --git a/backend/app/routers/backup.py b/backend/app/routers/backup.py index d9b923d..5a374a3 100644 --- a/backend/app/routers/backup.py +++ b/backend/app/routers/backup.py @@ -4,6 +4,7 @@ das wiederherstellbare Voll-Backup als JSON (siehe ``services/backup_data``).""" import csv import io import os +import re import sqlite3 import tempfile from datetime import datetime, timezone @@ -38,7 +39,27 @@ MAX_BACKUP_BYTES = 64 * 1024 * 1024 def _ts() -> str: - return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + """Ortszeit als ``YYYY-MM-DD_HHMM`` – Dateinamen sollen lesbar sein. + + Bewusst nicht UTC: wer abends um 20 Uhr exportiert, soll im Downloads-Ordner + keine Datei von 18 Uhr finden.""" + return datetime.now().astimezone().strftime("%Y-%m-%d_%H%M") + + +def _dateiname(art: str, endung: str) -> str: + """Einheitlicher, sprechender Dateiname für alle Downloads. + + ``art`` muss erkennen lassen, worum es geht – im Downloads-Ordner liegen die + Dateien sonst nebeneinander und man sieht ihnen nicht an, welche sich + zurückladen lässt (``vollbackup``) und welche nur zum Ansehen taugt + (``datenuebersicht``).""" + return f"certain-finance_{art}_{_ts()}.{endung}" + + +def _namensteil(text: str) -> str: + """Beliebigen Text auf etwas eindampfen, das in einem Dateinamen taugt.""" + sauber = re.sub(r"[^A-Za-z0-9]+", "-", text).strip("-").lower() + return sauber[:32] or "unbekannt" def _sqlite_path() -> str | None: @@ -94,7 +115,7 @@ def backup_db(_: User = Depends(require_admin)): return FileResponse( tmp.name, media_type="application/octet-stream", - filename=f"certain-finance-{_ts()}.db", + filename=_dateiname("datenbank", "db"), background=BackgroundTask(os.remove, tmp.name), ) @@ -136,7 +157,7 @@ def export_transactions_csv(db: Session = Depends(get_db), user: User = Depends( return Response( content=buf.getvalue().encode("utf-8-sig"), media_type="text/csv", - headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.csv"'}, + headers={"Content-Disposition": f'attachment; filename="{_dateiname("buchungen", "csv")}"'}, ) @@ -165,7 +186,7 @@ def export_transactions_json(db: Session = Depends(get_db), user: User = Depends return Response( content=_json_bytes(data), media_type="application/json", - headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.json"'}, + headers={"Content-Disposition": f'attachment; filename="{_dateiname("buchungen", "json")}"'}, ) @@ -229,32 +250,42 @@ def export_all_json(db: Session = Depends(get_db), user: User = Depends(get_curr return Response( content=_json_bytes(data), media_type="application/json", - headers={"Content-Disposition": f'attachment; filename="certain-finance-export-{_ts()}.json"'}, + # „datenuebersicht", nicht „backup": diese Datei lässt sich bewusst + # nicht zurückladen (siehe services/backup_data.parse). + headers={ + "Content-Disposition": f'attachment; filename="{_dateiname("datenuebersicht", "json")}"' + }, ) # --- Voll-Backup (wiederherstellbar) --------------------------------------- -def _download(obj: dict, name: str) -> Response: +def _download(obj: dict, dateiname: str) -> Response: return Response( content=backup_data.to_bytes(obj), media_type="application/json", - headers={"Content-Disposition": f'attachment; filename="{name}-{_ts()}.json"'}, + headers={"Content-Disposition": f'attachment; filename="{dateiname}"'}, ) @router.get("/export/backup.json") def export_backup(db: Session = Depends(get_db), user: User = Depends(get_current_user)): - """Alle eigenen Daten – vollständig und wieder einspielbar.""" - return _download(backup_data.export_user(db, user), "certain-finance-backup") + """Alle eigenen Daten – vollständig und wieder einspielbar. + + Der Benutzername steht im Dateinamen: bei mehreren Konten liegen sonst + gleichnamige Backups im selben Ordner.""" + return _download( + backup_data.export_user(db, user), + _dateiname(f"vollbackup_{_namensteil(user.username)}", "json"), + ) @router.get("/export/system-backup.json") def export_system_backup(db: Session = Depends(get_db), _: User = Depends(require_admin)): """Die ganze Instanz inklusive Benutzerkonten. Enthält Passwort-Hashes und TOTP-Secrets – die Datei ist wie ein Passwort zu behandeln.""" - return _download(backup_data.export_system(db), "certain-finance-system-backup") + return _download(backup_data.export_system(db), _dateiname("vollbackup-system", "json")) async def _read_upload(file: UploadFile) -> bytes: diff --git a/docs/BACKUP.md b/docs/BACKUP.md index 54e0237..4c5c690 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -8,7 +8,7 @@ sich in der App wieder einspielen lässt (siehe unten): Konten, Kategorien, Buchungen mit allen Feldern, Budget-Vorlage und Monatswerte, Schlagwortregeln, Import-Vorlagen, Dashboard-Layout und die persönlichen Einstellungen. -- **Buchungen (CSV/JSON)** und **Alle Daten (JSON)** — Ansichts-Exporte zum +- **Buchungen (CSV/JSON)** und **Datenübersicht (JSON)** — Ansichts-Exporte zum Archivieren und Weiterverarbeiten, etwa in einer Tabellenkalkulation. Referenzen laufen dort über Namen und mehrere Tabellen fehlen: zum Wiederherstellen taugen sie **nicht**. Die App weist eine solche Datei beim @@ -21,6 +21,23 @@ **Passwort-Hashes und Zwei-Faktor-Geheimnisse** und ist wie ein Passwort zu behandeln. +### Dateinamen + +Alle Downloads heissen `certain-finance___.`, wobei +`` sagt, worum es geht — das ist der Unterschied, den man im +Downloads-Ordner sehen muss: + +| Datei | Zurückladbar | +|---|---| +| `certain-finance_vollbackup__2026-08-20_1432.json` | ja | +| `certain-finance_vollbackup-system_2026-08-20_1432.json` | ja, nur Admin | +| `certain-finance_datenuebersicht_2026-08-20_1432.json` | nein | +| `certain-finance_buchungen_2026-08-20_1432.csv` / `.json` | nein | +| `certain-finance_datenbank_2026-08-20_1432.db` | ja, aber von Hand (siehe unten) | + +Den Namen vergibt der Server im `Content-Disposition`-Kopf; über die Oberfläche +und über einen direkten API-Aufruf kommt dieselbe Datei also gleich benannt an. + ## Wiederherstellen (JSON, in der App) **Einstellungen → Backup zurückladen.** Die Datei wird zuerst nur geprüft; die diff --git a/web/src/api.js b/web/src/api.js index e9e287b..3e965e4 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -85,8 +85,21 @@ function zeitstempel() { ); } -/** Datei (mit Sitzungs-Cookie) laden und im Browser als Download anbieten. */ -export async function downloadFile(path, filename) { +/** Dateiname aus dem Content-Disposition-Kopf, sonst der Rückfallwert. */ +function dateinameAusKopf(resp, rueckfall) { + const kopf = resp.headers.get("content-disposition") || ""; + const treffer = /filename="([^"]+)"/.exec(kopf); + return treffer ? treffer[1] : rueckfall; +} + +/** + * Datei (mit Sitzungs-Cookie) laden und im Browser als Download anbieten. + * + * Den Namen gibt der Server vor – sonst hiesse dieselbe Datei je nachdem, ob + * man sie über die Oberfläche oder direkt über die API holt, anders. `rueckfall` + * greift nur, wenn der Kopf fehlt. + */ +export async function downloadFile(path, rueckfall) { const resp = await fetch(`${API_BASE}${path}`, { credentials: "include" }); if (!resp.ok) { throw new ApiError(`Download fehlgeschlagen (${resp.status})`, resp.status); @@ -95,7 +108,7 @@ export async function downloadFile(path, filename) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = filename; + link.download = dateinameAusKopf(resp, rueckfall); document.body.appendChild(link); link.click(); link.remove(); @@ -232,14 +245,20 @@ export const api = { documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }), // Backup & Export - downloadDbBackup: () => downloadFile("/backup/db", `certain-finance-${zeitstempel()}.db`), - exportTransactionsCsv: () => downloadFile("/export/transactions.csv", `transaktionen_${zeitstempel()}.csv`), - exportTransactionsJson: () => downloadFile("/export/transactions.json", `transaktionen_${zeitstempel()}.json`), - exportAllJson: () => downloadFile("/export/all.json", `certain-finance-export_${zeitstempel()}.json`), + // Die Dateinamen kommen aus dem Content-Disposition-Kopf (backend/app/routers/ + // backup.py, _dateiname). Was hier steht, ist nur der Rückfall. + downloadDbBackup: () => + downloadFile("/backup/db", `certain-finance_datenbank_${zeitstempel()}.db`), + exportTransactionsCsv: () => + downloadFile("/export/transactions.csv", `certain-finance_buchungen_${zeitstempel()}.csv`), + exportTransactionsJson: () => + downloadFile("/export/transactions.json", `certain-finance_buchungen_${zeitstempel()}.json`), + exportAllJson: () => + downloadFile("/export/all.json", `certain-finance_datenuebersicht_${zeitstempel()}.json`), exportBackupJson: () => - downloadFile("/export/backup.json", `certain-finance-backup_${zeitstempel()}.json`), + downloadFile("/export/backup.json", `certain-finance_vollbackup_${zeitstempel()}.json`), exportSystemBackupJson: () => - downloadFile("/export/system-backup.json", `certain-finance-system-backup_${zeitstempel()}.json`), + downloadFile("/export/system-backup.json", `certain-finance_vollbackup-system_${zeitstempel()}.json`), restorePreview: (file) => { const fd = new FormData(); fd.append("file", file); diff --git a/web/src/locales/de.json b/web/src/locales/de.json index c7a0cf9..60e4f46 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -80,10 +80,10 @@ "languageDe": "Deutsch", "languageEn": "Englisch", "backup": "Backup & Export", - "exportHint": "Exportiere deine eigenen Daten zum Archivieren oder Weiterverarbeiten.", + "exportHint": "Zum Ansehen und Weiterverarbeiten, etwa in einer Tabellenkalkulation. Diese Dateien lassen sich nicht zurückladen.", "exportTxnCsv": "Buchungen (CSV)", "exportTxnJson": "Buchungen (JSON)", - "exportAll": "Alle Daten (JSON)", + "exportAll": "Datenübersicht (JSON)", "dbBackup": "Komplettes DB-Backup herunterladen", "dbBackupHint": "Vollständige Datenbank aller Nutzer (nur Admin) – zum Wiederherstellen sichern.", "keywordReview": "Schlagwörter prüfen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 27fbcab..52b264a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -80,10 +80,10 @@ "languageDe": "German", "languageEn": "English", "backup": "Backup & export", - "exportHint": "Export your own data for archiving or further processing.", + "exportHint": "For reading and further processing, e.g. in a spreadsheet. These files cannot be restored.", "exportTxnCsv": "Transactions (CSV)", "exportTxnJson": "Transactions (JSON)", - "exportAll": "All data (JSON)", + "exportAll": "Data overview (JSON)", "dbBackup": "Download full DB backup", "dbBackupHint": "Complete database of all users (admin only) — keep it for restore.", "keywordReview": "Review keywords", diff --git a/web/src/pages/TwoFactor.jsx b/web/src/pages/TwoFactor.jsx index 2cc78f7..02aac2b 100644 --- a/web/src/pages/TwoFactor.jsx +++ b/web/src/pages/TwoFactor.jsx @@ -17,7 +17,7 @@ function codesSichern(codes, benutzer) { const url = URL.createObjectURL(new Blob([inhalt], { type: "text/plain;charset=utf-8" })); const link = document.createElement("a"); link.href = url; - link.download = `certain-finance-wiederherstellungscodes_${benutzer}.txt`; + link.download = `certain-finance_wiederherstellungscodes_${benutzer}.txt`; document.body.appendChild(link); link.click(); link.remove();