7 import simplejson as json
9 from urllib.parse import urlsplit
10 from Crypto.PublicKey import RSA
11 from .database import DATABASE
12 from .http_debug import http_debug
14 from cachetools import LFUCache
17 # generate actor keys if not present
18 if "actorKeys" not in DATABASE:
19 logging.info("No actor keys present, generating 4096-bit RSA keypair.")
21 privkey = RSA.generate(4096)
22 pubkey = privkey.publickey()
24 DATABASE["actorKeys"] = {
25 "publicKey": pubkey.exportKey('PEM').decode('utf-8'),
26 "privateKey": privkey.exportKey('PEM').decode('utf-8')
30 PRIVKEY = RSA.importKey(DATABASE["actorKeys"]["privateKey"])
31 PUBKEY = PRIVKEY.publickey()
34 from . import app, CONFIG
35 from .remote_actor import fetch_actor
38 AP_CONFIG = CONFIG.get('ap', {
40 'blocked_instances': [],
41 'allowed_instances': [],
43 CACHE_SIZE = CONFIG.get('cache-size', 16384)
46 CACHE = LFUCache(CACHE_SIZE)
49 async def actor(request):
51 "@context": "https://www.w3.org/ns/activitystreams",
53 "sharedInbox": "https://{}/inbox".format(request.host)
55 "followers": "https://{}/followers".format(request.host),
56 "following": "https://{}/following".format(request.host),
57 "inbox": "https://{}/inbox".format(request.host),
58 "name": "ActivityRelay",
59 "type": "Application",
60 "id": "https://{}/actor".format(request.host),
62 "id": "https://{}/actor#main-key".format(request.host),
63 "owner": "https://{}/actor".format(request.host),
64 "publicKeyPem": DATABASE["actorKeys"]["publicKey"]
66 "summary": "ActivityRelay bot",
67 "preferredUsername": "relay",
68 "url": "https://{}/actor".format(request.host)
70 return aiohttp.web.json_response(data)
73 app.router.add_get('/actor', actor)
76 from .http_signatures import sign_headers
79 get_actor_inbox = lambda actor: actor.get('endpoints', {}).get('sharedInbox', actor['inbox'])
82 async def push_message_to_actor(actor, message, our_key_id):
83 inbox = get_actor_inbox(actor)
88 data = json.dumps(message)
90 '(request-target)': 'post {}'.format(url.path),
91 'Content-Length': str(len(data)),
92 'Content-Type': 'application/activity+json',
93 'User-Agent': 'ActivityRelay'
95 headers['signature'] = sign_headers(headers, PRIVKEY, our_key_id)
96 headers.pop('(request-target)')
98 logging.debug('%r >> %r', inbox, message)
101 async with aiohttp.ClientSession(trace_configs=[http_debug()]) as session:
102 async with session.post(inbox, data=data, headers=headers) as resp:
103 if resp.status == 202:
105 resp_payload = await resp.text()
106 logging.debug('%r >> resp %r', inbox, resp_payload)
107 except Exception as e:
108 logging.info('Caught %r while pushing to %r.', e, inbox)
111 async def follow_remote_actor(actor_uri):
112 actor = await fetch_actor(actor_uri)
114 logging.info('failed to fetch actor at: %r', actor_uri)
117 logging.info('following: %r', actor_uri)
120 "@context": "https://www.w3.org/ns/activitystreams",
123 "object": actor['id'],
124 "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
125 "actor": "https://{}/actor".format(AP_CONFIG['host'])
127 await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))
130 async def unfollow_remote_actor(actor_uri):
131 actor = await fetch_actor(actor_uri)
133 logging.info('failed to fetch actor at: %r', actor_uri)
136 logging.info('unfollowing: %r', actor_uri)
139 "@context": "https://www.w3.org/ns/activitystreams",
145 "actor": actor['id'],
146 "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4())
148 "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
149 "actor": "https://{}/actor".format(AP_CONFIG['host'])
151 await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))
154 tag_re = re.compile(r'(<!--.*?-->|<[^>]*>)')
155 def strip_html(data):
156 no_tags = tag_re.sub('', data)
157 return cgi.escape(no_tags)
160 def distill_inboxes(actor, object_id):
163 origin_hostname = urlsplit(object_id).hostname
165 inbox = get_actor_inbox(actor)
166 targets = [target for target in DATABASE.get('relay-list', []) if target != inbox]
167 targets = [target for target in targets if urlsplit(target).hostname != origin_hostname]
168 hostnames = [urlsplit(target).hostname for target in targets]
170 assert inbox not in targets
171 assert origin_hostname not in hostnames
176 def distill_object_id(activity):
177 logging.debug('>> determining object ID for %r', activity['object'])
178 obj = activity['object']
180 if isinstance(obj, str):
186 async def handle_relay(actor, data, request):
189 object_id = distill_object_id(data)
191 if object_id in CACHE:
192 logging.debug('>> already relayed %r as %r', object_id, CACHE[object_id])
195 activity_id = "https://{}/activities/{}".format(request.host, uuid.uuid4())
198 "@context": "https://www.w3.org/ns/activitystreams",
200 "to": ["https://{}/actor/followers".format(request.host)],
201 "actor": "https://{}/actor".format(request.host),
206 logging.debug('>> relay: %r', message)
208 inboxes = distill_inboxes(actor, object_id)
210 futures = [push_message_to_actor({'inbox': inbox}, message, 'https://{}/actor#main-key'.format(request.host)) for inbox in inboxes]
211 asyncio.ensure_future(asyncio.gather(*futures))
213 CACHE[object_id] = activity_id
216 async def handle_follow(actor, data, request):
219 following = DATABASE.get('relay-list', [])
220 inbox = get_actor_inbox(actor)
222 if urlsplit(inbox).hostname in AP_CONFIG['blocked_instances']:
225 if AP_CONFIG['allowed_instances'] and\
226 urlsplit(inbox).hostname not in AP_CONFIG['allowed_instances']:
229 if inbox not in following:
231 DATABASE['relay-list'] = following
233 if data['object'].endswith('/actor'):
234 asyncio.ensure_future(follow_remote_actor(actor['id']))
237 "@context": "https://www.w3.org/ns/activitystreams",
240 "actor": "https://{}/actor".format(request.host),
242 # this is wrong per litepub, but mastodon < 2.4 is not compliant with that profile.
246 "object": "https://{}/actor".format(request.host),
250 "id": "https://{}/activities/{}".format(request.host, uuid.uuid4()),
253 asyncio.ensure_future(push_message_to_actor(actor, message, 'https://{}/actor#main-key'.format(request.host)))
256 async def handle_undo(actor, data, request):
259 child = data['object']
260 if child['type'] == 'Follow':
261 following = DATABASE.get('relay-list', [])
263 inbox = get_actor_inbox(actor)
265 if inbox in following:
266 following.remove(inbox)
267 DATABASE['relay-list'] = following
269 if child['object'].endswith('/actor'):
270 await unfollow_remote_actor(actor['id'])
274 'Announce': handle_relay,
275 'Create': handle_relay,
276 'Follow': handle_follow,
281 async def inbox(request):
282 data = await request.json()
284 if 'actor' not in data or not request['validated']:
285 raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
287 actor = await fetch_actor(data["actor"])
288 actor_uri = 'https://{}/actor'.format(request.host)
290 logging.debug(">> payload %r", data)
292 processor = processors.get(data['type'], None)
294 await processor(actor, data, request)
296 return aiohttp.web.Response(body=b'{}', content_type='application/activity+json')
299 app.router.add_post('/inbox', inbox)