Add settings page with CSV export, SQL dump export/import

New /settings page (linked from the header's burger menu) offers:
- CSV export of the user's links, for spreadsheet apps.
- SQL dump export as INSERT INTO links (...) statements, scoped to
  the current user only — never a raw full-database dump, since that
  would leak other accounts' password hashes and data. Embeddings are
  excluded (regenerated via "KI neu beschreiben lassen" if needed).
- SQL import that re-adds a previously exported dump to the current
  account (additive, doesn't touch existing links).

Import safety (app/backup.py): uploaded SQL is never executed against
the real database. Each non-comment line is required to start with
"insert into links" and is run one statement at a time against an
isolated in-memory SQLite database with only a whitelisted `links`
schema (no id/user_id columns) — sqlite3.execute() also rejects
multiple statements per call. Only after that succeeds are rows
copied into the real DB via the ORM, with user_id forced to the
logged-in user. Verified this rejects DROP TABLE, ATTACH DATABASE,
stacked statements, cross-table subqueries, and user_id injection.
Upload is capped at 2 MB.

Also adds download/upload icons and a proper file-input styling
pattern (visually-hidden input + <label> trigger + filename readout),
since the browser's ::file-selector-button pseudo-element didn't
render reliably in testing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik Thiele
2026-07-20 23:25:27 +02:00
parent 4f99d51f9e
commit 7262aee63d
6 changed files with 336 additions and 7 deletions

View File

@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% import "_icons.html" as icons %}
{% block title %}Einstellungen · LinkVault{% endblock %}
{% block body %}
<header class="topbar">
<a href="/" class="brand">Link<span>Vault</span></a>
<div class="user-info">
<button id="theme-toggle" type="button" class="ghost icon-btn" title="Farbschema wechseln">
<span class="theme-icon-dark">{{ icons.moon() }}</span>
<span class="theme-icon-light">{{ icons.sun() }}</span>
</button>
<a href="/" class="ghost icon-btn" title="Zurück zu den Links">{{ icons.grid() }}</a>
</div>
</header>
<div class="layout" style="max-width:720px; margin-left:auto; margin-right:auto;">
<main class="main">
<h1 style="margin-top:0;">Einstellungen</h1>
<p class="muted" style="margin-top:-8px;">
Angemeldet als {{ user.email }} &middot; {{ link_count }} Link{{ '' if link_count == 1 else 'e' }}
</p>
{% if imported is not none %}
<div class="panel" style="border-color:#16a34a;">
{{ icons.sparkles(size=15) }} {{ imported }} Link{{ '' if imported == '1' else 'e' }} erfolgreich importiert.
</div>
{% endif %}
{% if import_error %}
<div class="error">{{ import_error }}</div>
{% endif %}
<div class="panel">
<h2 style="margin-top:0; font-size:1.05rem;">CSV-Export</h2>
<p class="muted">
Alle deine Links als CSV-Tabelle (Titel, Zusammenfassung, Kategorie,
Hersteller, Tags, Datum) zum Öffnen in Excel/Numbers/Sheets.
</p>
<a href="/settings/export.csv"><button type="button">{{ icons.download(size=14) }} CSV herunterladen</button></a>
</div>
<div class="panel">
<h2 style="margin-top:0; font-size:1.05rem;">Datenbank-Export (SQL-Dump)</h2>
<p class="muted">
Alle deine Links als SQL-<code>INSERT</code>-Anweisungen als Backup
oder zum späteren Wiedereinspielen über den SQL-Import unten.
Enthält keine Embeddings (semantische Suche wird beim nächsten
„KI neu beschreiben lassen" pro Link neu erzeugt).
</p>
<a href="/settings/export.sql"><button type="button">{{ icons.download(size=14) }} SQL-Dump herunterladen</button></a>
</div>
<div class="panel">
<h2 style="margin-top:0; font-size:1.05rem;">SQL-Import</h2>
<p class="muted">
Eine mit obigem SQL-Export erzeugte Datei wieder einspielen. Die
Links werden deinem Konto <strong>hinzugefügt</strong> (keine
bestehenden Links werden überschrieben oder gelöscht). Nur Dateien,
die dem Export-Format dieser App entsprechen, werden akzeptiert
(max. 2&nbsp;MB).
</p>
<form method="post" action="/settings/import-sql" enctype="multipart/form-data"
style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
<input type="file" name="file" id="sql-import-file" accept=".sql,text/plain" required class="file-input">
<label for="sql-import-file" class="ghost file-label" style="padding:9px 16px; border-radius:8px; border:1px solid var(--border);">
{{ icons.upload(size=14) }} Datei wählen
</label>
<span id="sql-import-filename" class="muted" style="font-size:.85rem;">Keine Datei ausgewählt</span>
<button type="submit" style="margin-left:auto;">Importieren</button>
</form>
</div>
</main>
</div>
<script>
(function () {
var input = document.getElementById('sql-import-file');
var label = document.getElementById('sql-import-filename');
if (input && label) {
input.addEventListener('change', function () {
label.textContent = input.files && input.files[0] ? input.files[0].name : 'Keine Datei ausgewählt';
});
}
})();
</script>
{% endblock %}