288 lines
10 KiB
Python
288 lines
10 KiB
Python
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 = "<ul>" + ("\n".join(f"<li>{it}</li>" for it in request.app.database.hostnames)) + "</ul>"
|
|
following_count = len(request.app.database.hostnames)
|
|
requested = "<ul>" + ("\n".join(f"<li>{it}</li>" for it in request.app.database["follow-requests"])) + "</ul>"
|
|
requested_count = len(request.app.database["follow-requests"])
|
|
note = request.app.config.note
|
|
host = request.app.config.host
|
|
|
|
text = f"""\
|
|
<html><head>
|
|
<title>ActivityPub Relay at {host}</title>
|
|
<style>
|
|
body {{ background-color: #000000; color: #FFFFFF; font-family: monospace, arial; font-size: 100%; }}
|
|
a {{ color: #26F; }}
|
|
a:visited {{ color: #46C; }}
|
|
a:hover {{ color: #8AF; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<p>This is an Activity Relay for fediverse instances.</p>
|
|
<p>{note}</p>
|
|
<p>To host your own relay, you may download the code at this address: <a href="https://git.arrdem.com/arrdem/source/src/branch/trunk/projects/activitypub_relay">https://git.arrdem.com/arrdem/source/src/branch/trunk/projects/activitypub_relay</a></p>
|
|
<br><p>This relay is peered with {following_count} registered instances:<br>{following}</p>
|
|
<br><p>Another {requested_count} peers await approval:<br>{requested}</p>
|
|
</body></html>"""
|
|
|
|
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/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")
|