176 lines
6.8 KiB
Python
176 lines
6.8 KiB
Python
"""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
|