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

105
app/backup.py Normal file
View File

@@ -0,0 +1,105 @@
"""CSV-/SQL-Export und SQL-Import der eigenen Links (Backup & Restore)."""
import csv
import io
import sqlite3
from .models import Link
SQL_COLUMNS = [
"url", "title", "summary", "category", "manufacturer",
"tags", "status", "needs_review", "created_at",
]
def to_csv(links: list[Link]) -> str:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(
["url", "title", "summary", "category", "manufacturer", "tags", "status", "created_at"]
)
for link in links:
writer.writerow(
[
link.url,
link.title,
link.summary,
link.category,
link.manufacturer,
", ".join(link.tag_list),
link.status,
link.created_at.isoformat(),
]
)
return buf.getvalue()
def _sql_quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def to_sql_dump(links: list[Link]) -> str:
lines = [
"-- LinkVault-Export Links des angemeldeten Kontos.",
"-- Kann über 'Einstellungen -> SQL-Import' wieder eingespielt werden.",
f"-- Zeilen: {len(links)}",
"",
]
for link in links:
values = ", ".join(
[
_sql_quote(link.url),
_sql_quote(link.title),
_sql_quote(link.summary),
_sql_quote(link.category),
_sql_quote(link.manufacturer),
_sql_quote(link.tags),
_sql_quote(link.status),
"1" if link.needs_review else "0",
_sql_quote(link.created_at.isoformat()),
]
)
lines.append(f"INSERT INTO links ({', '.join(SQL_COLUMNS)}) VALUES ({values});")
return "\n".join(lines) + "\n"
def parse_sql_dump(sql_text: str, max_rows: int = 5000) -> list[dict]:
"""Liest die von to_sql_dump() erzeugten INSERT-Zeilen sicher ein.
Läuft bewusst gegen eine isolierte In-Memory-SQLite-Datenbank statt gegen
die echte App-Datenbank: nur Zeilen, die (nach Trimmen) mit
'insert into links' beginnen, werden ausgeführt, und sqlite3.execute()
lässt ohnehin nur je eine einzelne Anweisung zu. Dadurch können weder
andere Tabellen berührt noch mehrere Anweisungen aneinandergehängt werden.
Das Ergebnis wird anschließend vom Aufrufer kontrolliert per ORM in die
echte Datenbank übernommen (mit fest gesetzter user_id).
"""
conn = sqlite3.connect(":memory:")
try:
conn.execute(
"CREATE TABLE links ("
"url TEXT, title TEXT, summary TEXT, category TEXT, manufacturer TEXT, "
"tags TEXT, status TEXT, needs_review INTEGER, created_at TEXT)"
)
statements = 0
for raw_line in sql_text.splitlines():
line = raw_line.strip()
if not line or line.startswith("--"):
continue
if not line.lower().startswith("insert into links"):
continue
if statements >= max_rows:
raise ValueError(f"Zu viele Zeilen (Limit: {max_rows}).")
try:
conn.execute(line)
except sqlite3.Error as e:
raise ValueError(f"Ungültige Zeile: {e}") from e
statements += 1
if statements == 0:
raise ValueError("Keine gültigen INSERT-Zeilen für 'links' gefunden.")
cur = conn.execute(f"SELECT {', '.join(SQL_COLUMNS)} FROM links")
return [dict(zip(SQL_COLUMNS, row)) for row in cur.fetchall()]
finally:
conn.close()

View File

