diora-web/books/views.py
Marwin Schulz 6d391587c8
All checks were successful
Build and push Docker image / build (push) Successful in 11s
Test / test (push) Successful in 11s
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
  GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
  zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
  arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
  smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
  wait for EPUB images to load before restoring scroll, PDF paginated uses page
  fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00

225 lines
6.7 KiB
Python

import base64
import json
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
for p in EBookProgress.objects.filter(user=request.user)
}
for b in books:
b['scroll_fraction'] = progress_map.get(b['id'], 0.0)
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))
progress, _ = EBookProgress.objects.get_or_create(
user=request.user,
book=book,
)
progress.scroll_fraction = scroll_fraction
progress.save(update_fields=['scroll_fraction', '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})