relay: add http request debugger
[relay.git/.git] / relay / actor.py
1 import aiohttp
2 import aiohttp.web
3 import asyncio
4 import logging
5 import uuid
6 import re
7 import urllib.parse
8 import simplejson as json
9 import cgi
10 from Crypto.PublicKey import RSA
11 from .database import DATABASE
12 from .http_debug import http_debug
13
14
15 # generate actor keys if not present
16 if "actorKeys" not in DATABASE:
17     logging.info("No actor keys present, generating 4096-bit RSA keypair.")
18
19     privkey = RSA.generate(4096)
20     pubkey = privkey.publickey()
21
22     DATABASE["actorKeys"] = {
23         "publicKey": pubkey.exportKey('PEM'),
24         "privateKey": privkey.exportKey('PEM')
25     }
26
27
28 PRIVKEY = RSA.importKey(DATABASE["actorKeys"]["privateKey"])
29 PUBKEY = PRIVKEY.publickey()
30
31
32 from . import app, CONFIG
33 from .remote_actor import fetch_actor
34
35
36 AP_CONFIG = CONFIG.get('ap', {'host': 'localhost','blocked_instances':[]})
37
38
39 async def actor(request):
40     data = {
41         "@context": "https://www.w3.org/ns/activitystreams",
42         "endpoints": {
43             "sharedInbox": "https://{}/inbox".format(request.host)
44         },
45         "followers": "https://{}/followers".format(request.host),
46         "following": "https://{}/following".format(request.host),
47         "inbox": "https://{}/inbox".format(request.host),
48         "sharedInbox": "https://{}/inbox".format(request.host),
49         "name": "ActivityRelay",
50         "type": "Application",
51         "id": "https://{}/actor".format(request.host),
52         "publicKey": {
53             "id": "https://{}/actor#main-key".format(request.host),
54             "owner": "https://{}/actor".format(request.host),
55             "publicKeyPem": DATABASE["actorKeys"]["publicKey"]
56         },
57         "summary": "ActivityRelay bot",
58         "preferredUsername": "relay",
59         "url": "https://{}/actor".format(request.host)
60     }
61     return aiohttp.web.json_response(data)
62
63
64 app.router.add_get('/actor', actor)
65
66
67 from .http_signatures import sign_headers
68
69
70 get_actor_inbox = lambda actor: actor.get('endpoints', {}).get('sharedInbox', actor['inbox'])
71
72
73 async def push_message_to_actor(actor, message, our_key_id):
74     inbox = get_actor_inbox(actor)
75
76     url = urllib.parse.urlsplit(inbox)
77
78     # XXX: Digest
79     data = json.dumps(message)
80     headers = {
81         '(request-target)': 'post {}'.format(url.path),
82         'Content-Length': str(len(data)),
83         'Content-Type': 'application/activity+json',
84         'User-Agent': 'ActivityRelay'
85     }
86     headers['signature'] = sign_headers(headers, PRIVKEY, our_key_id)
87
88     logging.debug('%r >> %r', inbox, message)
89
90     async with aiohttp.ClientSession(trace_configs=[http_debug()]) as session:
91         async with session.post(inbox, data=data, headers=headers) as resp:
92             resp_payload = await resp.text()
93             logging.debug('%r >> resp %r', inbox, resp_payload)
94
95
96 async def follow_remote_actor(actor_uri):
97     logging.info('following: %r', actor_uri)
98
99     actor = await fetch_actor(actor_uri)
100
101     message = {
102         "@context": "https://www.w3.org/ns/activitystreams",
103         "type": "Follow",
104         "to": [actor['id']],
105         "object": actor['id'],
106         "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
107         "actor": "https://{}/actor".format(AP_CONFIG['host'])
108     }
109     await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))
110
111
112 async def unfollow_remote_actor(actor_uri):
113     logging.info('unfollowing: %r', actor_uri)
114
115     actor = await fetch_actor(actor_uri)
116
117     message = {
118         "@context": "https://www.w3.org/ns/activitystreams",
119         "type": "Undo",
120         "to": [actor['id']],
121         "object": {
122              "type": "Follow",
123              "object": actor_uri,
124              "actor": actor['id'],
125              "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4())
126         },
127         "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
128         "actor": "https://{}/actor".format(AP_CONFIG['host'])
129     }
130     await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))
131
132
133 tag_re = re.compile(r'(<!--.*?-->|<[^>]*>)')
134 def strip_html(data):
135     no_tags = tag_re.sub('', data)
136     return cgi.escape(no_tags)
137
138
139 def distill_inboxes(actor):
140     global DATABASE
141
142     inbox = get_actor_inbox(actor)
143     targets = [target for target in DATABASE.get('relay-list', []) if target != inbox]
144
145     assert inbox not in targets
146
147     return targets
148
149
150 def distill_object_id(activity):
151     logging.debug('>> determining object ID for %r', activity['object'])
152     obj = activity['object']
153
154     if isinstance(obj, str):
155         return obj
156
157     return obj['id']
158
159
160 async def handle_relay(actor, data, request):
161     object_id = distill_object_id(data)
162
163     # don't relay mastodon announces -- causes LRP fake direction issues
164     if data['type'] == 'Announce' and len(data.get('cc', [])) > 0:
165         return
166
167     message = {
168         "@context": "https://www.w3.org/ns/activitystreams",
169         "type": "Announce",
170         "to": ["https://{}/actor/followers".format(request.host)],
171         "actor": "https://{}/actor".format(request.host),
172         "object": object_id,
173         "id": "https://{}/activities/{}".format(request.host, uuid.uuid4())
174     }
175
176     logging.debug('>> relay: %r', message)
177
178     inboxes = distill_inboxes(actor)
179
180     futures = [push_message_to_actor({'inbox': inbox}, message, 'https://{}/actor#main-key'.format(request.host)) for inbox in inboxes]
181     asyncio.ensure_future(asyncio.gather(*futures))
182
183
184 async def handle_follow(actor, data, request):
185     global DATABASE
186
187     following = DATABASE.get('relay-list', [])
188     inbox = get_actor_inbox(actor)
189
190     if urllib.parse.urlsplit(inbox).hostname in AP_CONFIG['blocked_instances']:
191         return
192
193     if inbox not in following:
194         following += [inbox]
195         DATABASE['relay-list'] = following
196
197     message = {
198         "@context": "https://www.w3.org/ns/activitystreams",
199         "type": "Accept",
200         "to": [actor["id"]],
201         "actor": "https://{}/actor".format(request.host),
202
203         # this is wrong per litepub, but mastodon < 2.4 is not compliant with that profile.
204         "object": {
205              "type": "Follow",
206              "id": data["id"],
207              "object": "https://{}/actor".format(request.host),
208              "actor": actor["id"]
209         },
210
211         "id": "https://{}/activities/{}".format(request.host, uuid.uuid4()),
212     }
213
214     asyncio.ensure_future(push_message_to_actor(actor, message, 'https://{}/actor#main-key'.format(request.host)))
215
216     if data['object'].endswith('/actor'):
217         asyncio.ensure_future(follow_remote_actor(actor['id']))
218
219
220 async def handle_undo(actor, data, request):
221     global DATABASE
222
223     child = data['object']
224     if child['type'] == 'Follow':
225         following = DATABASE.get('relay-list', [])
226
227         inbox = get_actor_inbox(actor)
228
229         if inbox in following:
230             following.remove(inbox)
231             DATABASE['relay-list'] = following
232
233         if child['object'].endswith('/actor'):
234             await unfollow_remote_actor(actor['id'])
235
236
237 processors = {
238     'Announce': handle_relay,
239     'Create': handle_relay,
240     'Follow': handle_follow,
241     'Undo': handle_undo
242 }
243
244
245 async def inbox(request):
246     data = await request.json()
247
248     if 'actor' not in data or not request['validated']:
249         raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
250
251     actor = await fetch_actor(data["actor"])
252     actor_uri = 'https://{}/actor'.format(request.host)
253
254     logging.debug(">> payload %r", data)
255
256     processor = processors.get(data['type'], None)
257     if processor:
258         await processor(actor, data, request)
259
260     return aiohttp.web.Response(body=b'{}', content_type='application/activity+json')
261
262
263 app.router.add_post('/inbox', inbox)