317 lines
15 KiB
HTML
317 lines
15 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Einstellungen — diora{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="settings-container">
|
||
<h1 class="settings-title">Einstellungen</h1>
|
||
|
||
{% if lastfm_error %}
|
||
<div class="message message-error">{{ lastfm_error }}</div>
|
||
{% endif %}
|
||
|
||
<!-- Last.fm section -->
|
||
<section class="settings-section">
|
||
<h2>Last.fm</h2>
|
||
{% if has_lastfm %}
|
||
<div class="lastfm-connected">
|
||
<p class="connected-status">
|
||
Verbunden als <strong>{{ profile.lastfm_username }}</strong>
|
||
</p>
|
||
<form method="post" class="settings-form" id="scrobble-form">
|
||
{% csrf_token %}
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" name="lastfm_scrobble" id="lastfm_scrobble"
|
||
{% if profile.lastfm_scrobble %}checked{% endif %}
|
||
onchange="document.getElementById('scrobble-form').submit()">
|
||
Titel an Last.fm scrobbeln
|
||
</label>
|
||
</form>
|
||
<form method="post" action="{% url 'lastfm_disconnect' %}" class="inline-form" style="margin-top: 1rem;">
|
||
{% csrf_token %}
|
||
<button type="submit" class="btn btn-danger">Last.fm trennen</button>
|
||
</form>
|
||
</div>
|
||
{% else %}
|
||
<p class="lastfm-description">
|
||
Verbinde dein Last.fm-Konto, damit gehörte Titel automatisch gescrobbelt werden.
|
||
</p>
|
||
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Last.fm verbinden</a>
|
||
{% endif %}
|
||
</section>
|
||
|
||
<!-- Background section -->
|
||
<section class="settings-section">
|
||
<h2>Hintergrund</h2>
|
||
{% if request.user.profile.background_encrypted %}
|
||
<p class="lastfm-description">Ein verschlüsselter Hintergrund ist gesetzt. <span class="muted">(Keine Vorschau – er wird erst auf der Hauptseite im Browser entschlüsselt.)</span></p>
|
||
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
|
||
{% csrf_token %}
|
||
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
|
||
</form>
|
||
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen:</p>
|
||
{% elif request.user.profile.background_image_data %}
|
||
<p>
|
||
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Dein Hintergrund">
|
||
</p>
|
||
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
|
||
{% csrf_token %}
|
||
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
|
||
</form>
|
||
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen (wird verschlüsselt gespeichert):</p>
|
||
{% else %}
|
||
<p class="lastfm-description">Eigenes Hintergrundbild hochladen (JPG, PNG oder WebP, höchstens 5 MB). Es wird Ende-zu-Ende-verschlüsselt gespeichert.</p>
|
||
{% endif %}
|
||
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
|
||
<label class="btn" for="bg-upload-input">Datei auswählen</label>
|
||
<input type="file" id="bg-upload-input" accept=".jpg,.jpeg,.png,.webp" style="display:none;" onchange="uploadBackground(this)">
|
||
<span id="bg-upload-status" style="font-size:0.85rem; color:#888;"></span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Account section -->
|
||
<section class="settings-section">
|
||
<h2>Konto</h2>
|
||
<p>Angemeldet als <strong>{{ request.user.username }}</strong></p>
|
||
|
||
<details {% if password_form_open %}open{% endif %} style="margin-top:1rem;">
|
||
<summary class="btn" style="display:inline-block;cursor:pointer;">Passwort ändern</summary>
|
||
<form id="pw-change-form" method="post" action="{% url 'change_password' %}" style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem; max-width:320px;">
|
||
{% csrf_token %}
|
||
{% for field in password_form %}
|
||
<div>
|
||
<label style="display:block; font-size:0.85rem; margin-bottom:2px;">{{ field.label }}</label>
|
||
{{ field }}
|
||
{% if field.errors %}
|
||
<div class="message message-error" style="margin-top:4px; padding:4px 8px; font-size:0.8rem;">{{ field.errors|join:", " }}</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
{% if password_form.non_field_errors %}
|
||
<div class="message message-error" style="padding:4px 8px; font-size:0.8rem;">{{ password_form.non_field_errors|join:", " }}</div>
|
||
{% endif %}
|
||
<div id="pw-change-status" style="font-size:0.85rem; color:#888;"></div>
|
||
<div><button type="submit" class="btn">Speichern</button></div>
|
||
</form>
|
||
</details>
|
||
|
||
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
|
||
{% csrf_token %}
|
||
<button type="submit" class="btn">Abmelden</button>
|
||
</form>
|
||
</section>
|
||
|
||
<!-- API token section -->
|
||
<section class="settings-section">
|
||
<h2>API-Zugriff für lokale Apps</h2>
|
||
<p class="lastfm-description">
|
||
Mit einem persönlichen Token können eigene Apps auf deine diora-Daten zugreifen (Bücher,
|
||
Lesefortschritt, Notizen, Podcasts, Radio) — ohne dein Passwort zu teilen. Token als
|
||
<code>Authorization: Bearer <token></code>-Header mitschicken, z. B. gegen
|
||
<code>GET /api/sync/</code> für einen kompletten Datenabzug.
|
||
</p>
|
||
{% if api_token %}
|
||
<p style="margin-top:0.75rem;">
|
||
<code style="user-select:all; word-break:break-all; background:#1a1a1a; padding:4px 8px; border-radius:4px; display:inline-block;">{{ api_token.token }}</code>
|
||
</p>
|
||
<form method="post" action="{% url 'regenerate_api_token' %}" class="inline-form" style="margin-top:0.75rem;"
|
||
onsubmit="return confirm('Neues Token generieren? Bereits verbundene Apps können sich dann nicht mehr anmelden.');">
|
||
{% csrf_token %}
|
||
<button type="submit" class="btn btn-danger">Token neu generieren</button>
|
||
</form>
|
||
{% else %}
|
||
<form method="post" action="{% url 'regenerate_api_token' %}" class="inline-form" style="margin-top:0.75rem;">
|
||
{% csrf_token %}
|
||
<button type="submit" class="btn">Token generieren</button>
|
||
</form>
|
||
{% endif %}
|
||
</section>
|
||
|
||
<!-- Cloud connections (WebDAV / Nextcloud) for the ebook import -->
|
||
<section class="settings-section">
|
||
<h2>Cloud-Verbindungen für Bücher</h2>
|
||
<p class="lastfm-description">
|
||
Verbinde einen WebDAV-Server — Nextcloud, ownCloud, Synology oder was auch immer WebDAV
|
||
spricht — und importiere <code>.epub</code>- und <code>.pdf</code>-Dateien direkt daraus in
|
||
deine Bibliothek. Die Datei läuft dabei nur durch den Server hindurch; verschlüsselt wird sie
|
||
wie immer erst in deinem Browser, gespeichert wird ausschließlich der Geheimtext.
|
||
</p>
|
||
<p class="lastfm-description">
|
||
Lege dafür bitte ein <strong>App-Passwort</strong> an (bei Nextcloud unter
|
||
Einstellungen → Sicherheit) statt dein Konto-Passwort einzutragen — es wird serverseitig
|
||
gespeichert und lässt sich jederzeit einzeln widerrufen.
|
||
</p>
|
||
|
||
{% if webdav_sources %}
|
||
<ul class="webdav-list">
|
||
{% for source in webdav_sources %}
|
||
<li class="webdav-item">
|
||
<div class="webdav-item-info">
|
||
<strong>{{ source.label }}</strong>{% if source.inbox %} <span class="muted">· Eingang</span>{% endif %}
|
||
<span class="muted">{{ source.base_url }}{% if source.root_path %} · /{{ source.root_path }}{% endif %}</span>
|
||
</div>
|
||
<div class="webdav-item-actions">
|
||
<button type="button" class="btn" aria-expanded="false"
|
||
onclick="const f = document.getElementById('webdav-edit-{{ source.pk }}'); f.hidden = !f.hidden; this.setAttribute('aria-expanded', String(!f.hidden));">Bearbeiten</button>
|
||
<form method="post" action="{% url 'webdav_test' source.pk %}" class="inline-form">
|
||
{% csrf_token %}
|
||
<button type="submit" class="btn">Testen</button>
|
||
</form>
|
||
<form method="post" action="{% url 'webdav_delete' source.pk %}" class="inline-form"
|
||
onsubmit="return confirm('Verbindung „{{ source.label|escapejs }}“ entfernen?');">
|
||
{% csrf_token %}
|
||
<button type="submit" class="btn btn-danger">Entfernen</button>
|
||
</form>
|
||
</div>
|
||
<form method="post" action="{% url 'webdav_edit' source.pk %}" class="webdav-form webdav-edit"
|
||
id="webdav-edit-{{ source.pk }}" hidden>
|
||
{% csrf_token %}
|
||
<label class="webdav-field">
|
||
<span>Name</span>
|
||
<input type="text" name="label" required value="{{ source.label }}">
|
||
</label>
|
||
<label class="webdav-field">
|
||
<span>Server-URL</span>
|
||
<input type="url" name="base_url" required value="{{ source.base_url }}">
|
||
</label>
|
||
<label class="webdav-field">
|
||
<span>Benutzername</span>
|
||
<input type="text" name="username" autocomplete="off" value="{{ source.username }}">
|
||
</label>
|
||
<label class="webdav-field">
|
||
<span>App-Passwort <span class="muted">(leer lassen = unverändert)</span></span>
|
||
<input type="password" name="password" autocomplete="new-password">
|
||
</label>
|
||
<label class="webdav-field">
|
||
<span>Unterordner <span class="muted">(optional, Import startet dort)</span></span>
|
||
<input type="text" name="root_path" value="{{ source.root_path }}" placeholder="Buecher">
|
||
</label>
|
||
<label class="webdav-field webdav-check">
|
||
<input type="checkbox" name="inbox" value="1"{% if source.inbox %} checked{% endif %}>
|
||
<span>Eingang: neue Bücher hier kommen von selbst in die Bibliothek
|
||
<span class="muted">(beim Öffnen der Bücher; der Ordner bleibt unverändert)</span></span>
|
||
</label>
|
||
<button type="submit" class="btn">Speichern</button>
|
||
</form>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% endif %}
|
||
|
||
<form method="post" action="{% url 'webdav_add' %}" class="webdav-form">
|
||
{% csrf_token %}
|
||
<label class="webdav-field">
|
||
<span>Name</span>
|
||
<input type="text" name="label" required placeholder="Meine Nextcloud">
|
||
</label>
|
||
<label class="webdav-field">
|
||
<span>Server-URL</span>
|
||
<input type="url" name="base_url" required placeholder="https://cloud.example.com">
|
||
</label>
|
||
<label class="webdav-field">
|
||
<span>Benutzername</span>
|
||
<input type="text" name="username" autocomplete="off" placeholder="dein-login">
|
||
</label>
|
||
<label class="webdav-field">
|
||
<span>App-Passwort</span>
|
||
<input type="password" name="password" autocomplete="new-password">
|
||
</label>
|
||
<label class="webdav-field">
|
||
<span>Unterordner <span class="muted">(optional)</span></span>
|
||
<input type="text" name="root_path" placeholder="Buecher">
|
||
</label>
|
||
<label class="webdav-field webdav-check">
|
||
<input type="checkbox" name="inbox" value="1">
|
||
<span>Eingang: neue Bücher hier kommen von selbst in die Bibliothek</span>
|
||
</label>
|
||
<p class="lastfm-description" style="margin:0;">
|
||
Bei Nextcloud/ownCloud genügt die Server-Adresse — der WebDAV-Pfad wird aus dem
|
||
Benutzernamen ergänzt. Andere Server brauchen die vollständige WebDAV-URL.
|
||
</p>
|
||
<button type="submit" class="btn">Verbindung hinzufügen</button>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<style>
|
||
.bg-preview { max-width: 240px; max-height: 135px; object-fit: cover; border-radius: 4px; border: 1px solid #333; }
|
||
</style>
|
||
<script>
|
||
function getCsrfToken() {
|
||
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
|
||
}
|
||
|
||
// Minimal crypto helpers for settings page (duplicated from app.js — settings page doesn't load app.js)
|
||
function _bytesToBase64(buf) {
|
||
const bytes = new Uint8Array(buf);
|
||
let str = '';
|
||
for (const b of bytes) str += String.fromCharCode(b);
|
||
return btoa(str);
|
||
}
|
||
function _bytesToHex(buf) {
|
||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
function _base64ToBytes(b64) {
|
||
const str = atob(b64);
|
||
const buf = new Uint8Array(str.length);
|
||
for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
|
||
return buf;
|
||
}
|
||
async function _getOrCreateEncKey() {
|
||
const userId = {{ request.user.id }};
|
||
const storageKey = `diora_enc_key_${userId}`;
|
||
const stored = localStorage.getItem(storageKey);
|
||
if (stored) {
|
||
try {
|
||
const raw = _base64ToBytes(stored);
|
||
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
||
} catch (e) {}
|
||
}
|
||
const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||
const raw = await crypto.subtle.exportKey('raw', key);
|
||
localStorage.setItem(storageKey, _bytesToBase64(raw));
|
||
return key;
|
||
}
|
||
|
||
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
|
||
const ok = confirm('Nach dem Ändern des Passworts können alle hochgeladenen Bücher nicht mehr geöffnet werden und müssen erneut hochgeladen werden. Fortfahren?');
|
||
if (!ok) e.preventDefault();
|
||
});
|
||
|
||
async function uploadBackground(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
const status = document.getElementById('bg-upload-status');
|
||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||
if (!allowedTypes.includes(file.type)) {
|
||
status.textContent = 'Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.';
|
||
return;
|
||
}
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
status.textContent = 'Das Bild darf höchstens 5 MB groß sein.';
|
||
return;
|
||
}
|
||
status.textContent = 'Verschlüssele …';
|
||
try {
|
||
const key = await _getOrCreateEncKey();
|
||
const buf = await file.arrayBuffer();
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, buf);
|
||
status.textContent = 'Lade hoch …';
|
||
const res = await fetch('/accounts/background/upload/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||
body: JSON.stringify({iv: _bytesToHex(iv), ciphertext: _bytesToBase64(ct), mime_type: file.type, file_size: file.size}),
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok) { location.reload(); }
|
||
else { status.textContent = data.error === 'only jpeg, png, or webp allowed' ? 'Nur JPEG, PNG oder WebP erlaubt.'
|
||
: data.error === 'file too large (max 5 MB)' ? 'Datei zu groß (höchstens 5 MB).' : (data.error || 'Hochladen fehlgeschlagen'); }
|
||
} catch (e) {
|
||
status.textContent = 'Hochladen fehlgeschlagen: ' + e.message;
|
||
}
|
||
input.value = '';
|
||
}
|
||
</script>
|
||
{% endblock %}
|