actor.py 9.52 KB
Newer Older
kaniini's avatar
kaniini committed
1
import aiohttp
kaniini's avatar
kaniini committed
2
import aiohttp.web
3
import asyncio
kaniini's avatar
kaniini committed
4
import logging
kaniini's avatar
kaniini committed
5
import uuid
6
import re
kaniini's avatar
kaniini committed
7 8
import simplejson as json
import cgi
9
from urllib.parse import urlsplit
kaniini's avatar
kaniini committed
10 11
from Crypto.PublicKey import RSA
from .database import DATABASE
kaniini's avatar
kaniini committed
12
from .http_debug import http_debug
kaniini's avatar
kaniini committed
13

kaniini's avatar
kaniini committed
14 15
from cachetools import LFUCache

kaniini's avatar
kaniini committed
16 17 18 19 20 21 22 23 24

# generate actor keys if not present
if "actorKeys" not in DATABASE:
    logging.info("No actor keys present, generating 4096-bit RSA keypair.")

    privkey = RSA.generate(4096)
    pubkey = privkey.publickey()

    DATABASE["actorKeys"] = {
Jeong Arm's avatar
Jeong Arm committed
25 26
        "publicKey": pubkey.exportKey('PEM').decode('utf-8'),
        "privateKey": privkey.exportKey('PEM').decode('utf-8')
kaniini's avatar
kaniini committed
27 28 29
    }


kaniini's avatar
kaniini committed
30 31 32
PRIVKEY = RSA.importKey(DATABASE["actorKeys"]["privateKey"])
PUBKEY = PRIVKEY.publickey()

33
sem = asyncio.Semaphore(500)
kaniini's avatar
kaniini committed
34

kaniini's avatar
kaniini committed
35
from . import app, CONFIG
kaniini's avatar
kaniini committed
36 37
from .remote_actor import fetch_actor

kaniini's avatar
kaniini committed
38

39
AP_CONFIG = CONFIG['ap']
kaniini's avatar
kaniini committed
40 41 42 43
CACHE_SIZE = CONFIG.get('cache-size', 16384)


CACHE = LFUCache(CACHE_SIZE)
kaniini's avatar
kaniini committed
44

kaniini's avatar
kaniini committed
45 46 47 48 49 50 51 52

async def actor(request):
    data = {
        "@context": "https://www.w3.org/ns/activitystreams",
        "endpoints": {
            "sharedInbox": "https://{}/inbox".format(request.host)
        },
        "followers": "https://{}/followers".format(request.host),
kaniini's avatar
kaniini committed
53
        "following": "https://{}/following".format(request.host),
kaniini's avatar
kaniini committed
54
        "inbox": "https://{}/inbox".format(request.host),
kaniini's avatar
kaniini committed
55
        "name": "ActivityRelay",
kaniini's avatar
kaniini committed
56
        "type": "Application",
kaniini's avatar
kaniini committed
57
        "id": "https://{}/actor".format(request.host),
kaniini's avatar
kaniini committed
58 59 60 61 62
        "publicKey": {
            "id": "https://{}/actor#main-key".format(request.host),
            "owner": "https://{}/actor".format(request.host),
            "publicKeyPem": DATABASE["actorKeys"]["publicKey"]
        },
kaniini's avatar
kaniini committed
63 64
        "summary": "ActivityRelay bot",
        "preferredUsername": "relay",
65
        "url": "https://{}/actor".format(request.host)
kaniini's avatar
kaniini committed
66 67 68 69 70
    }
    return aiohttp.web.json_response(data)


app.router.add_get('/actor', actor)
kaniini's avatar
kaniini committed
71 72 73 74 75


from .http_signatures import sign_headers


76 77 78
get_actor_inbox = lambda actor: actor.get('endpoints', {}).get('sharedInbox', actor['inbox'])


kaniini's avatar
kaniini committed
79
async def push_message_to_actor(actor, message, our_key_id):
80
    inbox = get_actor_inbox(actor)
81
    url = urlsplit(inbox)
kaniini's avatar
kaniini committed
82 83 84 85 86 87 88

    # XXX: Digest
    data = json.dumps(message)
    headers = {
        '(request-target)': 'post {}'.format(url.path),
        'Content-Length': str(len(data)),
        'Content-Type': 'application/activity+json',
kaniini's avatar
kaniini committed
89
        'User-Agent': 'ActivityRelay'
kaniini's avatar
kaniini committed
90 91
    }
    headers['signature'] = sign_headers(headers, PRIVKEY, our_key_id)
