Books: broken-book repair flow + simplify password change
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 16s

When a book fails to open, a '!' button appears next to it in the list.
Clicking it prompts for the original file; the file is re-encrypted with
the current key and replaces the broken ciphertext on the server while
keeping all metadata, progress, highlights and bookmarks intact.

- Add POST /books/<pk>/replace-data/ endpoint (updates data only)
- Add _evictCachedBook() to clear IndexedDB cache for a single book
- Replace complex client-side re-encryption on password change with a
  simple confirm() warning that books will need to be re-uploaded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-05-25 17:03:59 +02:00
parent 817323ad19
commit 4b47f6e67a
4 changed files with 97 additions and 97 deletions

View file

@ -6,6 +6,7 @@ urlpatterns = [
path('upload/', views.upload_book, name='upload_book'),
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'),
path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'),
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),

View file

@ -94,6 +94,35 @@ def get_book_data(request, pk):
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
@csrf_exempt
@require_http_methods(['POST'])
def replace_book_data(request, pk):
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
data_ct = body.get('data_ct', '')
data_iv = body.get('data_iv', '')
if not all([data_ct, data_iv]):
return JsonResponse({'error': 'data_ct and data_iv required'}, status=400)
book.data_ct = data_ct
book.data_iv = data_iv
book.save(update_fields=['data_ct', 'data_iv'])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def rekey_book(request, pk):

View file

@ -2720,6 +2720,18 @@ async function _setCachedBook(bookId, data_ct, data_iv) {
} catch {}
}
async function _evictCachedBook(bookId) {
try {
const db = await _openBookCacheDb();
await new Promise((resolve) => {
const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite');
tx.objectStore(_BOOK_CACHE_STORE).delete(bookId);
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch {}
}
async function _evictBookCache(bookList) {
try {
const db = await _openBookCacheDb();
@ -2838,6 +2850,54 @@ async function loadBookList() {
}
}
const _brokenBooks = new Set();
function markBookBroken(bookId) {
_brokenBooks.add(bookId);
const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`);
if (!item) return;
if (!item.querySelector('.book-broken-btn')) {
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-danger book-broken-btn';
btn.title = 'Buch konnte nicht geöffnet werden — Datei erneut hochladen';
btn.textContent = '!';
btn.onclick = () => repairBook(bookId);
item.querySelector('.book-item-actions').prepend(btn);
}
}
function repairBook(bookId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.epub,.pdf';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`);
const btn = item?.querySelector('.book-broken-btn');
if (btn) btn.textContent = '…';
try {
const key = await getOrCreateEncKey();
const buf = await file.arrayBuffer();
const enc = await encryptBytes(key, buf);
const res = await fetch(`/books/${bookId}/replace-data/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({data_ct: enc.ciphertext, data_iv: enc.iv}),
});
if (!(await res.json()).ok) throw new Error('Server error');
await _evictCachedBook(bookId);
_brokenBooks.delete(bookId);
if (btn) btn.remove();
openBook(bookId);
} catch (e) {
if (btn) btn.textContent = '!';
alert('Reparatur fehlgeschlagen: ' + e.message);
}
};
input.click();
}
function renderBookList(books) {
const listEl = $('book-list');
if (!listEl) return;
@ -2845,13 +2905,15 @@ function renderBookList(books) {
for (const b of books) {
const pct = Math.round((b.scroll_fraction || 0) * 100);
const keyWarning = b.keyOk === false ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠️ wrong key</span>' : '';
html += `<div class="book-item">
const broken = _brokenBooks.has(b.id);
html += `<div class="book-item" data-book-id="${b.id}">
<div class="book-item-info">
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}</strong>
<span class="muted book-author">${escapeHtml(b.author)}</span>
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
</div>
<div class="book-item-actions">
${broken ? `<button class="btn btn-sm btn-danger book-broken-btn" title="Buch konnte nicht geöffnet werden — Datei erneut hochladen" onclick="repairBook(${b.id})">!</button>` : ''}
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button>
</div>
@ -3352,7 +3414,7 @@ async function openBook(bookId) {
} catch (e) {
overlay.style.display = 'none';
alert(`Failed to open book: ${e.message}`);
markBookBroken(bookId);
}
}

View file

@ -143,101 +143,9 @@ async function _getOrCreateEncKey() {
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;
}
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) {