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:
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)
|
||||
|
||||
Reference in New Issue
Block a user