7 import simplejson as json
11 from urllib.parse import urlsplit
12 from Crypto.PublicKey import RSA
13 from cachetools import LFUCache
15 from . import app, CONFIG
16 from .database import DATABASE
17 from .http_debug import http_debug
18 from .remote_actor import fetch_actor
19 from .http_signatures import sign_headers, generate_body_digest
22 # generate actor keys if not present
23 if "actorKeys" not in DATABASE:
24 logging.info("No actor keys present, generating 4096-bit RSA keypair.")
26 privkey = RSA.generate(4096)
27 pubkey = privkey.publickey()
29 DATABASE["actorKeys"] = {
30 "publicKey": pubkey.exportKey('PEM').decode('utf-8'),
31 "privateKey": privkey.exportKey('PEM').decode('utf-8')
35 PRIVKEY = RSA.importKey(DATABASE["actorKeys"]["privateKey"])
36 PUBKEY = PRIVKEY.publickey()
37 AP_CONFIG = CONFIG['ap']
38 CACHE_SIZE = CONFIG.get('cache-size', 16384)
39 CACHE = LFUCache(CACHE_SIZE)
41 sem = asyncio.Semaphore(500)
44 async def actor(request):
46 "@context": "https://www.w3.org/ns/activitystreams",
48 "sharedInbox": "https://{}/inbox".format(request.host)
50 "followers": "https://{}/followers".format(request.host),
51 "following": "https://{}/following".format(request.host),
52 "inbox": "https://{}/inbox".format(request.host),
53 "name": "ActivityRelay",
54 "type": "Application",
55 "id": "https://{}/actor".format(request.host),
57 "id": "https://{}/actor#main-key".format(request.host),
58 "owner": "https://{}/actor".format(request.host),
59 "publicKeyPem": DATABASE["actorKeys"]["publicKey"]
61 "summary": "ActivityRelay bot",
62 "preferredUsername": "relay",
63 "url": "https://{}/actor".format(request.host)
65 return aiohttp.web.json_response(data, content_type='application/activity+json')
68 app.router.add_get('/actor', actor)
69 get_actor_inbox = lambda actor: actor.get('endpoints', {}).get('sharedInbox', actor['inbox'])
72 async def push_message_to_actor(actor, message, our_key_id):
73 inbox = get_actor_inbox(actor)
77 data = json.dumps(message)
79 '(request-target)': 'post {}'.format(url.path),
80 'Content-Length': str(len(data)),
81 'Content-Type': 'application/activity+json',
82 'User-Agent': 'ActivityRelay',
84 'Digest': 'SHA-256={}'.format(generate_body_digest(data)),
85 'Date': datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
87 headers['signature'] = sign_headers(headers, PRIVKEY, our_key_id)
88 headers.pop('(request-target)')
91 logging.debug('%r >> %r', inbox, message)
96 async with aiohttp.ClientSession(trace_configs=[http_debug()]) as session:
97 async with session.post(inbox, data=data, headers=headers) as resp:
98 if resp.status == 202:
100 resp_payload = await resp.text()
101 logging.debug('%r >> resp %r', inbox, resp_payload)
102 except Exception as e:
103 logging.info('Caught %r while pushing to %r.', e, inbox)
106 async def fetch_nodeinfo(domain):
107 headers = {'Accept': 'application/activity+json'}
110 wk_nodeinfo = await fetch_actor(f'https://{domain}/.well-known/nodeinfo', headers=headers)
115 for link in wk_nodeinfo.get('links', ''):
116 if link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/2.0':
117 nodeinfo_url = link['href']
123 nodeinfo_data = await fetch_actor(nodeinfo_url, headers=headers)
124 software = nodeinfo_data.get('software')
126 return software.get('name') if software else None
129 async def follow_remote_actor(actor_uri):
130 actor = await fetch_actor(actor_uri)
133 logging.info('failed to fetch actor at: %r', actor_uri)
136 if AP_CONFIG['whitelist_enabled'] is True and urlsplit(actor_uri).hostname not in AP_CONFIG['whitelist']:
137 logging.info('refusing to follow non-whitelisted actor: %r', actor_uri)
140 logging.info('following: %r', actor_uri)
143 "@context": "https://www.w3.org/ns/activitystreams",
146 "object": actor['id'],
147 "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
148 "actor": "https://{}/actor".format(AP_CONFIG['host'])
150 await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))
153 async def unfollow_remote_actor(actor_uri):
154 actor = await fetch_actor(actor_uri)
156 logging.info('failed to fetch actor at: %r', actor_uri)
159 logging.info('unfollowing: %r', actor_uri)
162 "@context": "https://www.w3.org/ns/activitystreams",
168 "actor": actor['id'],
169 "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4())
171 "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
172 "actor": "https://{}/actor".format(AP_CONFIG['host'])
174 await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))
177 tag_re = re.compile(r'(<!--.*?-->|<[^>]*>)')
178 def strip_html(data):
179 no_tags = tag_re.sub('', data)
180 return cgi.escape(no_tags)
183 def distill_inboxes(actor, object_id):
186 origin_hostname = urlsplit(object_id).hostname
188 inbox = get_actor_inbox(actor)
189 targets = [target for target in DATABASE.get('relay-list', []) if target != inbox]
190 targets = [target for target in targets if urlsplit(target).hostname != origin_hostname]
191 hostnames = [urlsplit(target).hostname for target in targets]
193 assert inbox not in targets
194 assert origin_hostname not in hostnames
199 def distill_object_id(activity):
200 logging.debug('>> determining object ID for %r', activity['object'])
201 obj = activity['object']
203 if isinstance(obj, str):
209 async def handle_relay(actor, data, request):
212 object_id = distill_object_id(data)
214 if object_id in CACHE:
215 logging.debug('>> already relayed %r as %r', object_id, CACHE[object_id])
218 activity_id = "https://{}/activities/{}".format(request.host, uuid.uuid4())
221 "@context": "https://www.w3.org/ns/activitystreams",
223 "to": ["https://{}/followers".format(request.host)],
224 "actor": "https://{}/actor".format(request.host),
229 logging.debug('>> relay: %r', message)
231 inboxes = distill_inboxes(actor, object_id)
233 futures = [push_message_to_actor({'inbox': inbox}, message, 'https://{}/actor#main-key'.format(request.host)) for inbox in inboxes]
234 asyncio.ensure_future(asyncio.gather(*futures))
236 CACHE[object_id] = activity_id
239 async def handle_forward(actor, data, request):
240 object_id = distill_object_id(data)
242 logging.debug('>> Relay %r', data)
244 inboxes = distill_inboxes(actor, object_id)
247 push_message_to_actor(
250 'https://{}/actor#main-key'.format(request.host))
251 for inbox in inboxes]
252 asyncio.ensure_future(asyncio.gather(*futures))
255 async def handle_follow(actor, data, request):
258 following = DATABASE.get('relay-list', [])
259 inbox = get_actor_inbox(actor)
262 if urlsplit(inbox).hostname in AP_CONFIG['blocked_instances']:
265 if inbox not in following:
267 DATABASE['relay-list'] = following
269 asyncio.ensure_future(follow_remote_actor(actor['id']))
272 "@context": "https://www.w3.org/ns/activitystreams",
275 "actor": "https://{}/actor".format(request.host),
277 # this is wrong per litepub, but mastodon < 2.4 is not compliant with that profile.
281 "object": "https://{}/actor".format(request.host),
285 "id": "https://{}/activities/{}".format(request.host, uuid.uuid4()),
288 asyncio.ensure_future(push_message_to_actor(actor, message, 'https://{}/actor#main-key'.format(request.host)))
291 async def handle_undo(actor, data, request):
294 child = data['object']
295 if child['type'] == 'Follow':
296 following = DATABASE.get('relay-list', [])
298 inbox = get_actor_inbox(actor)
300 if inbox in following:
301 following.remove(inbox)
302 DATABASE['relay-list'] = following
304 await unfollow_remote_actor(actor['id'])
308 'Announce': handle_relay,
309 'Create': handle_relay,
310 'Delete': handle_forward,
311 'Follow': handle_follow,
313 'Update': handle_forward,
317 async def inbox(request):
318 data = await request.json()
319 instance = urlsplit(data['actor']).hostname
321 if AP_CONFIG['blocked_software']:
322 software = await fetch_nodeinfo(instance)
324 if software and software.lower() in AP_CONFIG['blocked_software']:
325 raise aiohttp.web.HTTPUnauthorized(body='relays have been blocked', content_type='text/plain')
327 if 'actor' not in data or not request['validated']:
328 raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
330 elif data['type'] != 'Follow' and 'https://{}/inbox'.format(instance) not in DATABASE['relay-list']:
331 raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
333 elif AP_CONFIG['whitelist_enabled'] is True and instance not in AP_CONFIG['whitelist']:
334 raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
336 actor = await fetch_actor(data["actor"])
337 actor_uri = 'https://{}/actor'.format(request.host)
339 logging.debug(">> payload %r", data)
341 processor = processors.get(data['type'], None)
343 await processor(actor, data, request)
345 return aiohttp.web.Response(body=b'{}', content_type='application/activity+json')
347 app.router.add_post('/inbox', inbox)