"""Projekte (docs/projects.md): the API. Everything a project says is encrypted on the device; here it's stored and handed back. A write names the IV it changed (base_iv): changed elsewhere since, it's refused with the current state (409) for the device to merge.""" import json import re import requests from django.core.cache import cache from django.db import IntegrityError, transaction 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 Project, ProjectItem MAX_DATA = 2_000_000 # characters of base64: a long draft, not a PDF MAX_PROJECTS = 200 MAX_ITEMS = 5000 def _auth(request): if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) return None def _body(request): try: body = json.loads(request.body or b'{}') except (json.JSONDecodeError, ValueError): return None return body if isinstance(body, dict) else None def _hex_iv(value): return isinstance(value, str) and 8 <= len(value) <= 32 and all(c in '0123456789abcdefABCDEF' for c in value) def _blob(body): """(data_ct, data_iv) from a request, or None.""" ct, iv = body.get('data_ct'), body.get('data_iv') if not isinstance(ct, str) or not ct or len(ct) > MAX_DATA or not _hex_iv(iv): return None return ct, iv def _project_json(p, counts=None): return {'id': p.id, 'preset': p.preset, 'data_ct': p.data_ct, 'data_iv': p.data_iv, 'archived': p.archived, 'position': p.position, 'created_at': p.created_at.isoformat(), 'updated_at': p.updated_at.isoformat(), 'items': (counts or {}).get(p.id, 0)} def _item_json(i): return {'id': i.id, 'project': i.project_id, 'kind': i.kind, 'ref': i.ref, 'data_ct': i.data_ct, 'data_iv': i.data_iv, 'created_at': i.created_at.isoformat(), 'updated_at': i.updated_at.isoformat()} def _project(request, pk): return Project.objects.filter(pk=pk, user=request.user).first() # ---- projects ------------------------------------------------------------------------------ @require_http_methods(['GET']) def projects(request): """All projects, and every link (kind, ref, project) — what a book's or an episode's sheet shows as "in Projekt …".""" err = _auth(request) if err: return err rows = list(Project.objects.filter(user=request.user)) counts = {} links = [] for pid, kind, ref in ProjectItem.objects.filter(project__user=request.user).values_list('project_id', 'kind', 'ref'): counts[pid] = counts.get(pid, 0) + 1 if ref is not None: links.append({'project': pid, 'kind': kind, 'ref': ref}) return JsonResponse({'projects': [_project_json(p, counts) for p in rows], 'links': links, 'presets': [k for k, _ in Project.PRESETS]}) @csrf_exempt @require_http_methods(['POST']) def create(request): err = _auth(request) if err: return err body = _body(request) blob = body and _blob(body) if not blob: return JsonResponse({'error': 'data_ct, data_iv required'}, status=400) preset = body.get('preset', Project.RESEARCH) if preset not in dict(Project.PRESETS): return JsonResponse({'error': 'unknown preset'}, status=400) if Project.objects.filter(user=request.user).count() >= MAX_PROJECTS: return JsonResponse({'error': 'too many projects'}, status=400) p = Project.objects.create(user=request.user, preset=preset, data_ct=blob[0], data_iv=blob[1]) return JsonResponse({'project': _project_json(p)}) @csrf_exempt @require_http_methods(['GET', 'POST']) def project(request, pk): """GET: the project with its items. POST: new data (with base_iv), archived, position.""" err = _auth(request) if err: return err p = _project(request, pk) if not p: return JsonResponse({'error': 'not found'}, status=404) if request.method == 'GET': items = list(p.items.all()) return JsonResponse({'project': _project_json(p, {p.id: len(items)}), 'items': [_item_json(i) for i in items]}) body = _body(request) if body is None: return JsonResponse({'error': 'invalid JSON'}, status=400) fields = [] if 'data_ct' in body: blob = _blob(body) if not blob: return JsonResponse({'error': 'data_ct, data_iv required'}, status=400) base = body.get('base_iv') if base and base != p.data_iv: return JsonResponse({'error': 'stale', 'project': _project_json(p)}, status=409) p.data_ct, p.data_iv = blob fields += ['data_ct', 'data_iv'] if 'archived' in body: p.archived = bool(body['archived']) fields.append('archived') if 'position' in body: try: p.position = int(body['position']) except (TypeError, ValueError): return JsonResponse({'error': 'position'}, status=400) fields.append('position') if fields: p.save(update_fields=fields + ['updated_at']) return JsonResponse({'project': _project_json(p)}) @csrf_exempt @require_http_methods(['POST']) def delete(request, pk): err = _auth(request) if err: return err p = _project(request, pk) if not p: return JsonResponse({'error': 'not found'}, status=404) p.delete() return JsonResponse({'ok': True}) # ---- items --------------------------------------------------------------------------------- @csrf_exempt @require_http_methods(['POST']) def item_add(request, pk): """{kind, ref?, data_ct, data_iv}. A book, episode or article is in a project once: adding it again answers with the item there (existing: true).""" err = _auth(request) if err: return err p = _project(request, pk) if not p: return JsonResponse({'error': 'not found'}, status=404) body = _body(request) blob = body and _blob(body) if not blob: return JsonResponse({'error': 'data_ct, data_iv required'}, status=400) kind = body.get('kind') if kind not in dict(ProjectItem.KINDS): return JsonResponse({'error': 'kind'}, status=400) ref = body.get('ref') if kind in ProjectItem.LINKED: if not isinstance(ref, int) or isinstance(ref, bool) or ref <= 0: return JsonResponse({'error': 'ref required'}, status=400) else: ref = None if p.items.count() >= MAX_ITEMS: return JsonResponse({'error': 'too many items'}, status=400) if ref is not None: have = p.items.filter(kind=kind, ref=ref).first() if have: return JsonResponse({'item': _item_json(have), 'existing': True}) try: with transaction.atomic(): i = ProjectItem.objects.create(project=p, kind=kind, ref=ref, data_ct=blob[0], data_iv=blob[1]) except IntegrityError: return JsonResponse({'item': _item_json(p.items.get(kind=kind, ref=ref)), 'existing': True}) p.save(update_fields=['updated_at']) return JsonResponse({'item': _item_json(i), 'existing': False}) def _item(request, pk): return ProjectItem.objects.filter(pk=pk, project__user=request.user).select_related('project').first() @csrf_exempt @require_http_methods(['POST']) def item_update(request, pk): """{data_ct, data_iv, base_iv}: a changed note or source; 409 with the current one when it was changed elsewhere since.""" err = _auth(request) if err: return err i = _item(request, pk) if not i: return JsonResponse({'error': 'not found'}, status=404) body = _body(request) blob = body and _blob(body) if not blob: return JsonResponse({'error': 'data_ct, data_iv required'}, status=400) base = body.get('base_iv') if base and base != i.data_iv: return JsonResponse({'error': 'stale', 'item': _item_json(i)}, status=409) i.data_ct, i.data_iv = blob i.save(update_fields=['data_ct', 'data_iv', 'updated_at']) i.project.save(update_fields=['updated_at']) return JsonResponse({'item': _item_json(i)}) @csrf_exempt @require_http_methods(['POST']) def item_delete(request, pk): err = _auth(request) if err: return err i = _item(request, pk) if not i: return JsonResponse({'error': 'not found'}, status=404) i.delete() return JsonResponse({'ok': True}) # ---- a paper's data by its DOI ---------------------------------------------------------------- DOI_RE = re.compile(r'(10\.\d{4,9}/[^\s"<>]+)', re.I) def doi_of(text): """The DOI in a DOI, a doi.org link, a publisher's link that carries it, or an arXiv id.""" text = (text or '').strip() m = DOI_RE.search(text) if m: return m.group(1).rstrip('.,;)') m = re.search(r'arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})|^(?:arxiv:)?(\d{4}\.\d{4,5})(?:v\d+)?$', text, re.I) if m: return '10.48550/arXiv.' + (m.group(1) or m.group(2)) return None @require_http_methods(['GET']) def doi(request): """?q=: the work's citation data (CSL-JSON) from doi.org — Crossref, DataCite (arXiv) and the other registries answer there. Not kept beyond a cache.""" err = _auth(request) if err: return err d = doi_of(request.GET.get('q', '')) if not d: return JsonResponse({'error': 'Keine DOI erkannt.'}, status=400) key = 'doi:' + re.sub(r'\W+', '_', d.lower())[:200] csl = cache.get(key) if csl is None: try: r = requests.get('https://doi.org/' + d, timeout=15, headers={'Accept': 'application/vnd.citationstyles.csl+json', 'User-Agent': 'diora (https://diora.creamfresh.xyz)'}) except requests.RequestException: return JsonResponse({'error': 'doi.org nicht erreichbar.'}, status=502) if r.status_code == 404: return JsonResponse({'error': 'DOI unbekannt.'}, status=404) try: csl = r.json() except ValueError: return JsonResponse({'error': 'Keine Daten zu dieser DOI.'}, status=502) if not isinstance(csl, dict): return JsonResponse({'error': 'Keine Daten zu dieser DOI.'}, status=502) # What a citation needs; the reference lists and licences stay out. for k in ('reference', 'license', 'link', 'relation', 'assertion', 'funder', 'content-domain', 'indexed', 'deposited', 'update-policy', 'resource'): csl.pop(k, None) cache.set(key, csl, 7 * 24 * 3600) return JsonResponse({'doi': d, 'csl': csl})