diora-web/books/views.py

720 lines
27 KiB
Python
Raw Normal View History

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, EBookFile, EBookProgress, EBookHighlights, EBookBookmarks, LibraryPrefs, book_rows
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 = book_rows(request.user)
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)
# 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'])
return JsonResponse({'ok': True})
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht) - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
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)
with transaction.atomic():
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
f = EBookFile.objects.filter(book_id=pk, book__user=request.user).first()
if f is None:
return JsonResponse({'error': 'not found'}, status=404)
return JsonResponse({'data_ct': f.data_ct, 'data_iv': f.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)
with transaction.atomic():
EBookFile.objects.update_or_create(book=book, defaults={'data_ct': data_ct, 'data_iv': data_iv})
if meta_ct and meta_iv:
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 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)
with transaction.atomic():
EBookFile.objects.update_or_create(book=book, defaults={'data_ct': data_ct, 'data_iv': data_iv})
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 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})