def get_public_key(key_id): ''' Takes the "keyId" field of a request and uses resolve_ap_object-actor to fetch the public key of the specified actor. ''' actor = resolve_ap_object(key_id) if not actor or not actor.get('publicKey'): return error( 'An error occurred while attempting to fetch the public key of the inbound actor.', 400) public_key_wrapper = actor.get('publicKey') if public_key_wrapper.get('id') != key_id: return error('Keys don\'t match', 400) public_key_string = public_key_wrapper.get('publicKeyPem') if not public_key_string: return error('Public key not found.') public_key = RSA.importKey(bytes(public_key_string, 'utf-8')) return public_key
def route_signup(): json = request.get_json() username = str(json.get('username')).lower() password = str(json.get('password')) password_confirm = str(json.get('passwordConfirm')) actor_name = str(json.get('actorName')) existing_user = db.session.query(User).filter( db.func.lower(User.username) == db.func.lower(username)).first() if existing_user != None: return error('That username is not available.', 400) existing_actor = db.session.query(Actor).filter( db.func.lower(Actor.username) == db.func.lower(actor_name)).first() if existing_actor != None: return error('That actor name is not available.', 400) if password != password_confirm: return error('Passwords don\'t match.', 400) new_user = User(username, password) db.session.add(new_user) db.session.flush() new_actor = Actor(user_id=new_user.id, username=actor_name) db.session.add(new_actor) db.session.commit() session['uid'] = new_user.id return make_response('', 201)
def handle_undo(inbound_json, actor): ''' The Undo activity is one of a handful with side effects. These include deleting Like objects and undoing leader-follower relationships. These side effects are handled here. Returns: None ''' undone_activity = None if isinstance(inbound_json['object'], str): undone_activity = APObject.get_object_from_url(inbound_json['object']) elif isinstance(inbound_json['object'], dict): undone_activity = APObject.get_object_from_url( inbound_json['object']['id']) if undone_activity is None: return error('Could not undo activity: activity not found.', 404) if undone_activity.internal_actor_id != actor.id: return error('You cannot undo activities performed by other actors.') if isinstance(undone_activity, Follow): pass elif isinstance(undone_activity, Like): db.session.delete(undone_activity) else: return error('You cannot undo that kind of activity.')
def route_signin(): json = request.get_json() username = str(json.get('username')) password = str(json.get('password')) if 'uid' in session: return error( 'You are currently signed in. Please sign out before trying to sign in.' ) err_msg = 'Invalid username or password.' user = db.session.query(User).filter( db.func.lower(User.username) == db.func.lower(username)).first() if user == None: return error(err_msg) if not bcrypt.checkpw(bytes(password, 'utf-8'), bytes(user.password_hash, 'utf-8')): return error(err_msg) session['uid'] = user.id return make_response('', 200)
def handle_inbound_follow(activity, obj): ''' Handles the side effects of an incoming Follow activity. Returns: Flask response if an error occurs, None otherwise ''' api_url = os.environ['API_URL'] if obj['id'].find(f'{api_url}/actors/') < 0: return error('Invalid actor ID') local_actor_name = obj['id'].replace(f'{api_url}/actors/', '').lower() leader = db.session.query(Actor).filter( db.func.lower(Actor.username) == local_actor_name).first() if leader is None: return error('Actor not found', 404) follower = resolve_ap_object(activity['actor']) follower_shared_inbox = None follower_inbox = follower['inbox'] if 'endpoints' in follower: if 'sharedInbox' in follower['endpoints']: follower_shared_inbox = follower['endpoints']['sharedInbox'] accept_activity = Accept() accept_activity.set_actor(leader) accept_activity.set_object(activity) db.session.add(accept_activity) db.session.flush() message_body = accept_activity.to_dict() message_body['object'] = activity try: signed_request(leader, message_body, url=follower_inbox) except: return error('An error occurred while processing that follow request.', 400) new_followed_by = FollowedBy(leader.id, follower['id'], follower_inbox, follower_shared_inbox) db.session.add(new_followed_by) follower_username = follower['id'] if 'preferredUsername' in follower: follower_username = follower['preferredUsername'] db.session.add( Notification(leader, f'{follower_username} has followed you', 'Follow')) return None
def handle_follow(inbound_json, actor, base_activity, base_object, is_local): ''' Handles the various oddities associated with the correspondance nature of the Follow activity. ''' leader = resolve_ap_object(inbound_json['object']) existing_follow = db.session.query(Following).filter( db.and_(Following.follower_id == actor.id, Following.leader == leader['id'])).first() if existing_follow is not None and existing_follow.approved is True: return error('You are already following this actor.') new_follow = Following(actor.id, leader['id'], leader['followers'], approved=is_local) db.session.add(new_follow) # Due to the correspondance nature of the Follow activity, it has some very unusual requirements. # We need to detect if the incoming Follow activity is targeting a local user. If it is, we don't need # To deliver server-to-server messages about this transaction. Attempting to do so would cause problems # resulting from uncomitted database transactions and be a waste of resources. if is_local: local_leader = db.session.query(Actor).filter( db.func.lower(Actor.username) == db.func.lower( inbound_json['object'].replace( f'{os.environ["API_URL"]}/actors/', ''))).first() if local_leader is None: return make_response('Actor not found', 404) actor_dict = actor.to_dict() new_followed_by = FollowedBy( local_leader.id, actor_dict['id'], actor_dict['inbox'], follower_shared_inbox=actor_dict['endpoints']['sharedInbox'], approved=True) db.session.add( Notification( local_leader, f'{actor_dict["preferredUsername"]} has followed you.', 'Follow')) db.session.add(new_followed_by) db.session.commit() else: db.session.commit( ) # This is required so when we get an Accept activity back before the end of this request, we're able to find the Follow activity try: signed_request(actor, base_activity.to_dict(), leader['inbox']) except: return error( 'Your follow request was not able to be delivered to that server.' ) return make_response('', 200)
def get_base_objects(obj): ''' The client is capable of sending a wide array of object types to the server. This function parses the input AP Activity+Object pair and returns a tuple containing the correct database model(s). base_object is either a string or a database model depending on context Returns: Flask.response | tuple(base_activity, base_object) ''' base_activity = None base_object = None err_not_supported = 'Vagabond does not currently support this type of AcvtivityPub object.' if obj['type'] == 'Create': if obj['object']['type'] == 'Note': base_object = Note() base_activity = Create() else: return error(err_not_supported) elif obj['type'] == 'Follow': base_activity = Follow() elif obj['type'] == 'Like': base_activity = Like() elif obj['type'] == 'Undo': base_activity = Undo() elif obj['type'] == 'Delete': base_activity = Delete() else: return error(err_not_supported) if base_object is None: if (isinstance(obj['object'], str)): base_object = obj['object'] elif (isinstance(obj['object'], dict)): base_object = obj['object']['id'] else: raise Exception( 'Invalid Activity: object field was neither an object nor a string.' ) db.session.add(base_activity) if isinstance(base_object, db.Model): db.session.add(base_object) db.session.flush() return (base_activity, base_object)
def route_switch_actor(user): actor = db.session.query(Actor).filter( db.func.lower(Actor.username) == db.func.lower(user.username)).first() if actor is None: return error('Actor not found', 404) if actor.user_id == user.id: user.primary_actor_id = actor.id db.session.commit() return make_response('', 200) else: return error('User does not own actor.', 404)
def route_get_actor_following_page(username, page): actor = db.session.query(Actor).filter( db.func.lower(Actor.username) == db.func.lower(username)).first() if actor is None: return error('Actor not found', 404) items = db.session.query(Following).filter( db.and_(Following.follower_id == actor.id, Following.approved == 1)).paginate(page, 20).items ordered_items = [] for item in items: ordered_items.append(item.leader) return make_response( { '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'{config["api_url"]}/actors/{actor.username}/following/{page}', 'type': 'OrderedCollectionPage', 'totalItems': len(items), 'partOf': f'{config["api_url"]}/actors/{actor.username}/following/', 'orderedItems': ordered_items }, 200)
def route_get_actor_following(username): ''' Publicly accessible following collection. ''' actor = db.session.query(Actor).filter( db.func.lower(Actor.username) == db.func.lower(username)).first() if actor is None: return error('Actor not found', 404) items_per_page = 20 total_items = db.session.query(Following).filter( db.and_(Following.follower_id == actor.id, Following.approved == 1)).count() last_page = ceil(total_items / items_per_page) return make_response( { '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'{config["api_url"]}/actors/{actor.username}/following', 'type': 'OrderedCollection', 'totalItems': total_items, 'first': f'{config["api_url"]}/actors/{actor.username}/following/1', 'last': f'{config["api_url"]}/actors/{actor.username}/following/{last_page}' }, 200)
def handle_inbound_accept_reject(activity, obj): ''' Incoming Accept and Reject activites on Follow objects have side effects which are handled here ''' actor = APObject.get_object_from_url( obj['actor']) #actor on local server who is following external actor if not isinstance(actor, Actor): raise Exception( 'Remote server tried to accept or reject a Follow request done by an object and not by an actor. Aborting.' ) following = db.session.query(Following).filter( db.and_(Following.follower_id == actor.id, Following.leader == activity['actor']), Following.approved == 0).first() follow_activity = db.session.query(Follow).filter( db.and_(Follow.external_object_id == obj['object'], Follow.internal_actor_id == actor.id)).first() if following is None or follow_activity is None: return error('Follow request not found.', 404) if activity['type'] == 'Accept': following.approved = True db.session.add(following) else: db.session.delete(following) return None
def get_outbox(username): username = username.lower() actor = db.session.query(Actor).filter_by(username=username).first() if not actor: return error('Actor not found', 404) items_per_page = 20 total_items = db.session.query(Activity).filter( Activity.internal_actor_id == actor.id).count() max_id_object = db.session.query(Activity).filter( Activity.internal_actor_id == actor.id).order_by( Activity.id.desc()).first() max_id = 0 if max_id_object is not None: max_id = max_id_object.id max_page = ceil(total_items / items_per_page) api_url = os.environ['API_URL'] output = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'{api_url}/actors/{username}/outbox', 'type': 'OrderedCollection', 'totalItems': total_items, 'first': f'{api_url}/actors/{username}/outbox/1?maxId={max_id}', 'last': f'{api_url}/actors/{username}/outbox/{max_page}?maxId={max_id}' } response = make_response(output, 200) response.headers['Content-Type'] = 'application/activity+json' return response
def get_outbox(username): username = username.lower() actor = db.session.query(Actor).filter_by(username=username).first() if not actor: return error('Actor not found', 404) items_per_page = 20 total_items = db.session.query(APObject).filter_by( type=APObjectType.NOTE).count() max_page = ceil(total_items / items_per_page) api_url = config['api_url'] output = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'{api_url}/actors/{username}/outbox', 'type': 'OrderedCollection', 'totalItems': total_items, 'first': f'{api_url}/actors/{username}/outbox/1', 'last': f'{api_url}/actors/{username}/outbox/{max_page}' } response = make_response(output, 200) response.headers['Content-Type'] = 'application/activity+json' return response
def route_get_actor_following_page(username, page): actor = db.session.query(Actor).filter( db.func.lower(Actor.username) == db.func.lower(username)).first() if actor is None: return error('Actor not found', 404) items = db.session.query(Following).filter( db.and_(Following.follower_id == actor.id, Following.approved == 1)).paginate(page, 20).items ordered_items = [] for item in items: ordered_items.append(item.leader) api_url = os.environ['API_URL'] output = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'{api_url}/actors/{actor.username}/following/{page}', 'type': 'OrderedCollectionPage', 'partOf': f'{api_url}/actors/{actor.username}/following', 'next': f'{api_url}/actors/{actor.username}/following/{page+1}', 'orderedItems': ordered_items } if page > 1: output[ 'prev'] = f'{api_url}/actors/{actor.username}/following/{page-1}' return make_response(output, 200)
def modify_follow(actor, activity, obj): ''' Incoming Accept and Reject activites on Follow objects have side effects which are handled here ''' following = db.session.query(Following).filter( db.and_(Following.follower_id == actor.id, Following.leader == activity['actor']), Following.approved == 0).first() follow_activity = db.session.query(Follow).filter( db.and_(Follow.external_object_id == obj['object'], Follow.internal_actor_id == actor.id)).first() if following is None or follow_activity is None: return error('Follow request not found.', 404) if activity['type'] == 'Accept': following.approved = True db.session.add(following) else: db.session.delete(following) db.session.delete(follow_activity) db.session.commit() return make_response('', 200)
def route_user_outbox_paginated(actor_name, page): actor = db.session.query(Actor).filter_by( username=actor_name.lower()).first() if actor is None: return error('Actor not found', 404) activities = db.session.query(Activity).filter( db.and_(Activity.actor == actor, Activity.type != APObjectType.FOLLOW)).order_by( Activity.published.desc()).paginate(page, 20).items api_url = config['api_url'] orderedItems = [] for activity in activities: orderedItems.append(activity.to_dict()) output = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'{api_url}/actors/{actor_name}/outbox/{page}', 'partOf': f'{api_url}/actors/{actor_name}/outbox', 'type': 'OrderedCollectionPage', 'prev': f'{api_url}/actors/{actor_name}/outbox/{page-1}', 'next': f'{api_url}/actors/{actor_name}/outbox/{page+1}', 'orderedItems': orderedItems } response = make_response(output, 200) response.headers['Content-Type'] = 'application/activity+json' return response
def route_get_liked_collection(actor_name): actor = db.session.query(Actor).filter( db.func.lower(actor_name) == db.func.lower(Actor.username)).first() if actor is None: return error('Actor not found.', 404) max_id = 0 max_id_object = db.session.query(Like).filter( Like.internal_actor_id == actor.id).order_by(Like.id.desc()).first() if max_id_object is not None: max_id = max_id_object.id total_items = db.session.query(Like).filter( Like.internal_actor_id == actor.id).count() items_per_page = 20 max_page = ceil(total_items / items_per_page) api_url = os.environ['API_URL'] output = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'{api_url}/actors/{actor_name}/liked', 'type': 'OrderedCollection', 'totalItems': total_items, 'first': f'{api_url}/actors/{actor_name}/liked/1?maxId={max_id}', 'last': f'{api_url}/actors/{actor_name}/liked/{max_page}?maxId={max_id}' } output = make_response(output, 200) output.headers['content-type'] = 'application/activity+json' return output
def route_get_actor_followers(username): ''' Publicly accessible followers collection. ''' actor = db.session.query(Actor).filter( db.func.lower(Actor.username) == db.func.lower(username)).first() if actor is None: return error('Actor not found', 404) items_per_page = 20 total_items = db.session.query(FollowedBy).filter( FollowedBy.leader_id == actor.id).count() last_page = ceil(total_items / items_per_page) return make_response( { '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'{os.environ["API_URL"]}/actors/{actor.username}/followers', 'type': 'OrderedCollection', 'totalItems': total_items, 'first': f'{os.environ["API_URL"]}/actors/{actor.username}/followers/1', 'last': f'{os.environ["API_URL"]}/actors/{actor.username}/followers/{last_page}' }, 200)
def handle_delete(inbound_json, actor): deleted_object = None if isinstance(inbound_json['object'], str): deleted_object = APObject.get_object_from_url(inbound_json['object']) elif isinstance(inbound_json['object'], dict): deleted_object = APObject.get_object_from_url( inbound_json['object']['id']) if deleted_object is None: return error('Could not delete activity: activity not found.', 404) if deleted_object.internal_author_id != actor.id: return error('You cannot delete objects created by other actors.') if isinstance(deleted_object, Note): db.session.delete(deleted_object)
def route_get_object_by_id(object_id): ap_object = db.session.query(APObject).get(object_id) if ap_object is None: return error('Object not found', 404) response = make_response(ap_object.to_dict(), 200) response.headers['Content-Type'] = 'application/activity+json' return response
def get_note_by_id(object_id): ap_object = db.session.query(APObject).filter( db.and_(APObject.id == object_id, APObject.external_id == None)).first() if ap_object is None: return error('Object not found', 404) response = make_response(ap_object.to_dict(), 200) response.headers['Content-Type'] = 'application/activity+json' return response
def route_get_actor_by_username(username): ''' Returns: ActivityPub actor object ''' actor = db.session.query(Actor).filter( db.func.lower(Actor.username) == db.func.lower(username)).first() if actor is None: return error('Actor not found', 404) response = make_response(actor.to_dict(), 200) response.headers['Content-Type'] = 'application/activity+json' return response
def route_get_liked_collection_paginated(actor_name, page): #actor check actor = db.session.query(Actor).filter( db.func.lower(actor_name) == db.func.lower(Actor.username)).first() if actor is None: return error('Actor not found.', 404) # set variables items_per_page = 20 max_id = None if 'maxId' in request.args: max_id = int(request.args['maxId']) total_items = 0 base_query = db.session.query(Like).filter( Like.internal_actor_id == actor.id).order_by(Like.id.desc()) total_items = base_query.count() if max_id is not None: base_query = base_query.filter(Like.id <= max_id) api_url = os.environ['API_URL'] output = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': f'{api_url}/actors/{actor_name}/liked/{page}', 'type': 'OrderedCollectionPage', 'partOf': f'{api_url}/actors/{actor.username}/liked', 'orderedItems': [] } for item in base_query.paginate(page, items_per_page).items: output['orderedItems'].append(item.to_dict()) if max_id is not None: output['id'] = output['id'] + f'?maxId={max_id}' # previous page if page > 1: output['prev'] = f'{api_url}/actors/{actor_name}/liked/{page-1}' if max_id is not None: output['prev'] = output['prev'] + f'?maxId={max_id}' # optional next page if total_items > items_per_page * page: output['next'] = f'{api_url}/actors/{actor_name}/liked/{page}' if max_id is not None: output['next'] = output['next'] + f'?maxId={max_id}' output = make_response(output, 200) output.headers['content-type'] = 'application/activity+json' return output
def route_user_outbox(actor_name): ''' Post requests to an actor's outbox can come from either a C2S or S2S interaction. Here we determine which type of request is being received and act accordingly. GET requests are also permitted. ''' if request.method == 'GET': return get_outbox(actor_name) elif request.method == 'POST' and 'uid' in session: return post_outbox_c2s(actor_name) else: return error('Invalid request')
def route_actor_inbox_paginated(user, actor_name, page): has_permission = False actor = None for _actor in user.actors: if _actor.username.lower() == actor_name.lower(): has_permission = True actor = _actor break if not has_permission: return error('You can only view your own inbox, not someone else\'s!') return get_inbox_paginated(actor, page, personalized=True)
def handle_undo(inbound_json, activity, obj): print(json.dumps(inbound_json)) if obj['type'] == 'Follow': leader = APObject.get_object_from_url(obj['object']) if leader is None or not isinstance(leader, Actor): return error('Actor not found.', 404) follow_activity = db.session.query(Follow).filter( Follow.external_id == obj['id']).first() if follow_activity is None: return error('Cannot undo follow activity: follow not found.', 404) followed_by = db.session.query(FollowedBy).filter( FollowedBy.follower == inbound_json['actor']).filter( FollowedBy.leader_id == leader.id).first() if followed_by is None: return error('Cannot undo follow activity: follow not found.', 404) db.session.delete(follow_activity) db.session.delete(followed_by) db.session.commit() return make_response('', 200)
def post_outbox_c2s(actor_name, *args, **kwargs): user = kwargs['user'] is_own_outbox = False actor = None for _actor in user.actors: if _actor.username.lower() == actor_name.lower(): is_own_outbox = True actor = _actor break if not is_own_outbox: return error( 'You can\'t post to the outbox of an actor that isn\'t yours.') _type = request.get_json().get('type') if _type is None: return error('Invalid ActivityPub object type') elif _type == 'Note': return create_note(actor, request.get_json()) elif _type == 'Follow': return follow(actor, request.get_json()) return error('Invalid ActivityPub object type.')
def follow(actor, follow_activity): ''' actor: Actor model follow_activity: Dictionary representation of the new follow request ''' leader = resolve_ap_object(follow_activity['object']) existing_follow = db.session.query(Following).filter( db.and_(Following.follower_id == actor.id, Following.leader == leader['id'])).first() if existing_follow is not None: if existing_follow.approved is True: return error('You are already following this actor.') db.session.delete(existing_follow) new_activity = Follow() new_activity.set_actor(actor) new_activity.set_object(follow_activity['object']) db.session.add(new_activity) db.session.flush() new_follow = Following(actor.id, leader['id'], leader['followers']) db.session.add(new_follow) response = signed_request(actor, new_activity.to_dict(), leader['inbox']) db.session.commit() if response.status_code >= 400: return error('Something went wrong :(') return make_response('', 200)
def get_actor_inbox(actor_name, user=None): actor_name = actor_name.lower() has_permission = False for _actor in user.actors: if _actor.username.lower() == actor_name: has_permission = True break if has_permission: return get_inbox(personalized=True) else: #TODO: Non-watseful way of figuring out which notes go to who return error('You can only view your own inbox, not someone else\'s!')
def route_add_new_actor(user): actor_name = request.get_json().get('actorName') existing_actor = db.session.query(Actor).filter( db.func.lower(Actor.username) == db.func.lower(actor_name)).first() if existing_actor is not None: return error('That actor name is not available.', 404) new_actor = Actor(actor_name, user_id=user.id) db.session.add(new_actor) db.session.flush() db.session.commit() return make_response('', 201)