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, LibraryPrefs 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 with_cover = set(request.user.ebooks.exclude(cover_ct='').values_list('id', flat=True)) 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() b['has_cover'] = b['id'] in with_cover 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}) MAX_COVER = 200_000 # characters of base64: a 300×450 JPEG is far below MAX_PREFS = 100_000 def _hex_iv(value): return isinstance(value, str) and 8 <= len(value) <= 32 and all(c in '0123456789abcdefABCDEF' for c in value) @csrf_exempt @require_http_methods(['GET', 'POST']) def book_cover(request, pk): """A book's cover, encrypted on the device with the user's key (docs/library.md): GET {cover_ct, cover_iv} (404 without one), POST the same to set it.""" err = _require_auth(request) if err: return err book = EBook.objects.filter(pk=pk, user=request.user).only('id', 'cover_ct', 'cover_iv').first() if book is None: return JsonResponse({'error': 'not found'}, status=404) if request.method == 'GET': if not book.cover_ct: return JsonResponse({'error': 'no cover'}, status=404) return JsonResponse({'cover_ct': book.cover_ct, 'cover_iv': book.cover_iv}) try: body = json.loads(request.body) except (json.JSONDecodeError, ValueError): return JsonResponse({'error': 'invalid JSON'}, status=400) ct, iv = body.get('cover_ct'), body.get('cover_iv') if not isinstance(ct, str) or not ct or len(ct) > MAX_COVER or not _hex_iv(iv): return JsonResponse({'error': 'cover_ct, cover_iv required (cover at most 150 KB)'}, status=400) book.cover_ct, book.cover_iv = ct, iv book.save(update_fields=['cover_ct', 'cover_iv']) return JsonResponse({'ok': True}) @csrf_exempt @require_http_methods(['GET', 'POST']) def library_prefs(request): """The library's settings, encrypted with the user's key ({folder_order: [...]}): GET {prefs_ct, prefs_iv} ('' while there are none), POST the same, replacing them.""" err = _require_auth(request) if err: return err if request.method == 'GET': p = LibraryPrefs.objects.filter(user=request.user).first() return JsonResponse({'prefs_ct': p.prefs_ct if p else '', 'prefs_iv': p.prefs_iv if p else ''}) try: body = json.loads(request.body) except (json.JSONDecodeError, ValueError): return JsonResponse({'error': 'invalid JSON'}, status=400) ct, iv = body.get('prefs_ct'), body.get('prefs_iv') if not isinstance(ct, str) or not ct or len(ct) > MAX_PREFS or not _hex_iv(iv): return JsonResponse({'error': 'prefs_ct, prefs_iv required'}, status=400) LibraryPrefs.objects.update_or_create(user=request.user, defaults={'prefs_ct': ct, 'prefs_iv': 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': f'file too large (max {settings.EBOOK_MAX_BYTES // (1024 * 1024)} 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})