Books: broken-book repair flow + simplify password change
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:
parent
817323ad19
commit
4b47f6e67a
4 changed files with 97 additions and 97 deletions
|
|
@ -6,6 +6,7 @@ urlpatterns = [
|
||||||
path('upload/', views.upload_book, name='upload_book'),
|
path('upload/', views.upload_book, name='upload_book'),
|
||||||
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
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>/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>/rekey/', views.rekey_book, name='rekey_book'),
|
||||||
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
||||||
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
|
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,35 @@ def get_book_data(request, pk):
|
||||||
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
|
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
|
@csrf_exempt
|
||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def rekey_book(request, pk):
|
def rekey_book(request, pk):
|
||||||
|
|
|
||||||
|
|
@ -2720,6 +2720,18 @@ async function _setCachedBook(bookId, data_ct, data_iv) {
|
||||||
} catch {}
|
} 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) {
|
async function _evictBookCache(bookList) {
|
||||||
try {
|
try {
|
||||||
const db = await _openBookCacheDb();
|
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) {
|
function renderBookList(books) {
|
||||||
const listEl = $('book-list');
|
const listEl = $('book-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
|
|
@ -2845,13 +2905,15 @@ function renderBookList(books) {
|
||||||
for (const b of books) {
|
for (const b of books) {
|
||||||
const pct = Math.round((b.scroll_fraction || 0) * 100);
|
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>' : '';
|
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">
|
<div class="book-item-info">
|
||||||
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}</strong>
|
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}</strong>
|
||||||
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
||||||
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="book-item-actions">
|
<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" 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>
|
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3352,7 +3414,7 @@ async function openBook(bookId) {
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
alert(`Failed to open book: ${e.message}`);
|
markBookBroken(bookId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,101 +143,9 @@ async function _getOrCreateEncKey() {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _hexToBytes(hex) {
|
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
|
||||||
const buf = new Uint8Array(hex.length / 2);
|
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?');
|
||||||
for (let i = 0; i < buf.length; i++) buf[i] = parseInt(hex.slice(i*2, i*2+2), 16);
|
if (!ok) e.preventDefault();
|
||||||
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) {
|
async function uploadBackground(input) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue