Buch-Metadaten: Änderung auf veraltetem Stand wird abgelehnt (409 mit dem aktuellen), das Web baut sie darauf neu auf
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Has been cancelled

Eine App mit alter Bücherliste (Desktop während des langsamen Syncs) hat beim Öffnen fünf
MEW-Bände mit ihrem alten Stand überschrieben – Titel, Autor, Ordner weg. base_iv sagt dem
Server, von welchem Stand eine Änderung ausgeht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-19 18:03:49 +02:00
parent 58b337d224
commit 1cb6584395
5 changed files with 92 additions and 13 deletions

View file

@ -259,3 +259,34 @@ class EBookFileTests(TestCase):
other = User.objects.create_user('other-filer', password='pw-other-123')
b = EBook.objects.create(user=other, meta_ct='m', meta_iv='i', data_ct='ZA==', data_iv='i')
self.assertEqual(self.client.get(f'/books/{b.id}/data/').status_code, 404)
class StaleMetaTests(TestCase):
"""A meta write that starts from an older meta (base_iv) is refused with the current one."""
def setUp(self):
from django.contrib.auth.models import User
from books.models import EBook
self.user = User.objects.create_user('metauser', password='pw-meta-123')
self.client.force_login(self.user)
self.book = EBook.objects.create(user=self.user, meta_ct='m1', meta_iv='aa', data_ct='ZA==', data_iv='i')
def post(self, body):
import json
return self.client.post(f'/books/{self.book.id}/meta/', json.dumps(body), content_type='application/json')
def test_current_base_is_taken(self):
self.assertEqual(self.post({'meta_ct': 'm2', 'meta_iv': 'bb', 'base_iv': 'aa'}).status_code, 200)
self.book.refresh_from_db()
self.assertEqual((self.book.meta_ct, self.book.meta_iv), ('m2', 'bb'))
def test_old_base_is_refused_with_the_current_meta(self):
self.post({'meta_ct': 'm2', 'meta_iv': 'bb', 'base_iv': 'aa'})
r = self.post({'meta_ct': 'old', 'meta_iv': 'cc', 'base_iv': 'aa'})
self.assertEqual(r.status_code, 409)
self.assertEqual((r.json()['meta_ct'], r.json()['meta_iv']), ('m2', 'bb'))
self.book.refresh_from_db()
self.assertEqual(self.book.meta_ct, 'm2')
def test_without_base_as_before(self):
self.assertEqual(self.post({'meta_ct': 'm3', 'meta_iv': 'dd'}).status_code, 200)

View file

@ -350,6 +350,14 @@ def update_book_meta(request, pk):
if not meta_ct or not meta_iv:
return JsonResponse({'error': 'meta_ct, meta_iv required'}, status=400)
# base_iv: the IV of the meta the client changed. Another one now (changed elsewhere
# since): refused with the current meta, for the client to apply its change to that.
# A queued write from a device with an old list (a title or folder set meanwhile)
# used to put the old meta back. Clients that don't send base_iv write as before.
base_iv = body.get('base_iv')
if base_iv and base_iv != book.meta_iv:
return JsonResponse({'error': 'stale', 'meta_ct': book.meta_ct, 'meta_iv': book.meta_iv}, status=409)
book.meta_ct = meta_ct
book.meta_iv = meta_iv
book.save(update_fields=['meta_ct', 'meta_iv'])

View file