92
    headers.pop('(request-target)')
kaniini's avatar
kaniini committed
93

94
    logging.debug('%r >> %r', inbox, message)
kaniini's avatar
kaniini committed
95

96 97 98 99 100 101 102 103 104 105 106
    global sem
    async with sem:
        try:
            async with aiohttp.ClientSession(trace_configs=[http_debug()]) as session:
                async with session.post(inbox, data=data, headers=headers) as resp:
                    if resp.status == 202:
                        return
                    resp_payload = await resp.text()
                    logging.debug('%r >> resp %r', inbox, resp_payload)
        except Exception as e:
            logging.info('Caught %r while pushing to %r.', e, inbox)
kaniini's avatar
kaniini committed
107 108


kaniini's avatar
kaniini committed
109 110
async def follow_remote_actor(actor_uri):
    actor = await fetch_actor(actor_uri)
Izalia Mae's avatar
Izalia Mae committed
111
    
112 113 114 115
    if not actor:
        logging.info('failed to fetch actor at: %r', actor_uri)
        return

Izalia Mae's avatar
Izalia Mae committed
116 117 118 119
    if AP_CONFIG['whitelist_enabled'] is True and urlsplit(actor_uri).hostname not in AP_CONFIG['whitelist']:
        logging.info('refusing to follow non-whitelisted actor: %r', actor_uri)
        return

120
    logging.info('following: %r', actor_uri)
kaniini's avatar
kaniini committed
121 122 123 124 125

    message = {
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Follow",
        "to": [actor['id']],
kaniini's avatar
kaniini committed
126
        "object": actor['id'],
kaniini's avatar
kaniini committed
127 128 129 130 131 132
        "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
        "actor": "https://{}/actor".format(AP_CONFIG['host'])
    }
    await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))


kaniini's avatar
kaniini committed
133 134
async def unfollow_remote_actor(actor_uri):
    actor = await fetch_actor(actor_uri)
135 136 137 138 139
    if not actor:
        logging.info('failed to fetch actor at: %r', actor_uri)
        return

    logging.info('unfollowing: %r', actor_uri)
kaniini's avatar
kaniini committed
140 141 142 143 144 145 146 147 148

    message = {
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Undo",
        "to": [actor['id']],
        "object": {
             "type": "Follow",
             "object": actor_uri,
             "actor": actor['id'],
kaniini's avatar
kaniini committed
149 150
             "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4())
        },
kaniini's avatar
kaniini committed
151 152 153 154 155 156
        "id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
        "actor": "https://{}/actor".format(AP_CONFIG['host'])
    }
    await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))


kaniini's avatar
kaniini committed
157 158 159 160 161 162
tag_re = re.compile(r'(<!--.*?-->|<[^>]*>)')
def strip_html(data):
    no_tags = tag_re.sub('', data)
    return cgi.escape(no_tags)


163
def distill_inboxes(actor, object_id):
164 165
    global DATABASE

166 167
    origin_hostname = urlsplit(object_id).hostname

168 169
    inbox = get_actor_inbox(actor)
    targets = [target for target in DATABASE.get('relay-list', []) if target != inbox]
170
    targets = [target for target in targets if urlsplit(target).hostname != origin_hostname]
171
    hostnames = [urlsplit(target).hostname for target in targets]
172 173

    assert inbox not in targets
174
    assert origin_hostname not in hostnames
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189

    return targets


def distill_object_id(activity):
    logging.debug('>> determining object ID for %r', activity['object'])
    obj = activity['object']

    if isinstance(obj, str):
        return obj

    return obj['id']


async def handle_relay(actor, data, request):
190 191
    global CACHE

192 193
    object_id = distill_object_id(data)

194 195 196 197 198 199
    if object_id in CACHE:
        logging.debug('>> already relayed %r as %r', object_id, CACHE[object_id])
        return

    activity_id = "https://{}/activities/{}".format(request.host, uuid.uuid4())

200 201 202
    message = {
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Announce",
203
        "to": ["https://{}/followers".format(request.host)],
204 205
        "actor": "https://{}/actor".format(request.host),
        "object": object_id,
206
        "id": activity_id
207 208
    }

