diff --git a/books/tests.py b/books/tests.py index 695e9f0..c8e711c 100644 --- a/books/tests.py +++ b/books/tests.py @@ -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) diff --git a/books/views.py b/books/views.py index 3c68a17..299f02e 100644 --- a/books/views.py +++ b/books/views.py @@ -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']) diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js index 6df58f6..2ec66c4 100644 --- a/e2e/navigation.spec.js +++ b/e2e/navigation.spec.js @@ -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/`); +}); diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index a4dde37..0fabf7e 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -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; } diff --git a/static/js/sw.js b/static/js/sw.js index 4b816c2..cc14f05 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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',