@@ -1,18 +1,18 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from urllib.parse import quote, urlparse
from fastapi import Depends, FastAPI, Form, Request
from fastapi import Depends, FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from . import ai, config, scraper, search
from . import ai, backup, config, scraper, search
from .database import Base, engine, get_db
from .models import Link, User
from .models import Link, User, utcnow
from .security import hash_password, verify_password
Base.metadata.create_all(bind=engine)
@@ -529,3 +529,126 @@ def reanalyze_link(
return templates.TemplateResponse(
"_link_card_edit.html", _edit_context(request, db, user, link)
)
# ---------------------------------------------------------------------------
# Einstellungen: Export / Import
# ---------------------------------------------------------------------------
MAX_IMPORT_BYTES = 2 * 1024 * 1024
@app.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request, db: Session = Depends(get_db)):
user = current_user(request, db)
if not user:
return RedirectResponse("/login", status_code=303)
link_count = db.scalar(
select(func.count()).select_from(Link).where(Link.user_id == user.id)
)
return templates.TemplateResponse(
"settings.html",
{
"request": request,
"user": user,
"link_count": link_count or 0,
"imported": request.query_params.get("imported"),
"import_error": request.query_params.get("import_error"),
},
)
@app.get("/settings/export.csv")
def export_csv(request: Request, db: Session = Depends(get_db)):
user = current_user(request, db)
if not user:
return RedirectResponse("/login", status_code=303)
links = list(
db.scalars(
select(Link).where(Link.user_id == user.id).order_by(Link.created_at)
).all()
)
return Response(
backup.to_csv(links),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=linkvault-links.csv"},
)
@app.get("/settings/export.sql")
def export_sql(request: Request, db: Session = Depends(get_db)):
user = current_user(request, db)
if not user:
return RedirectResponse("/login", status_code=303)
links = list(
db.scalars(
select(Link).where(Link.user_id == user.id).order_by(Link.created_at)
).all()
)
return Response(
backup.to_sql_dump(links),
media_type="application/sql; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=linkvault-links.sql"},
)
@app.post("/settings/import-sql")
async def import_sql(
request: Request,
file: UploadFile = File(...),
db: Session = Depends(get_db),
):
user = current_user(request, db)
if not user:
return RedirectResponse("/login", status_code=303)
raw = await file.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES:
return RedirectResponse(
f"/settings?import_error={quote('Datei ist größer als 2 MB.')}",
status_code=303,
)
try:
sql_text = raw.decode("utf-8")
except UnicodeDecodeError:
return RedirectResponse(
f"/settings?import_error={quote('Datei ist keine gültige UTF-8-Textdatei.')}",
status_code=303,
)
try:
rows = backup.parse_sql_dump(sql_text)
except ValueError as e:
return RedirectResponse(f"/settings?import_error={quote(str(e))}", status_code=303)
imported = 0
for row in rows:
url = (row.get("url") or "").strip()[:2000]
if not url:
continue
created_at = utcnow()
if row.get("created_at"):
try:
created_at = datetime.fromisoformat(row["created_at"])
except ValueError:
pass
try:
tags = json.dumps(json.loads(row.get("tags") or "[]"), ensure_ascii=False)
except (ValueError, TypeError):
tags = "[]"
db.add(
Link(
user_id=user.id,
url=url,
title=(row.get("title") or "").strip()[:300],
summary=(row.get("summary") or "").strip()[:1000],
category=(row.get("category") or "").strip()[:120],
manufacturer=(row.get("manufacturer") or "").strip()[:120],
tags=tags,
status=(row.get("status") or "ok").strip()[:20] or "ok",
needs_review=bool(row.get("needs_review")),
created_at=created_at,
)
)
imported += 1
db.commit()
return RedirectResponse(f"/settings?imported={imported}", status_code=303)

View File

@@ -74,6 +74,14 @@
{{ base('<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>', size) }}
{%- endmacro %}
{% macro download(size=16) -%}
{{ base('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>', size) }}
{%- endmacro %}
{% macro upload(size=16) -%}
{{ base('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>', size) }}
{%- endmacro %}
{% macro tag(size=16) -%}
{{ base('<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r="1.5"/>', size) }}
{%- endmacro %}

View File

@@ -59,6 +59,15 @@
background: var(--panel2); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 10px 12px; font-size: .95rem; width: 100%;
}
.file-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.file-label {
display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
white-space: nowrap;
}
.file-label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
input:focus, select:focus { outline: none; border-color: var(--accent); }
select {
background: var(--panel2); border: 1px solid var(--border); color: var(--text);

View File

@@ -27,9 +27,9 @@
<button type="button" class="dropdown-item" disabled>
{{ icons.tag(size=15) }} Tags verwalten <span class="soon">bald</span>
</button>
<button type="button" class="dropdown-item" disabled>
{{ icons.sliders(size=15) }} Einstellungen <span class="soon">bald</span>
</button>
<a href="/settings" class="dropdown-item" style="text-decoration:none;">
{{ icons.sliders(size=15) }} Einstellungen
</a>
<div class="dropdown-sep"></div>
<form method="post" action="/logout" style="margin:0;">
<button type="submit" class="dropdown-item">{{ icons.log_out(size=15) }} Abmelden</button>

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 %}