285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""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=<DOI, link or arXiv id>: 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})
|