The old key comparison was wrong — the localStorage key may have been randomly generated rather than PBKDF2-derived. Fix: - Add /accounts/check-password/ to validate the old password server-side before touching any keys - Use the localStorage key directly as the old decryption key (it is always the correct source of truth, regardless of how it was generated) - Derive the new key from the new password via PBKDF2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
277 lines
11 KiB
HTML
277 lines
11 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Settings — diora{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="settings-container">
|
|
<h1 class="settings-title">Settings</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">
|
|
Connected as <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()">
|
|
Scrobble tracks to Last.fm
|
|
</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">Disconnect Last.fm</button>
|
|
</form>
|
|
</div>
|
|
{% else %}
|
|
<p class="lastfm-description">
|
|
Connect your Last.fm account to automatically scrobble the tracks you listen to.
|
|
</p>
|
|
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Connect Last.fm</a>
|
|
{% endif %}
|
|
</section>
|
|
|
|
<!-- Background section -->
|
|
<section class="settings-section">
|
|
<h2>Background</h2>
|
|
{% if request.user.profile.background_encrypted %}
|
|
<p class="lastfm-description">Encrypted background is set. <span class="muted">(Preview not available — decrypted in browser on main page.)</span></p>
|
|
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn btn-danger">Remove background</button>
|
|
</form>
|
|
<p style="margin-top:12px;">Upload a new image to replace it:</p>
|
|
{% elif request.user.profile.background_image_data %}
|
|
<p>
|
|
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Your background">
|
|
</p>
|
|
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn btn-danger">Remove background</button>
|
|
</form>
|
|
<p style="margin-top:12px;">Upload a new image to replace it (will be stored encrypted):</p>
|
|
{% else %}
|
|
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB). Stored end-to-end encrypted.</p>
|
|
{% endif %}
|
|
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
|
|
<label class="btn" for="bg-upload-input">Choose file</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>Account</h2>
|
|
<p>Logged in as <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;">Change password</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">Save</button></div>
|
|
</form>
|
|
</details>
|
|
|
|
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
|
|
{% csrf_token %}
|
|
<button type="submit" class="btn">Logout</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;
|
|
}
|
|
|
|
function _hexToBytes(hex) {
|
|
const buf = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < buf.length; i++) buf[i] = parseInt(hex.slice(i*2, i*2+2), 16);
|
|
return buf;
|
|
}
|
|
|
|
async function _pbkdf2Key(password, username) {
|
|
const enc = new TextEncoder();
|
|
const mat = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
|
|
return crypto.subtle.deriveKey(
|
|
{name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'},
|
|
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
async function _reEncrypt(oldKey, newKey, iv, ct) {
|
|
const plain = await crypto.subtle.decrypt({name: 'AES-GCM', iv: _hexToBytes(iv)}, oldKey, _base64ToBytes(ct));
|
|
const newIv = crypto.getRandomValues(new Uint8Array(12));
|
|
const newCt = await crypto.subtle.encrypt({name: 'AES-GCM', iv: newIv}, newKey, plain);
|
|
return {iv: _bytesToHex(newIv), ct: _bytesToBase64(newCt)};
|
|
}
|
|
|
|
document.getElementById('pw-change-form')?.addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
const form = this;
|
|
const statusEl = document.getElementById('pw-change-status');
|
|
const oldPw = form.old_password.value;
|
|
const newPw = form.new_password1.value;
|
|
const userId = {{ request.user.id }};
|
|
const username = '{{ request.user.username }}';
|
|
const storageKey = `diora_enc_key_${userId}`;
|
|
|
|
statusEl.textContent = 'Passwort prüfen…';
|
|
try {
|
|
// Validate old password server-side before touching any keys
|
|
const checkResp = await fetch('/accounts/check-password/', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
body: JSON.stringify({password: oldPw}),
|
|
}).then(r => r.json());
|
|
if (!checkResp.ok) {
|
|
statusEl.textContent = 'Fehler: Altes Passwort ist falsch.';
|
|
return;
|
|
}
|
|
|
|
// Use the key currently in localStorage as the old key (source of truth for decryption)
|
|
const stored = localStorage.getItem(storageKey);
|
|
let oldKey;
|
|
if (stored) {
|
|
oldKey = await crypto.subtle.importKey('raw', _base64ToBytes(stored), {name: 'AES-GCM'}, false, ['decrypt']);
|
|
}
|
|
|
|
const newKey = await _pbkdf2Key(newPw, username);
|
|
|
|
// Re-encrypt all books (only if we have a key to decrypt with)
|
|
statusEl.textContent = 'Bücherliste laden…';
|
|
const books = oldKey ? await fetch('/books/').then(r => r.json()) : [];
|
|
|
|
for (let i = 0; i < books.length; i++) {
|
|
const book = books[i];
|
|
statusEl.textContent = `Buch ${i + 1} / ${books.length} neu verschlüsseln…`;
|
|
|
|
const newMeta = await _reEncrypt(oldKey, newKey, book.meta_iv, book.meta_ct);
|
|
const data = await fetch(`/books/${book.id}/data/`).then(r => r.json());
|
|
const newData = await _reEncrypt(oldKey, newKey, data.data_iv, data.data_ct);
|
|
|
|
await fetch(`/books/${book.id}/rekey/`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
body: JSON.stringify({meta_ct: newMeta.ct, meta_iv: newMeta.iv, data_ct: newData.ct, data_iv: newData.iv}),
|
|
});
|
|
|
|
// Re-encrypt highlights and bookmarks if they exist
|
|
for (const endpoint of ['highlights', 'bookmarks']) {
|
|
const resp = await fetch(`/books/${book.id}/${endpoint}/`).then(r => r.json());
|
|
if (resp.ct) {
|
|
const reenc = await _reEncrypt(oldKey, newKey, resp.iv, resp.ct);
|
|
await fetch(`/books/${book.id}/${endpoint}/`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
body: JSON.stringify({ct: reenc.ct, iv: reenc.iv}),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update localStorage key
|
|
const newRaw = new Uint8Array(await crypto.subtle.exportKey('raw', newKey));
|
|
localStorage.setItem(storageKey, _bytesToBase64(newRaw));
|
|
|
|
statusEl.textContent = 'Passwort ändern…';
|
|
form.submit();
|
|
} catch (err) {
|
|
statusEl.textContent = 'Fehler: ' + err.message;
|
|
}
|
|
});
|
|
|
|
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 = 'Only JPEG, PNG, or WebP images are allowed.';
|
|
return;
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
status.textContent = 'Image must be 5 MB or smaller.';
|
|
return;
|
|
}
|
|
status.textContent = 'Encrypting…';
|
|
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 = 'Uploading…';
|
|
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 || 'Upload failed'; }
|
|
} catch (e) {
|
|
status.textContent = 'Upload failed: ' + e.message;
|
|
}
|
|
input.value = '';
|
|
}
|
|
</script>
|
|
{% endblock %}
|