Buch-Metadaten: Änderung auf veraltetem Stand wird abgelehnt (409 mit dem aktuellen), das Web baut sie darauf neu auf
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:
parent
58b337d224
commit
1cb6584395
5 changed files with 92 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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/`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue