diff --git a/lemonauth/tokens.py b/lemonauth/tokens.py index 949acd5..f4bfcfa 100644 --- a/lemonauth/tokens.py +++ b/lemonauth/tokens.py @@ -3,23 +3,18 @@ from jose import jwt from datetime import datetime, timedelta from django.contrib.auth import get_user_model from django.conf import settings -from django.http import HttpResponse + +from micropub.views import error def auth(request): if 'HTTP_AUTHORIZATION' in request.META: auth = request.META.get('HTTP_AUTHORIZATION').split(' ') if auth[0] != 'Bearer': - return HttpResponse( - 'authorisation with {0} not supported'.format(auth[0]), - content_type='text/plain', - status=400 - ) + return error.bad_req('auth type {0} not supported'.format(auth[0])) if len(auth) != 2: - return HttpResponse( - 'invalid Bearer auth format, must be Bearer ', - content_type='text/plain', - status=400 + return error.bad_req( + 'invalid Bearer auth format, must be Bearer ' ) token = auth[1] elif 'access_token' in request.POST: @@ -27,20 +22,12 @@ def auth(request): elif 'access_token' in request.GET: token = request.GET.get('access_token') else: - return HttpResponse( - 'authorisation required', - content_type='text/plain', - status=401 - ) + return error.unauthorized() try: token = decode(token) except Exception as e: - return HttpResponse( - 'invalid micropub token', - content_type='text/plain', - status=403, - ) + return error.forbidden() return MicropubToken(token) diff --git a/micropub/views.py b/micropub/views.py deleted file mode 100644 index 21d23e8..0000000 --- a/micropub/views.py +++ /dev/null @@ -1,102 +0,0 @@ -import json -from django.http import HttpResponse -from django.urls import reverse -from django.utils.decorators import method_decorator -from django.views import View -from django.views.decorators.csrf import csrf_exempt -from urllib.parse import urljoin - -from entries.jobs import ping_hub, send_mentions -from entries.models import Cat, Entry -from entries.kinds import Article, Note, Reply, Like, Repost -from lemoncurry import utils -from lemonauth import tokens - - -def form_to_mf2(request): - properties = {} - post = request.POST - for key in post.keys(): - if key.endswith('[]'): - key = key[:-2] - if key == 'access_token': - continue - properties[key] = post.getlist(key) + post.getlist(key + '[]') - - type = [] - if 'h' in properties: - type = ['h-' + p for p in properties['h']] - del properties['h'] - return {'type': type, 'properties': properties} - - -@method_decorator(csrf_exempt, name='dispatch') -class MicropubView(View): - def post(self, request): - token = tokens.auth(request) - if hasattr(token, 'content'): - return token - - normalise = { - 'application/json': json.load, - 'application/x-www-form-urlencoded': form_to_mf2, - } - if request.content_type not in normalise: - return HttpResponse( - 'unsupported request type {0}'.format(request.content_type), - content_type='text/plain', - status=415, - ) - body = normalise[request.content_type](request) - if 'type' not in body: - return utils.bad_req('mf2 object type required') - if body['type'] != ['h-entry']: - return utils.bad_req('only h-entry supported') - - entry = Entry(author=token.user) - props = body.get('properties', {}) - kind = Note - if 'name' in props: - entry.name = '\n'.join(props['name']) - kind = Article - if 'content' in props: - entry.content = '\n'.join( - c if isinstance(c, str) else c['html'] - for c in props['content'] - ) - if 'in-reply-to' in props: - entry.in_reply_to = props['in-reply-to'] - kind = Reply - if 'like-of' in props: - entry.like_of = props['like-of'] - kind = Like - if 'repost-of' in props: - entry.repost_of = props['repost-of'] - kind = Repost - - cats = [Cat.objects.from_name(c) for c in props.get('category', [])] - - entry.kind = kind.id - entry.save() - entry.cats = cats - entry.save() - - base = utils.origin(request) - perma = urljoin(base, entry.url) - others = [urljoin(base, url) for url in ( - reverse('home:index'), - reverse('entries:atom'), - reverse('entries:rss'), - reverse('entries:' + kind.index), - reverse('entries:' + kind.atom), - reverse('entries:' + kind.rss), - )] + [urljoin(base, cat.url) for cat in cats] - ping_hub.delay(perma, *others) - send_mentions.delay(perma) - - res = HttpResponse(status=201) - res['Location'] = perma - return res - - -micropub = MicropubView.as_view() diff --git a/micropub/views/__init__.py b/micropub/views/__init__.py new file mode 100644 index 0000000..381a98f --- /dev/null +++ b/micropub/views/__init__.py @@ -0,0 +1,20 @@ +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from lemonauth import tokens + +from .create import create +from .query import query + + +@csrf_exempt +@require_http_methods(['GET', 'HEAD', 'POST']) +def micropub(request): + token = tokens.auth(request) + if hasattr(token, 'content'): + return token + request.token = token + if request.method == 'POST': + return create(request) + if request.method in ('GET', 'HEAD'): + return query(request) diff --git a/micropub/views/create.py b/micropub/views/create.py new file mode 100644 index 0000000..cb5f8ed --- /dev/null +++ b/micropub/views/create.py @@ -0,0 +1,87 @@ +import json +from django.urls import reverse +from django.http import HttpResponse +from urllib.parse import urljoin + +from entries.jobs import ping_hub, send_mentions +from entries.models import Cat, Entry +from entries.kinds import Article, Note, Reply, Like, Repost +from lemoncurry import utils + +from . import error + + +def form_to_mf2(request): + properties = {} + post = request.POST + for key in post.keys(): + if key.endswith('[]'): + key = key[:-2] + if key == 'access_token': + continue + properties[key] = post.getlist(key) + post.getlist(key + '[]') + + type = [] + if 'h' in properties: + type = ['h-' + p for p in properties['h']] + del properties['h'] + return {'type': type, 'properties': properties} + + +def create(request): + normalise = { + 'application/json': json.load, + 'application/x-www-form-urlencoded': form_to_mf2, + } + if request.content_type not in normalise: + return error.unsupported_type(request.content_type) + body = normalise[request.content_type](request) + if 'type' not in body: + return error.bad_req('mf2 object type required') + if body['type'] != ['h-entry']: + return error.bad_req('only h-entry supported') + + entry = Entry(author=request.token.user) + props = body.get('properties', {}) + kind = Note + if 'name' in props: + entry.name = '\n'.join(props['name']) + kind = Article + if 'content' in props: + entry.content = '\n'.join( + c if isinstance(c, str) else c['html'] + for c in props['content'] + ) + if 'in-reply-to' in props: + entry.in_reply_to = props['in-reply-to'] + kind = Reply + if 'like-of' in props: + entry.like_of = props['like-of'] + kind = Like + if 'repost-of' in props: + entry.repost_of = props['repost-of'] + kind = Repost + + cats = [Cat.objects.from_name(c) for c in props.get('category', [])] + + entry.kind = kind.id + entry.save() + entry.cats = cats + entry.save() + + base = utils.origin(request) + perma = urljoin(base, entry.url) + others = [urljoin(base, url) for url in ( + reverse('home:index'), + reverse('entries:atom'), + reverse('entries:rss'), + reverse('entries:' + kind.index), + reverse('entries:' + kind.atom), + reverse('entries:' + kind.rss), + )] + [urljoin(base, cat.url) for cat in cats] + ping_hub.delay(perma, *others) + send_mentions.delay(perma) + + res = HttpResponse(status=201) + res['Location'] = perma + return res diff --git a/micropub/views/error.py b/micropub/views/error.py new file mode 100644 index 0000000..1c7b1a8 --- /dev/null +++ b/micropub/views/error.py @@ -0,0 +1,31 @@ +from django.http import JsonResponse + + +def forbidden(): + return res('forbidden', 403) + + +def unauthorized(): + return res('unauthorized', 401) + + +def bad_req(msg): + return res('invalid_request', msg=msg) + + +def bad_type(type): + msg = 'unsupported request type {0}'.format(type) + return res('invalid_request', 415, msg) + + +def bad_scope(scope): + return res('insufficient_scope', 401, scope=scope) + + +def res(error, status=400, msg=None, scope=None): + content = {'error': error} + if msg: + content['error_description'] = msg + if scope: + content['scope'] = scope + return JsonResponse(content, status=status) diff --git a/micropub/views/query.py b/micropub/views/query.py new file mode 100644 index 0000000..7c893ae --- /dev/null +++ b/micropub/views/query.py @@ -0,0 +1,47 @@ +from django.http import JsonResponse +from lemoncurry import requests +from . import error + + +def config(request): + config = syndicate_to(request) + return config + + +def source(request): + get = request.GET + if 'url' not in get: + return error.bad_req('must specify url parameter for source query') + mf2 = requests.mf2(get['url']).to_dict(filter_by_type='h-entry') + if not mf2: + return error.bad_req('no h-entry at the requested url') + entry = mf2[0] + keys = get.getlist('properties', []) + get.getlist('properties[]', []) + if not keys: + return entry + + props = entry['properties'] + return {'properties': {k: props[k] for k in keys if k in props}} + + +def syndicate_to(request): + return {'syndicate-to': []} + + +queries = { + 'config': config, + 'source': source, + 'syndicate-to': syndicate_to, +} + + +def query(request): + if 'q' not in request.GET: + return error.bad_req('must specify q parameter') + q = request.GET['q'] + if q not in queries: + return error.bad_req('unsupported query {0}'.format(q)) + res = queries[q](request) + if hasattr(res, 'content'): + return res + return JsonResponse(res)