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:
@@ -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