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