"""Cloud import endpoints for the ebook library. These let the browser browse a user-configured WebDAV server and pull one book out of it. The fetch is proxied through the server rather than done in the browser because the WebDAV host is a different origin and will not send CORS headers for it. That proxy is a deliberate, narrow relaxation of the same rule the ISBN lookup in books/views.py bends: the server sees the file's bytes in memory for the duration of one request, but never persists them. The book is encrypted in the browser and only then POSTed to /books/upload/ as ciphertext, exactly like a local upload — nothing readable is ever stored server-side. """ import json from datetime import timedelta from django.conf import settings from django.http import HttpResponse, JsonResponse from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from accounts.models import WebDAVSeen, WebDAVSource from accounts.webdav import WebDAVError, WebDAVInputError, list_directory, fetch_file from .views import _require_auth def _get_source(request, pk): return WebDAVSource.objects.filter(pk=pk, user=request.user).first() @require_http_methods(['GET']) def cloud_sources(request): unauthorized = _require_auth(request) if unauthorized: return unauthorized sources = request.user.webdav_sources.all() return JsonResponse({'sources': [ {'id': s.id, 'label': s.label, 'host': s.base_url, 'inbox': s.inbox} for s in sources ]}) # ---- "Eingang" (docs/library.md): new books of a source into the library by themselves ---- INBOX_DEPTH = 3 # sub-folders looked into INBOX_MAX = 20 # files handed out per call INBOX_CLAIM = timedelta(minutes=10) def _inbox_files(source): """The source's book files, its sub-folders too (INBOX_DEPTH deep).""" out, todo = [], [('', 0)] while todo: path, depth = todo.pop(0) for e in list_directory(source, path): if e['is_dir'] and depth < INBOX_DEPTH: todo.append((e['path'], depth + 1)) elif e['is_book']: out.append(e) return out @require_http_methods(['GET']) def cloud_inbox(request): """The files of the user's "Eingang" sources not taken into the library yet, claimed for this device for ten minutes (another device asking meanwhile doesn't get them): {files: [{source, label, path, name, size, modified}], errors: [{source, label, error}]}. A file that changed (other size) counts as new again.""" unauthorized = _require_auth(request) if unauthorized: return unauthorized now = timezone.now() files, errors = [], [] for source in request.user.webdav_sources.filter(inbox=True): try: entries = _inbox_files(source) except (WebDAVError, WebDAVInputError) as exc: errors.append({'source': source.id, 'label': source.label, 'error': str(exc)}) continue seen = {s.path: s for s in source.seen.all()} for e in entries: if len(files) >= INBOX_MAX: break s = seen.get(e['path']) if s and s.size == e['size']: if s.done_at or (s.claimed_at and now - s.claimed_at < INBOX_CLAIM): continue WebDAVSeen.objects.update_or_create( source=source, path=e['path'][:1000], defaults={'size': e['size'], 'modified': e['modified'][:100], 'claimed_at': now, 'done_at': None, 'result': ''}) files.append({'source': source.id, 'label': source.label, 'path': e['path'], 'name': e['name'].rsplit('/', 1)[-1], 'size': e['size'], 'modified': e['modified']}) source.last_used_at = now source.save(update_fields=['last_used_at']) return JsonResponse({'files': files, 'errors': errors}) @csrf_exempt @require_http_methods(['POST']) def cloud_done(request, pk): """{path, result}: a device finished with an "Eingang" file — imported, known (already in the library), too_big: not offered again; failed: offered again after the claim ran out.""" unauthorized = _require_auth(request) if unauthorized: return unauthorized source = _get_source(request, pk) if not source: return JsonResponse({'error': 'unknown source'}, status=404) try: body = json.loads(request.body or b'{}') except (json.JSONDecodeError, ValueError): return JsonResponse({'error': 'invalid JSON'}, status=400) result = body.get('result') if isinstance(body, dict) else None if result not in ('imported', 'known', 'too_big', 'failed'): return JsonResponse({'error': 'result: imported, known, too_big or failed'}, status=400) seen = WebDAVSeen.objects.filter(source=source, path=str(body.get('path', ''))[:1000]).first() if not seen: return JsonResponse({'error': 'unknown file'}, status=404) seen.result = result seen.done_at = None if result == 'failed' else timezone.now() seen.save(update_fields=['result', 'done_at']) return JsonResponse({'ok': True}) @require_http_methods(['GET']) def cloud_browse(request, pk): unauthorized = _require_auth(request) if unauthorized: return unauthorized source = _get_source(request, pk) if not source: return JsonResponse({'error': 'unknown source'}, status=404) path = request.GET.get('path', '') try: entries = list_directory(source, path) except WebDAVInputError as exc: return JsonResponse({'error': str(exc)}, status=400) except WebDAVError as exc: return JsonResponse({'error': str(exc)}, status=502) source.last_used_at = timezone.now() source.save(update_fields=['last_used_at']) return JsonResponse({'ok': True, 'path': path, 'entries': entries}) @require_http_methods(['GET']) def cloud_fetch(request, pk): unauthorized = _require_auth(request) if unauthorized: return unauthorized source = _get_source(request, pk) if not source: return JsonResponse({'error': 'unknown source'}, status=404) path = request.GET.get('path', '') try: payload = fetch_file(source, path, settings.EBOOK_MAX_BYTES) except WebDAVInputError as exc: return JsonResponse({'error': str(exc)}, status=400) except WebDAVError as exc: return JsonResponse({'error': str(exc)}, status=502) response = HttpResponse(payload, content_type='application/octet-stream') # These are the book's plaintext bytes, on their way to be encrypted in the # browser. Keeping them out of the HTTP disk cache (and any reverse proxy # in front) is what makes "nothing readable is stored" true end to end. response['Cache-Control'] = 'no-store' return response