diora-web/books/views.py
mrwnslz 60dd4926cf
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m21s
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.

- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
  sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
  Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
  Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
  /books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
  hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
  schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
  während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
  Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
  Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
  Stelle, Liste und Snapshot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00

661 lines
24 KiB
Python

import base64
import json
import re
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone as dt_timezone
import requests
from django.conf import settings
from django.db import transaction
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
def _require_auth(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return None
# ---------------------------------------------------------------------------
# Library-shelf metadata lookup (DNB, falling back to Open Library)
#
# Only ever called on explicit user request (the "Metadaten" book action), never
# automatically — the server briefly sees the plaintext ISBN for this one proxied
# request, which is a deliberate, narrow exception to the "server never sees book
# content" rule (see CLAUDE.md), made because the encryption's real purpose here is
# to keep the operator from being able to see what's on the platform (piracy
# liability), not strict user privacy — an ISBN lookup against public library
# catalogs doesn't undermine that. Nothing from this lookup is persisted server-side;
# the resulting label is stored only in the client's encrypted meta blob.
# ---------------------------------------------------------------------------
# DNB "Sachgruppen der Deutschen Nationalbibliografie" / DDC divisions (hundreds -> tens),
# used to turn a raw DDC notation like "833.92" into a short shelf label.
_DDC_DIVISIONS = {
'000': 'Allgemeines, Informatik', '010': 'Bibliografien', '020': 'Bibliotheks- und Informationswissenschaft',
'030': 'Enzyklopädien', '050': 'Zeitschriften, fortlaufende Sammelwerke', '060': 'Organisationen, Museumswissenschaft',
'070': 'Nachrichtenmedien, Journalismus, Verlagswesen', '090': 'Handschriften, seltene Bücher',
'100': 'Philosophie', '130': 'Parapsychologie, Okkultismus', '150': 'Psychologie',
'200': 'Religion', '230': 'Christentum, Christliche Theologie', '290': 'Andere Religionen',
'300': 'Sozialwissenschaften, Soziologie', '310': 'Statistiken', '320': 'Politikwissenschaft',
'330': 'Wirtschaft', '340': 'Recht', '350': 'Öffentliche Verwaltung, Militärwissenschaft',
'360': 'Soziale Probleme, Sozialdienste, Versicherungen', '370': 'Erziehung, Schul- und Bildungswesen',
'380': 'Handel, Kommunikation, Verkehr', '390': 'Gebräuche, Etikette, Folklore',
'400': 'Sprache, Linguistik', '420': 'Englisch', '430': 'Deutsch, Germanische Sprachen',
'440': 'Französisch, Romanische Sprachen', '450': 'Italienisch, Rumänisch, Rätoromanisch',
'460': 'Spanisch, Portugiesisch', '470': 'Latein, Italische Sprachen', '480': 'Griechisch', '490': 'Andere Sprachen',
'500': 'Naturwissenschaften', '510': 'Mathematik', '520': 'Astronomie', '530': 'Physik', '540': 'Chemie',
'550': 'Geowissenschaften', '560': 'Paläontologie', '570': 'Biowissenschaften, Biologie',
'580': 'Pflanzen (Botanik)', '590': 'Tiere (Zoologie)',
'600': 'Technik', '610': 'Medizin, Gesundheit', '620': 'Ingenieurwissenschaften', '630': 'Landwirtschaft',
'640': 'Hauswirtschaft', '650': 'Management', '660': 'Chemische Technik', '670': 'Industrielle Fertigung',
'680': 'Fertigung für spezielle Zwecke', '690': 'Hausbau, Bauhandwerk',
'700': 'Künste', '710': 'Landschaftsgestaltung, Raumplanung', '720': 'Architektur',
'730': 'Plastik, Keramik, Metallkunst', '740': 'Zeichnung, angewandte Kunst', '750': 'Malerei',
'760': 'Grafik, Druckgrafik, Fotografie', '780': 'Musik', '790': 'Freizeit, Darstellende Kunst, Sport',
'800': 'Literatur', '810': 'Amerikanische Literatur', '820': 'Englische Literatur',
'830': 'Deutsche Literatur', '840': 'Französische Literatur', '850': 'Italienische Literatur',
'860': 'Spanische, Portugiesische Literatur', '870': 'Lateinische Literatur', '880': 'Griechische Literatur',
'890': 'Literaturen in anderen Sprachen',
'900': 'Geschichte', '910': 'Geografie, Reisen', '920': 'Biografie, Genealogie',
'930': 'Geschichte des Altertums', '940': 'Geschichte Europas', '950': 'Geschichte Asiens',
'960': 'Geschichte Afrikas', '970': 'Geschichte Nordamerikas', '980': 'Geschichte Südamerikas',
'990': 'Geschichte der übrigen Welt',
}
def _ddc_label(ddc_raw):
"""Coarsen a DDC notation ('833.92') down to its nearest known division/class label."""
digits = re.sub(r'\D', '', ddc_raw or '')
if len(digits) < 3:
return None
tens = digits[:2] + '0'
if tens in _DDC_DIVISIONS:
return _DDC_DIVISIONS[tens]
return _DDC_DIVISIONS.get(digits[0] + '00')
_MARC_NS = '{http://www.loc.gov/MARC21/slim}'
def _lookup_dnb_shelf(isbn):
url = 'https://services.dnb.de/sru/dnb'
params = {
'version': '1.1',
'operation': 'searchRetrieve',
'query': f'dnb.num={isbn}',
'recordSchema': 'MARC21-xml',
'maximumRecords': '1',
}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
root = ET.fromstring(resp.content)
for datafield in root.iter(f'{_MARC_NS}datafield'):
if datafield.get('tag') != '082':
continue
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
if subfield.get('code') == 'a' and subfield.text:
label = _ddc_label(subfield.text)
if label:
return label
return None
def _lookup_openlibrary_shelf(isbn):
url = 'https://openlibrary.org/api/books'
params = {'bibkeys': f'ISBN:{isbn}', 'jscmd': 'data', 'format': 'json'}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
data = resp.json().get(f'ISBN:{isbn}', {})
ddc = (data.get('classifications') or {}).get('dewey_decimal_class') or []
if ddc:
label = _ddc_label(ddc[0])
if label:
return label
subjects = data.get('subjects') or []
if subjects:
return subjects[0].get('name')
return None
def _cql_phrase(value):
# CQL string literals are quoted; strip embedded quotes rather than escaping them
# (this only feeds a lookup heuristic, not a stored/displayed value).
return value.replace('"', ' ').strip()
def _lookup_dnb_shelf_by_title(title, author):
url = 'https://services.dnb.de/sru/dnb'
query = f'dnb.tit="{_cql_phrase(title)}"'
if author:
query += f' and dnb.per="{_cql_phrase(author)}"'
params = {
'version': '1.1',
'operation': 'searchRetrieve',
'query': query,
'recordSchema': 'MARC21-xml',
'maximumRecords': '1',
}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
root = ET.fromstring(resp.content)
for datafield in root.iter(f'{_MARC_NS}datafield'):
if datafield.get('tag') != '082':
continue
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
if subfield.get('code') == 'a' and subfield.text:
label = _ddc_label(subfield.text)
if label:
return label
return None
def _lookup_openlibrary_shelf_by_title(title, author):
url = 'https://openlibrary.org/search.json'
params = {'title': title, 'limit': 1, 'fields': 'ddc,subject'}
if author:
params['author'] = author
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
docs = resp.json().get('docs') or []
if not docs:
return None
doc = docs[0]
ddc = doc.get('ddc') or []
if ddc:
label = _ddc_label(ddc[0])
if label:
return label
subjects = doc.get('subject') or []
if subjects:
return subjects[0]
return None
@require_http_methods(['GET'])
def lookup_book_metadata(request):
err = _require_auth(request)
if err:
return err
isbn_raw = request.GET.get('isbn', '').strip()
title = request.GET.get('title', '').strip()
author = request.GET.get('author', '').strip()
isbn = re.sub(r'[^0-9Xx]', '', isbn_raw)
if isbn_raw and len(isbn) not in (10, 13):
return JsonResponse({'error': 'invalid ISBN'}, status=400)
if not isbn and not title:
return JsonResponse({'error': 'isbn or title required'}, status=400)
label = None
source = None
# ISBN is the precise path — try it first when we have one.
if isbn:
try:
label = _lookup_dnb_shelf(isbn)
if label:
source = 'dnb'
except Exception:
pass
if not label:
try:
label = _lookup_openlibrary_shelf(isbn)
if label:
source = 'openlibrary'
except Exception:
pass
# No ISBN (or it drew a blank) — fall back to a title/author text search. Less
# precise (wrong edition/translation is possible), so the source is tagged
# distinctly for the client to hint at that if it wants to.
if not label and title:
try:
label = _lookup_dnb_shelf_by_title(title, author)
if label:
source = 'dnb-title'
except Exception:
pass
if not label:
try:
label = _lookup_openlibrary_shelf_by_title(title, author)
if label:
source = 'openlibrary-title'
except Exception:
pass
return JsonResponse({'label': label, 'source': source})
def _anchor_parts(anchor):
"""Split a position anchor 'blockIndex:innerFraction' into (block, inner).
Returns (-1, 0.0) for empty/invalid anchors (e.g. PDF progress)."""
if not isinstance(anchor, str) or ':' not in anchor:
return (-1, 0.0)
block, _, inner = anchor.partition(':')
try:
return (int(block), float(inner))
except ValueError:
return (-1, 0.0)
def _progress_is_further(new_anchor, new_frac, old_anchor, old_frac):
"""True if the new reading position is at least as far into the book as the
old one. Compares by anchor block index (precise, decoupled from layout);
falls back to scroll_fraction only when an anchor is missing (PDFs)."""
return _cmp_progress(new_anchor, new_frac, old_anchor, old_frac) >= 0
def _cmp_progress(a_anchor, a_frac, b_anchor, b_frac):
"""> 0 if A is further into the book than B, < 0 if behind, 0 if level."""
ab, ai = _anchor_parts(a_anchor)
bb, bi = _anchor_parts(b_anchor)
if ab >= 0 and bb >= 0:
if ab != bb:
return ab - bb
return (ai > bi) - (ai < bi)
return (a_frac > b_frac) - (a_frac < b_frac)
def _now_ms():
return int(time.time() * 1000)
def progress_state(p):
"""A book's reading state as every client sees it (docs/progress-sync.md);
p may be None (never opened)."""
if p is None:
return {'scroll_fraction': 0.0, 'position_anchor': '', 'read_at': None, 'device': '',
'furthest_fraction': 0.0, 'furthest_anchor': '', 'last_read': None}
read_at = p.read_at if p.read_at is not None else int(p.updated_at.timestamp() * 1000)
return {
'scroll_fraction': p.scroll_fraction,
'position_anchor': p.position_anchor,
'read_at': read_at,
'device': p.device,
'furthest_fraction': p.furthest_fraction,
'furthest_anchor': p.furthest_anchor,
'last_read': datetime.fromtimestamp(read_at / 1000, tz=dt_timezone.utc).isoformat(),
}
@require_http_methods(['GET'])
def book_list(request):
err = _require_auth(request)
if err:
return err
books = list(
request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read')
)
for b in books:
b['uploaded_at'] = b['uploaded_at'].isoformat()
progress_map = {p.book_id: p for p in EBookProgress.objects.filter(user=request.user)}
highlighted_ids = set(
EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True)
)
for b in books:
b.update(progress_state(progress_map.get(b['id'])))
b['has_highlights'] = b['id'] in highlighted_ids
response = JsonResponse(books, safe=False)
# The list is a bare array (old clients expect that): the server clock goes in a header.
response['X-Server-Now'] = str(_now_ms())
return response
@csrf_exempt
@require_http_methods(['POST'])
def set_book_read(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)
book.is_read = bool(body.get('is_read', True))
book.save(update_fields=['is_read'])
return JsonResponse({'ok': True, 'is_read': book.is_read})
@csrf_exempt
@require_http_methods(['POST'])
def update_book_meta(request, pk):
"""Update only the encrypted metadata blob (e.g. to assign a folder) without touching book bytes."""
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)
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
if not meta_ct or not meta_iv:
return JsonResponse({'error': 'meta_ct, meta_iv required'}, status=400)
book.meta_ct = meta_ct
book.meta_iv = meta_iv
book.save(update_fields=['meta_ct', 'meta_iv'])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def upload_book(request):
err = _require_auth(request)
if err:
return err
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
data_ct = body.get('data_ct', '')
data_iv = body.get('data_iv', '')
if not all([meta_ct, meta_iv, data_ct, data_iv]):
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
# Enforce size limit: ciphertext is plaintext + 16-byte GCM tag
max_bytes = getattr(settings, 'EBOOK_MAX_BYTES', 10 * 1024 * 1024) + 32
try:
raw_size = len(base64.b64decode(data_ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
if raw_size > max_bytes:
return JsonResponse({'error': 'file too large (max 50 MB)'}, status=400)
book = EBook.objects.create(
user=request.user,
meta_ct=meta_ct,
meta_iv=meta_iv,
data_ct=data_ct,
data_iv=data_iv,
)
EBookProgress.objects.create(user=request.user, book=book)
return JsonResponse({'ok': True, 'id': book.id})
@require_http_methods(['GET'])
def get_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)
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', '')
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
if not all([data_ct, data_iv]):
return JsonResponse({'error': 'data_ct and data_iv required'}, status=400)
update_fields = ['data_ct', 'data_iv']
book.data_ct = data_ct
book.data_iv = data_iv
if meta_ct and meta_iv:
book.meta_ct = meta_ct
book.meta_iv = meta_iv
update_fields += ['meta_ct', 'meta_iv']
book.save(update_fields=update_fields)
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def rekey_book(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)
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
data_ct = body.get('data_ct', '')
data_iv = body.get('data_iv', '')
if not all([meta_ct, meta_iv, data_ct, data_iv]):
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
book.meta_ct = meta_ct
book.meta_iv = meta_iv
book.data_ct = data_ct
book.data_iv = data_iv
book.save(update_fields=['meta_ct', 'meta_iv', 'data_ct', 'data_iv'])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def delete_book(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)
book.delete()
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def save_progress(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)
scroll_fraction = float(body.get('scroll_fraction', 0.0))
scroll_fraction = max(0.0, min(1.0, scroll_fraction))
raw_anchor = body.get('position_anchor', '')
position_anchor = ''
if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor):
position_anchor = raw_anchor
force = body.get('force') is True
device = body.get('device') if isinstance(body.get('device'), str) else ''
now = _now_ms()
read_at = _client_ms(body.get('read_at'))
sent_at = _client_ms(body.get('sent_at'))
with transaction.atomic():
progress, created = EBookProgress.objects.select_for_update().get_or_create(user=request.user, book=book)
if created:
progress.read_at = None
if force:
when, store = now, True
elif read_at is not None:
# The device's clock may be off: move its time onto ours by the
# difference at sending, and nothing counts as later than now.
when = read_at + (now - sent_at) if sent_at is not None else read_at
when = min(when, now)
store = progress.read_at is None or when >= progress.read_at
else:
# An app from before 2026-10-12: only forward, as it used to be — but
# strictly, so re-saving the same place changes nothing.
when = now
store = created or _cmp_progress(position_anchor, scroll_fraction,
progress.position_anchor, progress.scroll_fraction) > 0
if store:
progress.scroll_fraction = scroll_fraction
progress.position_anchor = position_anchor
progress.read_at = when
progress.device = device[:40]
if _cmp_progress(position_anchor, scroll_fraction,
progress.furthest_anchor, progress.furthest_fraction) > 0 or created:
progress.furthest_fraction = scroll_fraction
progress.furthest_anchor = position_anchor
progress.save()
return JsonResponse({'ok': True, 'stored': store, 'server_now': now, **progress_state(progress)})
def _client_ms(value):
"""A time from a client (ms since epoch) or None if it isn't a plausible one."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
value = int(value)
return value if 10 ** 12 <= value < 10 ** 14 else None
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def book_highlights(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)
if request.method == 'GET':
try:
row = EBookHighlights.objects.get(user=request.user, book=book)
return JsonResponse({'ct': row.ct, 'iv': row.iv})
except EBookHighlights.DoesNotExist:
return JsonResponse({'ct': None, 'iv': None})
# POST — upsert
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
ct = body.get('ct', '')
iv = body.get('iv', '')
if not ct or not iv:
return JsonResponse({'error': 'ct and iv required'}, status=400)
# Size guard: highlights ≤ 700 KB base64
try:
raw_size = len(base64.b64decode(ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > getattr(settings, 'HIGHLIGHTS_MAX_BYTES', 700 * 1024):
return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400)
row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book)
row.ct = ct
row.iv = iv
row.save(update_fields=['ct', 'iv', 'updated_at'])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def book_bookmarks(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)
if request.method == 'GET':
try:
row = EBookBookmarks.objects.get(user=request.user, book=book)
return JsonResponse({'ct': row.ct, 'iv': row.iv})
except EBookBookmarks.DoesNotExist:
return JsonResponse({'ct': None, 'iv': None})
# POST — upsert
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
ct = body.get('ct', '')
iv = body.get('iv', '')
if not ct or not iv:
return JsonResponse({'error': 'ct and iv required'}, status=400)
# Size guard: bookmarks ≤ 100 KB base64
try:
raw_size = len(base64.b64decode(ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > getattr(settings, 'BOOKMARKS_MAX_BYTES', 100 * 1024):
return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400)
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)
row.ct = ct
row.iv = iv
row.save(update_fields=['ct', 'iv', 'updated_at'])
return JsonResponse({'ok': True})