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