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>
146 lines
6.2 KiB
HTML
146 lines
6.2 KiB
HTML
{% 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 }}&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 }}&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&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 %}
|