diff --git a/books/urls.py b/books/urls.py index 3781b38..b2a9de2 100644 --- a/books/urls.py +++ b/books/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ path('upload/', views.upload_book, name='upload_book'), path('/data/', views.get_book_data, name='get_book_data'), path('/delete/', views.delete_book, name='delete_book'), + path('/replace-data/', views.replace_book_data, name='replace_book_data'), path('/rekey/', views.rekey_book, name='rekey_book'), path('/progress/', views.save_progress, name='save_book_progress'), path('/highlights/', views.book_highlights, name='book_highlights'), diff --git a/books/views.py b/books/views.py index 423a528..44b6e85 100644 --- a/books/views.py +++ b/books/views.py @@ -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): diff --git a/static/js/app.js b/static/js/app.js index bb6e526..6ca1436 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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 ? '⚠️ wrong key' : ''; - html += `
+ const broken = _brokenBooks.has(b.id); + html += `
${escapeHtml(b.title)}${keyWarning} ${escapeHtml(b.author)} ${pct > 0 ? `${pct}% read` : ''}
+ ${broken ? `` : ''}
@@ -3352,7 +3414,7 @@ async function openBook(bookId) { } catch (e) { overlay.style.display = 'none'; - alert(`Failed to open book: ${e.message}`); + markBookBroken(bookId); } } diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index 442ef8f..b0f7510 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -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) {