Replaces scroll_fraction-only position tracking with element-based
anchors ("{index}:{innerFraction}"). Position is now stable across
font size changes and different screen sizes. A ResizeObserver
restores the anchor on viewport/orientation changes.
Falls back to scroll_fraction for books without a saved anchor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
7.1 KiB
Python
235 lines
7.1 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
|
|
|
|
|
|
@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 10 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,
|
|
)
|
|
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 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
|
|
|
|
progress, _ = EBookProgress.objects.get_or_create(
|
|
user=request.user,
|
|
book=book,
|
|
)
|
|
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 > 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 > 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})
|