diff --git a/relay/misc.py b/relay/misc.py index a800766..8b41199 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -10,6 +10,7 @@ from Crypto.Hash import SHA, SHA256, SHA512 from Crypto.PublicKey import RSA from Crypto.Signature import PKCS1_v1_5 from aiohttp import ClientSession +from aiohttp.web import Response as AiohttpResponse from datetime import datetime from json.decoder import JSONDecodeError from urllib.parse import urlparse @@ -26,6 +27,13 @@ HASHES = { 'sha512': SHA512 } +MIMETYPES = { + 'activity': 'application/activity+json', + 'html': 'text/html', + 'json': 'application/json', + 'plain': 'text/plain' +} + NODEINFO_NS = { '20': 'http://nodeinfo.diaspora.software/ns/schema/2.0', '21': 'http://nodeinfo.diaspora.software/ns/schema/2.1' @@ -166,12 +174,12 @@ async def request(uri, data=None, force=False, sign_headers=True, activity=True) method = 'POST' if data else 'GET' action = data.get('type') if data else None headers = { - 'Accept': 'application/activity+json, application/json;q=0.9', + 'Accept': f'{MIMETYPES["activity"]}, {MIMETYPES["json"]};q=0.9', 'User-Agent': 'ActivityRelay', } if data: - headers['Content-Type'] = 'application/activity+json' if activity else 'application/json' + headers['Content-Type'] = MIMETYPES['activity' if activity else 'json'] if sign_headers: signing_headers = { @@ -219,10 +227,10 @@ async def request(uri, data=None, force=False, sign_headers=True, activity=True) return logging.verbose(f'Received error when sending {action} to {uri}: {resp.status} {resp_data}') - if resp.content_type == 'application/activity+json': + if resp.content_type == MIMETYPES['activity']: resp_data = await resp.json(loads=Message.new_from_json) - elif resp.content_type == 'application/json': + elif resp.content_type == MIMETYPES['json']: resp_data = await resp.json(loads=DotDict.new_from_json) else: @@ -453,6 +461,45 @@ class Message(DotDict): return self.object +class Response(AiohttpResponse): + @classmethod + def new(cls, body='', status=200, headers=None, ctype='text'): + kwargs = { + 'status': status, + 'headers': headers, + 'content_type': MIMETYPES[ctype] + } + + if isinstance(body, bytes): + kwargs['body'] = body + + elif isinstance(body, dict) and ctype in {'json', 'activity'}: + kwargs['text'] = json.dumps(body) + + else: + kwargs['text'] = body + + return cls(**kwargs) + + + @classmethod + def new_error(cls, status, body, ctype='plain'): + if ctype == 'json': + body = json.dumps({'status': status, 'error': body}) + + return cls(body=body, status=status, ctype=ctype) + + + @property + def location(self): + return self.headers.get('Location') + + + @location.setter + def location(self, value): + self.headers['Location'] = value + + class WKNodeinfo(DotDict): @classmethod def new(cls, v20, v21): diff --git a/relay/views.py b/relay/views.py index 787616f..852a3c6 100644 --- a/relay/views.py +++ b/relay/views.py @@ -2,16 +2,21 @@ import logging import subprocess import traceback -from aiohttp.web import HTTPForbidden, HTTPUnauthorized, Response, json_response, route - from . import __version__, misc from .http_debug import STATS -from .misc import Message, WKNodeinfo +from .misc import Message, Response, WKNodeinfo from .processors import run_processor routes = [] +try: + commit_label = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip().decode('ascii') + version = f'{__version__} {commit_label}' + +except: + version = __version__ + def register_route(method, path): def wrapper(func): @@ -21,14 +26,6 @@ def register_route(method, path): return wrapper -try: - commit_label = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip().decode('ascii') - version = f'{__version__} {commit_label}' - -except: - version = __version__ - - @register_route('GET', '/') async def home(request): targets = '
'.join(request.app.database.hostnames) @@ -55,12 +52,7 @@ a:hover {{ color: #8AF; }}

List of {count} registered instances:
{targets}

""" - return Response( - status = 200, - content_type = 'text/html', - charset = 'utf-8', - text = text - ) + return Response.new(text, ctype='html') @register_route('GET', '/inbox') @@ -71,7 +63,7 @@ async def actor(request): pubkey = request.app.database.pubkey ) - return json_response(data, content_type='application/activity+json') + return Response.new(data, ctype='activity') @register_route('POST', '/inbox') @@ -95,29 +87,29 @@ async def inbox(request): ## reject if there is no actor in the message except KeyError: logging.verbose('actor not in data') - raise HTTPUnauthorized(body='no actor in message') + return Response.new_error(400, 'no actor in message', 'json') except: traceback.print_exc() logging.verbose('Failed to parse inbox message') - raise HTTPUnauthorized(body='failed to parse message') + return Response.new_error(400, 'failed to parse message', 'json') actor = await misc.request(data.actorid) ## reject if actor is empty if not actor: logging.verbose(f'Failed to fetch actor: {data.actorid}') - raise HTTPUnauthorized('failed to fetch actor') + return Response.new_error(400, 'failed to fetch actor', 'json') ## reject if the actor isn't whitelisted while the whiltelist is enabled elif config.whitelist_enabled and not config.is_whitelisted(data.domain): logging.verbose(f'Rejected actor for not being in the whitelist: {data.actorid}') - raise HTTPForbidden(body='access denied') + return Response.new_error(403, 'access denied', 'json') ## reject if actor is banned if request.app['config'].is_banned(data.domain): logging.verbose(f'Ignored request from banned actor: {data.actorid}') - raise HTTPForbidden(body='access denied') + return Response.new_error(403, 'access denied', 'json') ## reject if software used by actor is banned if len(config.blocked_software): @@ -125,22 +117,22 @@ async def inbox(request): if config.is_banned_software(software): logging.verbose(f'Rejected actor for using specific software: {software}') - raise HTTPForbidden(body='access denied') + return Response.new_error(403, 'access denied', 'json') ## reject if the signature is invalid if not (await misc.validate_signature(data.actorid, request)): logging.verbose(f'signature validation failed for: {data.actorid}') - raise HTTPUnauthorized(body='signature check failed, signature did not match key') + return Response.new_error(401, 'signature check failed', 'json') ## reject if activity type isn't 'Follow' and the actor isn't following if data['type'] != 'Follow' and not database.get_inbox(data.domain): logging.verbose(f'Rejected actor for trying to post while not following: {data.actorid}') - raise HTTPUnauthorized(body='access denied') + return Response.new_error(401, 'access denied', 'json') logging.debug(f">> payload {data}") await run_processor(request, actor, data, software) - return Response(body=b'{}', content_type='application/activity+json') + return Response.new(status=202) @register_route('GET', '/.well-known/webfinger') @@ -148,7 +140,7 @@ async def webfinger(request): subject = request.query['resource'] if subject != f'acct:relay@{request.app.config.host}': - return json_response({'error': 'user not found'}, status=404) + return Response.new_error(404, 'user not found', 'json') data = { 'subject': subject, @@ -159,7 +151,7 @@ async def webfinger(request): ] } - return json_response(data) + return Response.new(data, ctype='json') @register_route('GET', '/nodeinfo/{version:\d.\d\.json}') @@ -191,7 +183,7 @@ async def nodeinfo_2_0(request): if version == '2.1': data['software']['repository'] = 'https://git.pleroma.social/pleroma/relay' - return json_response(data) + return Response.new(data, ctype='json') @register_route('GET', '/.well-known/nodeinfo') @@ -201,9 +193,9 @@ async def nodeinfo_wellknown(request): v21 = f'https://{request.app.config.host}/nodeinfo/2.1.json' ) - return json_response(data) + return Response.new(data, ctype='json') @register_route('GET', '/stats') async def stats(request): - return json_response(STATS) + return Response.new(STATS, ctype='json')