209
    logging.debug('>> relay: %r', message)
210

211
    inboxes = distill_inboxes(actor, object_id)
212 213 214

    futures = [push_message_to_actor({'inbox': inbox}, message, 'https://{}/actor#main-key'.format(request.host)) for inbox in inboxes]
    asyncio.ensure_future(asyncio.gather(*futures))
kaniini's avatar
kaniini committed
215

216 217
    CACHE[object_id] = activity_id

kaniini's avatar
kaniini committed
218

219
async def handle_forward(actor, data, request):
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234
    object_id = distill_object_id(data)

    logging.debug('>> Relay %r', data)

    inboxes = distill_inboxes(actor, object_id)

    futures = [
        push_message_to_actor(
            {'inbox': inbox},
            data,
            'https://{}/actor#main-key'.format(request.host))
        for inbox in inboxes]
    asyncio.ensure_future(asyncio.gather(*futures))


kaniini's avatar
kaniini committed
235
async def handle_follow(actor, data, request):
236 237 238 239
    global DATABASE

    following = DATABASE.get('relay-list', [])
    inbox = get_actor_inbox(actor)
gled's avatar
gled committed
240

241
    if urlsplit(inbox).hostname in AP_CONFIG['blocked_instances']:
gled's avatar
gled committed
242
        return
243 244 245 246 247

    if inbox not in following:
        following += [inbox]
        DATABASE['relay-list'] = following

248 249 250
        if data['object'].endswith('/actor'):
            asyncio.ensure_future(follow_remote_actor(actor['id']))

kaniini's avatar
kaniini committed
251 252 253 254
    message = {
        "@context": "https://www.w3.org/ns/activitystreams",
        "type": "Accept",
        "to": [actor["id"]],
kaniini's avatar
kaniini committed
255
        "actor": "https://{}/actor".format(request.host),
kaniini's avatar
kaniini committed
256 257 258 259 260 261 262 263 264 265 266

        # this is wrong per litepub, but mastodon < 2.4 is not compliant with that profile.
        "object": {
             "type": "Follow",
             "id": data["id"],
             "object": "https://{}/actor".format(request.host),
             "actor": actor["id"]
        },

        "id": "https://{}/activities/{}".format(request.host, uuid.uuid4()),
    }
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285

    asyncio.ensure_future(push_message_to_actor(actor, message, 'https://{}/actor#main-key'.format(request.host)))


async def handle_undo(actor, data, request):
    global DATABASE

    child = data['object']
    if child['type'] == 'Follow':
        following = DATABASE.get('relay-list', [])

        inbox = get_actor_inbox(actor)

        if inbox in following:
            following.remove(inbox)
            DATABASE['relay-list'] = following

        if child['object'].endswith('/actor'):
            await unfollow_remote_actor(actor['id'])
kaniini's avatar
kaniini committed
286 287 288


processors = {
289 290
    'Announce': handle_relay,
    'Create': handle_relay,
291
    'Delete': handle_forward,
292
    'Follow': handle_follow,
293 294
    'Undo': handle_undo,
    'Update': handle_forward,
kaniini's avatar
kaniini committed
295 296 297 298
}


async def inbox(request):
kaniini's avatar
kaniini committed
299
    data = await request.json()
300
    instance = urlsplit(data['actor']).hostname
kaniini's avatar
kaniini committed
301 302 303

    if 'actor' not in data or not request['validated']:
        raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
kaniini's avatar
kaniini committed
304

Izalia Mae's avatar
Izalia Mae committed
305 306 307 308
    elif data['type'] != 'Follow' and 'https://{}/inbox'.format(instance) not in DATABASE['relay-list']:
        raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')

    elif AP_CONFIG['whitelist_enabled'] is True and instance not in AP_CONFIG['whitelist']:
309 310
        raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')

kaniini's avatar
kaniini committed
311 312 313
    actor = await fetch_actor(data["actor"])
    actor_uri = 'https://{}/actor'.format(request.host)

314 315
    logging.debug(">> payload %r", data)

kaniini's avatar
kaniini committed
316 317 318 319 320 321 322
    processor = processors.get(data['type'], None)
    if processor:
        await processor(actor, data, request)

    return aiohttp.web.Response(body=b'{}', content_type='application/activity+json')

app.router.add_post('/inbox', inbox)