import base64 import json import re import xml.etree.ElementTree as ET import requests 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 # --------------------------------------------------------------------------- # 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).""" 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', 'is_read') ) 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) } highlighted_ids = set( EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True) ) 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 '' b['has_highlights'] = b['id'] in highlighted_ids return JsonResponse(books, safe=False) @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}) @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})