import json from json.decoder import JSONDecodeError import logging from aiohttp.web import HTTPUnauthorized, Request from relay import __version__, misc from relay.http_debug import STATS from relay.misc import ( DotDict, Message, Response, WKNodeinfo, ) from relay.processors import run_processor routes = [] def register_route(method, path): def wrapper(func): routes.append([method, path, func]) return func return wrapper @register_route("GET", "/") async def home(request): following = "" following_count = len(request.app.database.hostnames) requested = "" requested_count = len(request.app.database["follow-requests"]) note = request.app.config.note host = request.app.config.host text = f"""\ ActivityPub Relay at {host}

This is an Activity Relay for fediverse instances.

{note}

To host your own relay, you may download the code at this address: https://git.arrdem.com/arrdem/source/src/branch/trunk/projects/activitypub_relay


This relay is peered with {following_count} registered instances:
{following}


Another {requested_count} peers await approval:
{requested}

""" return Response.new(text, ctype="html") @register_route("GET", "/inbox") @register_route("GET", "/actor") async def actor(request): data = Message.new_actor( host=request.app.config.host, pubkey=request.app.database.pubkey ) return Response.new(data, ctype="activity") @register_route("POST", "/inbox") @register_route("POST", "/actor") async def inbox(request): config = request.app.config # reject if missing signature header if "signature" not in request.headers: logging.debug("Actor missing signature header") raise HTTPUnauthorized(body="missing signature") # read message and get actor id and domain try: data = await request.json(loads=Message.new_from_json) if "actor" not in data: raise KeyError("actor") # reject if there is no actor in the message except KeyError: logging.debug("actor not in data") return Response.new_error(400, "no actor in message", "json") except: logging.exception("Failed to parse inbox message") return Response.new_error(400, "failed to parse message", "json") # FIXME: A lot of this code assumes that we're going to have access to the entire actor descriptor. # This isn't something we actually generally need, and it's not clear how used it is. # The original relay implementation mostly used to determine activity source domain for relaying. # This has been refactored out, since there are other sources of this information. # This PROBABLY means we can do without this data ever ... but here it is. # Trying to deal with actors/visibility if isinstance(data.object, dict) and not data.object.get("discoverable", True): actor = DotDict({"id": "dummy-for-undiscoverable-object"}) # Normal path of looking up the actor... else: try: # FIXME: Needs a cache actor = await misc.request(data.actorid) except: logging.exception(f"{request.id}: {data!r}") return logging.debug(f"Inbox >> {data!r}") # reject if actor is empty if not actor: logging.debug(f"Failed to fetch actor: {data.actorid}") return Response.new_error(400, "failed to fetch actor", "json") # Reject if the actor isn't whitelisted while the whiltelist is enabled # An exception is made for follow requests, which we want to enqueue not reject out of hand elif config.whitelist_enabled and not config.is_whitelisted(data.domain) and data["type"] != "Follow": logging.debug( f"Rejected actor for not being in the whitelist: {data.actorid}" ) return Response.new_error(403, "access denied", "json") # reject if actor is banned if request.app["config"].is_banned(data.domain): logging.debug(f"Ignored request from banned actor: {data.actorid}") return Response.new_error(403, "access denied", "json") # FIXME: Needs a cache software = await misc.fetch_nodeinfo(data.domain) # reject if software used by actor is banned if config.blocked_software: if config.is_banned_software(software): logging.debug(f"Rejected actor for using specific software: {software}") return Response.new_error(403, "access denied", "json") # reject if the signature is invalid if not (await misc.validate_signature(data.actorid, request)): logging.debug(f"signature validation failed for: {data.actorid}") return Response.new_error(401, "signature check failed", "json") logging.debug(f">> payload {data}") resp = await run_processor(request, actor, data, software) return resp or Response.new(status=202) @register_route("GET", "/.well-known/webfinger") async def webfinger(request): if not (subject := request.query.get("resource")): return Response.new_error(404, "no resource specified", "json") if subject != f"acct:relay@{request.app.config.host}": return Response.new_error(404, "user not found", "json") data = { "subject": subject, "aliases": [request.app.config.actor], "links": [ { "href": request.app.config.actor, "rel": "self", "type": "application/activity+json", }, { "href": request.app.config.actor, "rel": "self", "type": 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', }, ], } return Response.new(data, ctype="json") @register_route("GET", "/nodeinfo/{version:\d.\d\.json}") async def nodeinfo_2_0(request): niversion = request.match_info["version"][:3] data = { "openRegistrations": not request.app.config.whitelist_enabled, "protocols": ["activitypub"], "services": {"inbound": [], "outbound": []}, "software": {"name": "activityrelay", "version": __version__}, "usage": {"localPosts": 0, "users": {"total": 1}}, "metadata": {"peers": request.app.database.hostnames}, "version": niversion, } if niversion == "2.1": data["software"]["repository"] = "https://git.pleroma.social/pleroma/relay" return Response.new(data, ctype="json") @register_route("GET", "/.well-known/nodeinfo") async def nodeinfo_wellknown(request): data = WKNodeinfo.new( v20=f"https://{request.app.config.host}/nodeinfo/2.0.json", v21=f"https://{request.app.config.host}/nodeinfo/2.1.json", ) return Response.new(data, ctype="json") @register_route("GET", "/stats") async def stats(request): stats = STATS.copy() stats["pending_requests"] = len(request.app.database.get("follow-requests", {})) return Response.new(stats, ctype="json") @register_route("POST", "/admin/config") async def set_config(request: Request): if not (auth := request.headers.get("Authorization")): return Response.new_error(403, "access denied", "json") if not auth == f"Bearer {request.app.config.admin_token}": return Response.new_error(403, "access denied", "json") # FIXME: config doesn't have a way to go from JSON or update, using dict stuff text = await request.text() try: new_config = json.loads(text) except JSONDecodeError as e: logging.exception(f"Unable to load config {text!r}") return Response.new_error(400, "bad request", "json") request.app.config.update(new_config) if request.app.config.whitelist_enabled: # If there are pending follows which are NOW whitelisted, allow them for domain in request.app.config.whitelist: if (pending_follow := request.app.database.get_request(domain, False)): logging.info(f"Acknowledging queued follow request from {domain}...") await misc.request( pending_follow["inbox"], misc.Message.new_response( host=request.app.config.host, actor=pending_follow["actor"], followid=pending_follow["followid"], accept=True ), ) await misc.request( pending_follow["inbox"], misc.Message.new_follow( host=request.app.config.host, actor=pending_follow["actor"] ), ) request.app.database.del_request(domain) # FIXME: If there are EXISTING follows which are NO LONGER allowed/are blacklisted, drop them request.app.database.save() request.app.config.save() return Response.new(status=202) @register_route("GET", "/admin/config") def get_config(request: Request): if not (auth := request.headers.get("Authorization")): return Response.new_error(403, "access denied", "json") if not auth == f"Bearer {request.app.config.admin_token}": return Response.new_error(403, "access denied", "json") return Response.new(request.app.config, status=200, ctype="json") @register_route("GET", "/admin/db") def get_db(request: Request): if not (auth := request.headers.get("Authorization")): return Response.new_error(403, "access denied", "json") if not auth == f"Bearer {request.app.config.admin_token}": return Response.new_error(403, "access denied", "json") return Response.new(request.app.database, status=200, ctype="json") @register_route("GET", "/admin/pending") def get_pending(request): if not (auth := request.headers.get("Authorization")): return Response.new_error(403, "access denied", "json") if not auth == f"Bearer {request.app.config.admin_token}": return Response.new_error(403, "access denied", "json") return Response.new(request.app.database["follow-requests"], status=200, ctype="json")