@ -143,3 +143,27 @@ test('editions: two books joined show once with their formats, set apart show tw
await page.evaluate(() => { _lib.status = 'all'; });
for (const id of ids) await page.request.post(`/books/${id}/delete/`);
});
test('a meta change from an old list goes onto the current meta (409, then again)', async ({ page }) => {
const files = [path.join(__dirname, '.auth', 'many-Eins.epub')];
await page.goto('/#books');
await page.waitForFunction(() => typeof _importMany === 'function' && Array.isArray(_lastBookListData));
await page.locator('#book-file-input').setInputFiles(files);
await page.waitForFunction(() => _lastBookListData.some(b => b.title === 'Mehrfach Eins' && b.id && b.meta_iv), null, { timeout: 30000 });
const id = await page.evaluate(() => _lastBookListData.find(b => b.title === 'Mehrfach Eins').id);
// Elsewhere the title changes; this page still has the old list.
await page.evaluate(id => _updateBookMeta(id, {title: 'Anderswo umbenannt'}), id);
await page.evaluate(id => {
const b = _lastBookListData.find(x => x.id === id);
b.title = 'Mehrfach Eins'; b.meta_iv = '000000000000000000000000'; // the old list
bookMetaCache[id] = {...bookMetaCache[id], title: 'Mehrfach Eins'};
}, id);
await page.evaluate(id => _updateBookMeta(id, {folders: ['Probe']}), id);
const meta = await page.evaluate(async id => {
const row = (await (await fetch('/books/', {cache: 'no-store'})).json()).find(b => b.id === id);
return JSON.parse(new TextDecoder().decode(await decryptBytes(await getOrCreateEncKey(), row.meta_iv, row.meta_ct)));
}, id);
expect(meta.title).toBe('Anderswo umbenannt');
expect(meta.folders).toEqual(['Probe']);
await page.request.post(`/books/${id}/delete/`);
});

View file

@ -51,7 +51,7 @@ async function loadBookList() {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, work: meta.work, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt};
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, work: meta.work, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, has_cover: !!b.has_cover, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, work: meta.work, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, meta_iv: b.meta_iv, has_cover: !!b.has_cover, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
} catch (e) {
bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
@ -458,17 +458,33 @@ async function _updateBookMeta(bookId, overrides) {
if (overrides.folders) merged.folder = overrides.folders[0] || '';
else if ('folder' in overrides) merged.folders = overrides.folder ? [overrides.folder] : [];
const key = await getOrCreateEncKey();
const metaJson = new TextEncoder().encode(JSON.stringify(merged));
const metaEnc = await encryptBytes(key, metaJson);
const res = await fetch(`/books/${bookId}/meta/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}),
});
const data = await res.json();
if (!data.ok) throw new Error('Serverfehler');
Object.assign(book, overrides, {folder: merged.folder, folders: merged.folders});
bookMetaCache[bookId] = merged;
// base_iv: the meta this change starts from. Changed elsewhere since (the server says 409
// and sends the current one): the change goes onto that one instead, once.
let toWrite = merged;
let base = book.meta_iv;
let data;
for (let attempt = 0; attempt < 2; attempt++) {
const metaEnc = await encryptBytes(key, new TextEncoder().encode(JSON.stringify(toWrite)));
const res = await fetch(`/books/${bookId}/meta/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv, ...(base ? {base_iv: base} : {})}),
});
data = await res.json().catch(() => ({}));
if (res.status === 409 && data.meta_ct && attempt === 0) {
const now = JSON.parse(new TextDecoder().decode(await decryptBytes(key, data.meta_iv, data.meta_ct)));
toWrite = {...now, ...overrides};
if (overrides.folders) toWrite.folder = overrides.folders[0] || '';
else if ('folder' in overrides) toWrite.folders = overrides.folder ? [overrides.folder] : [];
base = data.meta_iv;
continue;
}
if (data.ok) { book.meta_iv = metaEnc.iv; break; }
throw new Error(res.status === 409 ? 'inzwischen anderswo geändert' : 'Serverfehler');
}
// What the server has now, all of it (from the rebase too: a title set elsewhere shows).
Object.assign(book, toWrite, {folder: toWrite.folder || '', folders: _foldersOfMeta(toWrite)});
bookMetaCache[bookId] = toWrite;
_saveBookMeta(_lastBookListData);
return true;
}

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v134';
const CACHE = 'diora-v135';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',