Files
linkvault/app/templates/index.html
Erik Thiele 7262aee63d 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>
2026-07-20 23:25:27 +02:00

146 lines
6.2 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% import "_icons.html" as icons %}
{% block title %}Meine Links · LinkVault{% endblock %}
{% block body %}
<header class="topbar">
<div class="brand">Link<span>Vault</span></div>
<div class="user-info">
{% if not ai_enabled %}
<span title="Kein OPENAI_API_KEY gesetzt Auto-Kategorisierung deaktiviert" class="icon-warn">
{{ icons.alert(size=14) }} KI aus
</span>
{% endif %}
<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>
<div class="user-menu">
<button type="button" class="ghost icon-btn" title="{{ user.email }}">{{ icons.user() }}</button>
<button id="menu-toggle" type="button" class="ghost icon-btn" title="Menü"
aria-haspopup="true" aria-expanded="false">{{ icons.menu() }}</button>
<div id="user-dropdown" class="user-dropdown" hidden>
<div class="dropdown-email">{{ user.email }}</div>
<div class="dropdown-sep"></div>
<button type="button" class="dropdown-item" disabled>
{{ icons.tag(size=15) }} Tags verwalten <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>
</form>
</div>
</div>
</div>
</header>
<div class="layout">
<aside class="sidebar">
<div class="panel">
<a href="/?sort={{ sort }}" class="facet {% if not active_category and not active_manufacturer and not review_only %}active{% endif %}">
<span>{{ icons.grid(size=14) }} Alle Links</span>
</a>
<div class="facet-group">
<h3>Kategorien</h3>
{% for name, count in facets.categories %}
<a href="/?category={{ name | urlencode }}&amp;sort={{ sort }}"
class="facet {% if active_category == name and not review_only %}active{% endif %}">
<span>{{ name }}</span><span class="count">{{ count }}</span>
</a>
{% else %}
<div class="muted" style="font-size:.85rem; padding:4px 8px;">noch keine</div>
{% endfor %}
</div>
<div class="facet-group">
<h3>Hersteller / Quellen</h3>
{% for name, count in facets.manufacturers %}
<a href="/?manufacturer={{ name | urlencode }}&amp;sort={{ sort }}"
class="facet {% if active_manufacturer == name and not review_only %}active{% endif %}">
<span>{{ name }}</span><span class="count">{{ count }}</span>
</a>
{% else %}
<div class="muted" style="font-size:.85rem; padding:4px 8px;">noch keine</div>
{% endfor %}
</div>
<div class="facet-group">
<h3>Merkliste</h3>
<a href="/?review=1&amp;sort={{ sort }}" class="facet {% if review_only %}active{% endif %}">
<span>{{ icons.bookmark(size=14) }} Zur Durchsicht</span><span class="count">{{ facets.review_count }}</span>
</a>
</div>
</div>
</aside>
<main class="main">
<div class="panel">
<form hx-post="/links" hx-target="#links" hx-swap="afterbegin"
hx-disabled-elt="button"
hx-on::after-request="if(event.detail.successful){this.reset(); var e=document.getElementById('empty-state'); if(e) e.remove();}">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<input type="url" name="url" placeholder="Link einfügen, z.B. https://hersteller.de/produkt" required
style="flex:1 1 220px; width:auto;">
<button type="submit" style="white-space:nowrap;">
<span class="htmx-indicator icon-spin">{{ icons.spinner(size=14) }}</span>
{{ icons.plus(size=14) }} Hinzufügen
</button>
</div>
<div class="muted" style="font-size:.8rem; margin-top:6px;">
Der Link wird automatisch geladen, zusammengefasst und einsortiert.
</div>
</form>
</div>
<div class="panel">
<form id="searchform" onsubmit="return false;" style="margin:0;">
<input type="hidden" name="category" value="{{ active_category }}">
<input type="hidden" name="manufacturer" value="{{ active_manufacturer }}">
<input type="hidden" name="review" value="{{ '1' if review_only else '' }}">
<div class="input-icon">
{{ icons.search(size=15) }}
<input type="text" name="q" value="{{ q }}"
placeholder="Suche in Titel, Zusammenfassung, Tags..."
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
hx-trigger="keyup changed delay:400ms, search"
hx-include="#searchform">
</div>
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-top:10px; flex-wrap:wrap;">
{% if ai_enabled %}
<label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem;">
<input type="checkbox" name="semantic" value="1" {% if semantic %}checked{% endif %}
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
hx-trigger="change" hx-include="#searchform" style="width:auto;">
KI-Suche (findet auch sinnverwandte Treffer)
</label>
{% else %}
<span></span>
{% endif %}
<label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem;">
Sortierung:
<select name="sort"
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
hx-trigger="change" hx-include="#searchform">
{% for value, label in sort_options.items() %}
<option value="{{ value }}" {% if sort == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
</div>
</form>
</div>
<div id="links">
{% include "_links_list.html" %}
</div>
</main>
</div>
{% endblock %}