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:
105
app/backup.py
Normal file
105
app/backup.py
Normal 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()
|
||||
131
app/main.py
131
app/main.py
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
app/templates/settings.html
Normal file
84
app/templates/settings.html
Normal 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 }} · {{ 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 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 %}
|
||||
Reference in New Issue
Block a user