actor: implement mastodon & pleroma relay handshakes
[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 async def handle_create(actor, data, request):
139     pass
140
141
142 async def handle_follow(actor, data, request):
143     global DATABASE
144
145     following = DATABASE.get('relay-list', [])
146     inbox = get_actor_inbox(actor)
147
148     if inbox not in following:
149         following += [inbox]
150         DATABASE['relay-list'] = following
151
152     message = {
153         "@context": "https://www.w3.org/ns/activitystreams",
154         "type": "Accept",
155         "to": [actor["id"]],
156         "actor": "https://{}/actor".format(request.host),
157
158         # this is wrong per litepub, but mastodon < 2.4 is not compliant with that profile.
159         "object": {
160              "type": "Follow",
161              "id": data["id"],
162              "object": "https://{}/actor".format(request.host),
163              "actor": actor["id"]
164         },
165
166         "id": "https://{}/activities/{}".format(request.host, uuid.uuid4()),
167     }
168
169     asyncio.ensure_future(push_message_to_actor(actor, message, 'https://{}/actor#main-key'.format(request.host)))
170
171     if data['object'].endswith('/actor'):
172         asyncio.ensure_future(follow_remote_actor(actor['id']))
173
174
175 async def handle_undo(actor, data, request):
176     global DATABASE
177
178     child = data['object']
179     if child['type'] == 'Follow':
180         following = DATABASE.get('relay-list', [])
181
182         inbox = get_actor_inbox(actor)
183
184         if inbox in following:
185             following.remove(inbox)
186             DATABASE['relay-list'] = following
187
188         if child['object'].endswith('/actor'):
189             await unfollow_remote_actor(actor['id'])
190
191
192 processors = {
193     'Create': handle_create,
194     'Follow': handle_follow,
195     'Undo': handle_undo
196 }
197
198
199 async def inbox(request):
200     data = await request.json()
201
202     if 'actor' not in data or not request['validated']:
203         raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
204
205     actor = await fetch_actor(data["actor"])
206     actor_uri = 'https://{}/actor'.format(request.host)
207
208     logging.debug(">> payload %r", data)
209
210     processor = processors.get(data['type'], None)
211     if processor:
212         await processor(actor, data, request)
213
214     return aiohttp.web.Response(body=b'{}', content_type='application/activity+json')
215
216
217 app.router.add_post('/inbox', inbox)