Die max()-Semantik aus v14 verglich nur die grobe scroll_fraction. Da scroll_fraction und position_anchor gemeinsam, aber entkoppelt gespeichert werden, konnte ein einzelner Save mit transient zu hoher fraction (z.B. instabiles Layout beim Laden) die Position dauerhaft einfrieren: jeder spätere echte Fortschritt hatte eine kleinere fraction und wurde inklusive Anker verworfen — der Leser landete immer wieder an derselben Stelle. Fix: Vergleich läuft jetzt über den Blockindex im Anker (die Größe, die auch die Sprungposition bestimmt), mit innerFraction als Tiebreaker. scroll_fraction dient nur noch als Fallback für PDFs ohne Anker. Server (_progress_is_further) und Client (_cmpProgress) nutzen identische Logik; loadBookList-Push und lokaler Cache ebenfalls. Bestehende eingefrorene Einträge entfrieren sich beim ersten Weiterlesen selbst. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
336 lines
11 KiB
Python
336 lines
11 KiB
Python
import base64
|
|
import json
|
|
import re
|
|
|
|
from django.conf import settings
|
|
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
|
|
|
|
|
|
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)."""
|
|
nb, ni = _anchor_parts(new_anchor)
|
|
ob, oi = _anchor_parts(old_anchor)
|
|
if nb >= 0 and ob >= 0:
|
|
return ni >= oi if nb == ob else nb >= ob
|
|
return new_frac >= old_frac
|
|
|
|
|
|
@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')
|
|
)
|
|
for b in books:
|
|
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
|
# Include saved scroll_fraction for each book
|
|
progress_map = {
|
|
p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor)
|
|
for p in EBookProgress.objects.filter(user=request.user)
|
|
}
|
|
for b in books:
|
|
prog = progress_map.get(b['id'])
|
|
b['scroll_fraction'] = prog[0] if prog else 0.0
|
|
b['last_read'] = prog[1].isoformat() if prog else None
|
|
b['position_anchor'] = prog[2] if prog else ''
|
|
return JsonResponse(books, safe=False)
|
|
|
|
|
|
@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 = bool(body.get('force', False))
|
|
|
|
progress, _ = EBookProgress.objects.get_or_create(
|
|
user=request.user,
|
|
book=book,
|
|
)
|
|
# Always advance to the furthest-read position (by anchor block index, so a
|
|
# transiently wrong scroll_fraction can't freeze the position) unless the
|
|
# client explicitly forces a reset (e.g. "start over" button in the reader).
|
|
if force or _progress_is_further(position_anchor, scroll_fraction,
|
|
progress.position_anchor, progress.scroll_fraction):
|
|
progress.scroll_fraction = scroll_fraction
|
|
progress.position_anchor = position_anchor
|
|
progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])
|
|
|
|
return JsonResponse({'ok': True})
|
|
|
|
|
|
@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})
|