From f8ee19b4d16ad8ecec75abe10203da65755e1312 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Fri, 1 Jun 2018 20:29:44 +0200 Subject: [PATCH] User API cleanup --- .travis.yml | 1 + Makefile | 11 ++ README.md | 144 ++++++++++++++++++++++++- activitypub.py | 23 ++-- app.py | 174 +++++++++++++++--------------- docker-compose-tests.yml | 4 +- docker-compose.yml | 4 +- tasks.py | 9 +- tests/federation_test.py | 224 +++++++++++++++++++++++++++------------ utils/errors.py | 3 + utils/key.py | 3 +- 11 files changed, 422 insertions(+), 178 deletions(-) diff --git a/.travis.yml b/.travis.yml index f613e2d..a523ee1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ script: - mypy --ignore-missing-imports . - flake8 activitypub.py - cp -r tests/fixtures/me.yml config/me.yml + - docker build . -t microblogpub:latest - docker-compose up -d - docker-compose ps - WEB_PORT=5006 CONFIG_DIR=./tests/fixtures/instance1/config docker-compose -p instance1 -f docker-compose-tests.yml up -d diff --git a/Makefile b/Makefile index 333f991..6221cdd 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,18 @@ css: password: python -c "import bcrypt; from getpass import getpass; print(bcrypt.hashpw(getpass().encode('utf-8'), bcrypt.gensalt()).decode('utf-8'))" +docker: + mypy . --ignore-missing-imports + docker build . -t microblogpub:latest + +reload-fed: + docker-compose -p instance2 -f docker-compose-tests.yml stop + docker-compose -p instance1 -f docker-compose-tests.yml stop + WEB_PORT=5006 CONFIG_DIR=./tests/fixtures/instance1/config docker-compose -p instance1 -f docker-compose-tests.yml up -d --force-recreate --build + WEB_PORT=5007 CONFIG_DIR=./tests/fixtures/instance2/config docker-compose -p instance2 -f docker-compose-tests.yml up -d --force-recreate --build + update: docker-compose stop git pull + docker build . -t microblogpub:latest docker-compose up -d --force-recreate --build diff --git a/README.md b/README.md index 7aa1bea..b8fe115 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,20 @@ $ docker-compose -f docker-compose-dev.yml up -d $ MICROBLOGPUB_DEBUG=1 FLASK_APP=app.py flask run -p 5005 --with-threads ``` +## ActivityPub API + +### GET / + +Returns the actor profile, with links to all the "standard" collections. + +### GET /tags/:tag + +Special collection that reference notes with the given tag. + +### GET /stream + +Special collection that returns the stream/inbox as displayed in the UI. + ## User API The user API is used by the admin UI (and requires a CSRF token when used with a regular user session), but it can also be accessed with an API key. @@ -95,7 +109,7 @@ All the examples are using [HTTPie](https://httpie.org/). ### POST /api/note/delete{?id} -Deletes the given note `id`. +Deletes the given note `id` (the note must from the instance outbox). Answers a **201** (Created) status code. @@ -104,7 +118,7 @@ You can pass the `id` via JSON, form data or query argument. #### Example ```shell -$ http POST https://microblog.pub/api/note/delete Authorization:'Bearer ' id=http://microblob.pub/outbox//activity +$ http POST https://microblog.pub/api/note/delete Authorization:'Bearer ' id=http://microblob.pub/outbox//activity ``` #### Response @@ -115,6 +129,132 @@ $ http POST https://microblog.pub/api/note/delete Authorization:'Bearer ' } ``` +### POST /api/like{?id} + +Likes the given activity. + +Answers a **201** (Created) status code. + +You can pass the `id` via JSON, form data or query argument. + +#### Example + +```shell +$ http POST https://microblog.pub/api/like Authorization:'Bearer ' id=http://activity-iri.tld +``` + +#### Response + +```json +{ + "activity": "https://microblog.pub/outbox/" +} +``` + +### POST /api/boost{?id} + +Boosts/Announces the given activity. + +Answers a **201** (Created) status code. + +You can pass the `id` via JSON, form data or query argument. + +#### Example + +```shell +$ http POST https://microblog.pub/api/boost Authorization:'Bearer ' id=http://activity-iri.tld +``` + +#### Response + +```json +{ + "activity": "https://microblog.pub/outbox/" +} +``` + +### POST /api/block{?actor} + +Blocks the given actor, all activities from this actor will be dropped after that. + +Answers a **201** (Created) status code. + +You can pass the `id` via JSON, form data or query argument. + +#### Example + +```shell +$ http POST https://microblog.pub/api/block Authorization:'Bearer ' actor=http://actor-iri.tld/ +``` + +#### Response + +```json +{ + "activity": "https://microblog.pub/outbox/" +} +``` + +### POST /api/follow{?actor} + +Follows the given actor. + +Answers a **201** (Created) status code. + +You can pass the `id` via JSON, form data or query argument. + +#### Example + +```shell +$ http POST https://microblog.pub/api/follow Authorization:'Bearer ' actor=http://actor-iri.tld/ +``` + +#### Response + +```json +{ + "activity": "https://microblog.pub/outbox/" +} +``` + +### POST /api/new_note{?content,reply} + +Creates a new note. `reply` is the IRI of the "replied" note if any. + +Answers a **201** (Created) status code. + +You can pass the `content` and `reply` via JSON, form data or query argument. + +#### Example + +```shell +$ http POST https://microblog.pub/api/new_note Authorization:'Bearer ' content=hello +``` + +#### Response + +```json +{ + "activity": "https://microblog.pub/outbox/" +} +``` + + +### GET /api/stream + + +#### Example + +```shell +$ http GET https://microblog.pub/api/stream Authorization:'Bearer ' +``` + +#### Response + +```json +``` + + ## Contributions PRs are welcome, please open an issue to start a discussion before your start any work. diff --git a/activitypub.py b/activitypub.py index 1ec068d..1cd048c 100644 --- a/activitypub.py +++ b/activitypub.py @@ -9,13 +9,12 @@ from bson.objectid import ObjectId from html2text import html2text from feedgen.feed import FeedGenerator -from utils.linked_data_sig import generate_signature from utils.actor_service import NotAnActorError from utils.errors import BadActivityError, UnexpectedActivityTypeError from utils import activitypub_utils from config import USERNAME, BASE_URL, ID from config import CTX_AS, CTX_SECURITY, AS_PUBLIC -from config import KEY, DB, ME, ACTOR_SERVICE +from config import DB, ME, ACTOR_SERVICE from config import OBJECT_SERVICE from config import PUBLIC_INSTANCES import tasks @@ -350,7 +349,6 @@ class BaseActivity(object): except NotImplementedError: logger.debug('post to outbox hook not implemented') - generate_signature(activity, KEY.privkey) payload = json.dumps(activity) for recp in recipients: logger.debug(f'posting to {recp}') @@ -571,7 +569,6 @@ class Like(BaseActivity): # Update the meta counter if the object is published by the server DB.outbox.update_one({'activity.object.id': obj.id}, { '$inc': {'meta.count_like': 1}, - '$addToSet': {'meta.col_likes': self.to_dict(embed=True, embed_object_id_only=True)}, }) # XXX(tsileo): notification?? @@ -580,7 +577,6 @@ class Like(BaseActivity): # Update the meta counter if the object is published by the server DB.outbox.update_one({'activity.object.id': obj.id}, { '$inc': {'meta.count_like': -1}, - '$pull': {'meta.col_likes': {'id': self.id}}, }) def _undo_should_purge_cache(self) -> bool: @@ -592,7 +588,6 @@ class Like(BaseActivity): # Unlikely, but an actor can like it's own post DB.outbox.update_one({'activity.object.id': obj.id}, { '$inc': {'meta.count_like': 1}, - '$addToSet': {'meta.col_likes': self.to_dict(embed=True, embed_object_id_only=True)}, }) # Keep track of the like we just performed @@ -603,7 +598,6 @@ class Like(BaseActivity): # Unlikely, but an actor can like it's own post DB.outbox.update_one({'activity.object.id': obj.id}, { '$inc': {'meta.count_like': -1}, - '$pull': {'meta.col_likes': {'id': self.id}}, }) DB.inbox.update_one({'activity.object.id': obj.id}, {'$set': {'meta.liked': False}}) @@ -646,7 +640,6 @@ class Announce(BaseActivity): DB.outbox.update_one({'activity.object.id': obj.id}, { '$inc': {'meta.count_boost': 1}, - '$addToSet': {'meta.col_shares': self.to_dict(embed=True, embed_object_id_only=True)}, }) def _undo_inbox(self) -> None: @@ -654,7 +647,6 @@ class Announce(BaseActivity): # Update the meta counter if the object is published by the server DB.outbox.update_one({'activity.object.id': obj.id}, { '$inc': {'meta.count_boost': -1}, - '$pull': {'meta.col_shares': {'id': self.id}}, }) def _undo_should_purge_cache(self) -> bool: @@ -1079,11 +1071,12 @@ def embed_collection(total_items, first_page_id): return { "type": ActivityType.ORDERED_COLLECTION.value, "totalItems": total_items, - "first": first_page_id, + "first": f'{first_page_id}?page=first', + "id": first_page_id, } -def build_ordered_collection(col, q=None, cursor=None, map_func=None, limit=50, col_name=None): +def build_ordered_collection(col, q=None, cursor=None, map_func=None, limit=50, col_name=None, first_page=False): col_name = col_name or col.name if q is None: q = {} @@ -1127,6 +1120,9 @@ def build_ordered_collection(col, q=None, cursor=None, map_func=None, limit=50, if len(data) == limit: resp['first']['next'] = BASE_URL + '/' + col_name + '?cursor=' + next_page_cursor + if first_page: + return resp['first'] + return resp # If there's a cursor, then we return an OrderedCollectionPage @@ -1141,6 +1137,9 @@ def build_ordered_collection(col, q=None, cursor=None, map_func=None, limit=50, if len(data) == limit: resp['next'] = BASE_URL + '/' + col_name + '?cursor=' + next_page_cursor - # TODO(tsileo): implements prev with prev= + if first_page: + return resp['first'] + + # XXX(tsileo): implements prev with prev=? return resp diff --git a/app.py b/app.py index 127c0f5..3313af3 100644 --- a/app.py +++ b/app.py @@ -62,6 +62,8 @@ from utils.webfinger import get_actor_url from utils.errors import Error from utils.errors import UnexpectedActivityTypeError from utils.errors import BadActivityError +from utils.errors import NotFromOutboxError +from utils.errors import ActivityNotFoundError from typing import Dict, Any @@ -509,7 +511,7 @@ def outbox(): DB.outbox, q=q, cursor=request.args.get('cursor'), - map_func=lambda doc: clean_activity(doc['activity']), + map_func=lambda doc: activity_from_doc(doc), )) # Handle POST request @@ -557,7 +559,7 @@ def outbox_activity_replies(item_id): data = DB.outbox.find_one({'id': item_id, 'meta.deleted': False}) if not data: abort(404) - obj = activitypub.parse_activity(data) + obj = activitypub.parse_activity(data['activity']) if obj.type_enum != ActivityType.CREATE: abort(404) @@ -571,8 +573,9 @@ def outbox_activity_replies(item_id): DB.inbox, q=q, cursor=request.args.get('cursor'), - map_func=lambda doc: doc['activity'], + map_func=lambda doc: doc['activity']['object'], col_name=f'outbox/{item_id}/replies', + first_page=request.args.get('page') == 'first', )) @@ -583,7 +586,7 @@ def outbox_activity_likes(item_id): data = DB.outbox.find_one({'id': item_id, 'meta.deleted': False}) if not data: abort(404) - obj = activitypub.parse_activity(data) + obj = activitypub.parse_activity(data['activity']) if obj.type_enum != ActivityType.CREATE: abort(404) @@ -600,6 +603,7 @@ def outbox_activity_likes(item_id): cursor=request.args.get('cursor'), map_func=lambda doc: doc['activity'], col_name=f'outbox/{item_id}/likes', + first_page=request.args.get('page') == 'first', )) @@ -610,7 +614,7 @@ def outbox_activity_shares(item_id): data = DB.outbox.find_one({'id': item_id, 'meta.deleted': False}) if not data: abort(404) - obj = activitypub.parse_activity(data) + obj = activitypub.parse_activity(data['activity']) if obj.type_enum != ActivityType.CREATE: abort(404) @@ -627,6 +631,7 @@ def outbox_activity_shares(item_id): cursor=request.args.get('cursor'), map_func=lambda doc: doc['activity'], col_name=f'outbox/{item_id}/shares', + first_page=request.args.get('page') == 'first', )) @@ -744,16 +749,26 @@ def api_user_key(): return flask_jsonify(api_key=ADMIN_API_KEY) -def _user_api_get_note(): +def _user_api_arg(key: str) -> str: + """Try to get the given key from the requests, try JSON body, form data and query arg.""" if request.is_json: - oid = request.json.get('id') + oid = request.json.get(key) else: - oid = request.args.get('id') or request.form.get('id') + oid = request.args.get(key) or request.form.get(key) if not oid: - raise ValueError('missing id') + raise ValueError(f'missing {key}') - return activitypub.parse_activity(OBJECT_SERVICE.get(oid), expected=ActivityType.NOTE) + return oid + + +def _user_api_get_note(from_outbox: bool = False): + oid = _user_api_arg('id') + note = activitypub.parse_activity(OBJECT_SERVICE.get(oid), expected=ActivityType.NOTE) + if from_outbox and not note.id.startswith(ID): + raise NotFromOutboxError(f'cannot delete {note.id}, id must be owned by the server') + + return note def _user_api_response(**kwargs): @@ -769,64 +784,50 @@ def _user_api_response(**kwargs): @api_required def api_delete(): """API endpoint to delete a Note activity.""" - note = _user_api_get_note() + note = _user_api_get_note(from_outbox=True) + delete = note.build_delete() delete.post_to_outbox() return _user_api_response(activity=delete.id) -@app.route('/api/boost') +@app.route('/api/boost', methods=['POST']) @api_required def api_boost(): - # FIXME(tsileo): ensure a Note and not a Create is given - oid = request.args.get('id') - obj = activitypub.parse_activity(OBJECT_SERVICE.get(oid)) - announce = obj.build_announce() - announce.post_to_outbox() - if request.args.get('redirect'): - return redirect(request.args.get('redirect')) - return Response( - status=201, - headers={'Microblogpub-Created-Activity': announce.id}, - ) + note = _user_api_get_note() -@app.route('/api/like') + announce = note.build_announce() + announce.post_to_outbox() + + return _user_api_response(activity=announce.id) + + +@app.route('/api/like', methods=['POST']) @api_required def api_like(): - # FIXME(tsileo): ensure a Note and not a Create is given - oid = request.args.get('id') - obj = activitypub.parse_activity(OBJECT_SERVICE.get(oid)) - if not obj: - raise ValueError(f'unkown {oid} object') - like = obj.build_like() + note = _user_api_get_note() + + like = note.build_like() like.post_to_outbox() - if request.args.get('redirect'): - return redirect(request.args.get('redirect')) - return Response( - status=201, - headers={'Microblogpub-Created-Activity': like.id}, - ) + + return _user_api_response(activity=like.id) -@app.route('/api/undo', methods=['GET', 'POST']) +@app.route('/api/undo', methods=['POST']) @api_required def api_undo(): - oid = request.args.get('id') + oid = _user_api_arg('id') doc = DB.outbox.find_one({'$or': [{'id': oid}, {'remote_id': oid}]}) - undo_id = None - if doc: - obj = activitypub.parse_activity(doc.get('activity')) - # FIXME(tsileo): detect already undo-ed and make this API call idempotent - undo = obj.build_undo() - undo.post_to_outbox() - undo_id = undo.id - if request.args.get('redirect'): - return redirect(request.args.get('redirect')) - return Response( - status=201, - headers={'Microblogpub-Created-Activity': undo_id}, - ) + if not doc: + raise ActivityNotFoundError(f'cannot found {oid}') + + obj = activitypub.parse_activity(doc.get('activity')) + # FIXME(tsileo): detect already undo-ed and make this API call idempotent + undo = obj.build_undo() + undo.post_to_outbox() + + return _user_api_response(activity=undo.id) @app.route('/stream') @@ -980,22 +981,27 @@ def api_upload(): ) -@app.route('/api/new_note') +@app.route('/api/new_note', methods=['POST']) @api_required def api_new_note(): - source = request.args.get('content') + source = _user_api_arg('content') if not source: raise ValueError('missing content') - reply = None - if request.args.get('reply'): - reply = activitypub.parse_activity(OBJECT_SERVICE.get(request.args.get('reply'))) - source = request.args.get('content') + _reply, reply = None, None + try: + _reply = _user_api_arg('reply') + except ValueError: + pass + content, tags = parse_markdown(source) to = request.args.get('to') cc = [ID+'/followers'] - if reply: + + if _reply: + reply = activitypub.parse_activity(OBJECT_SERVICE.get(_reply)) cc.append(reply.attributedTo) + for tag in tags: if tag['type'] == 'Mention': cc.append(tag['href']) @@ -1003,7 +1009,7 @@ def api_new_note(): note = activitypub.Note( cc=cc, to=[to if to else config.AS_PUBLIC], - content=content, # TODO(tsileo): handle markdown + content=content, tag=tags, source={'mediaType': 'text/markdown', 'content': source}, inReplyTo=reply.id if reply else None @@ -1011,11 +1017,8 @@ def api_new_note(): create = note.build_create() create.post_to_outbox() - return Response( - status=201, - response='OK', - headers={'Microblogpub-Created-Activity': create.id}, - ) + return _user_api_response(activity=create.id) + @app.route('/api/stream') @api_required @@ -1026,41 +1029,38 @@ def api_stream(): ) -@app.route('/api/block') +@app.route('/api/block', methods=['POST']) @api_required def api_block(): - # FIXME(tsileo): ensure it's a Person ID - actor = request.args.get('actor') - if not actor: - raise ValueError('missing actor') - if DB.outbox.find_one({'type': ActivityType.BLOCK.value, - 'activity.object': actor, - 'meta.undo': False}): - return Response(status=201) + actor = _user_api_arg('actor') + + existing = DB.outbox.find_one({ + 'type': ActivityType.BLOCK.value, + 'activity.object': actor, + 'meta.undo': False, + }) + if existing: + return _user_api_response(activity=existing['activity']['id']) block = activitypub.Block(object=actor) block.post_to_outbox() - return Response( - status=201, - headers={'Microblogpub-Created-Activity': block.id}, - ) + + return _user_api_response(activity=block.id) -@app.route('/api/follow') +@app.route('/api/follow', methods=['POST']) @api_required def api_follow(): - actor = request.args.get('actor') - if not actor: - raise ValueError('missing actor') - if DB.following.find({'remote_actor': actor}).count() > 0: - return Response(status=201) + actor = _user_api_arg('actor') + + existing = DB.following.find_one({'remote_actor': actor}) + if existing: + return _user_api_response(activity=existing['activity']['id']) follow = activitypub.Follow(object=actor) follow.post_to_outbox() - return Response( - status=201, - headers={'Microblogpub-Created-Activity': follow.id}, - ) + + return _user_api_response(activity=follow.id) @app.route('/followers') diff --git a/docker-compose-tests.yml b/docker-compose-tests.yml index a834b93..280f6c3 100644 --- a/docker-compose-tests.yml +++ b/docker-compose-tests.yml @@ -1,7 +1,7 @@ version: '3.5' services: web: - build: . + image: 'microblogpub:latest' ports: - "${WEB_PORT}:5005" links: @@ -16,7 +16,7 @@ services: - MICROBLOGPUB_DEBUG=1 celery: # image: "instance1_web" - build: . + image: 'microblogpub:latest' links: - mongo - rmq diff --git a/docker-compose.yml b/docker-compose.yml index 0218494..b7f9521 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '2' services: web: - build: . + image: 'microblogpub:latest' ports: - "${WEB_PORT}:5005" links: @@ -14,7 +14,7 @@ services: - MICROBLOGPUB_AMQP_BROKER=pyamqp://guest@rmq// - MICROBLOGPUB_MONGODB_HOST=mongo:27017 celery: - build: . + image: 'microblogpub:latest' links: - mongo - rmq diff --git a/tasks.py b/tasks.py index 7e45581..c9ce2b0 100644 --- a/tasks.py +++ b/tasks.py @@ -1,4 +1,5 @@ import os +import json import logging import random @@ -13,6 +14,7 @@ from config import KEY from config import USER_AGENT from utils.httpsig import HTTPSigAuth from utils.opengraph import fetch_og_metadata +from utils.linked_data_sig import generate_signature log = logging.getLogger(__name__) @@ -22,11 +24,14 @@ SigAuth = HTTPSigAuth(ID+'#main-key', KEY.privkey) @app.task(bind=True, max_retries=12) -def post_to_inbox(self, payload, to): +def post_to_inbox(self, payload: str, to: str) -> None: try: log.info('payload=%s', payload) + log.info('generating sig') + signed_payload = json.loads(payload) + generate_signature(signed_payload, KEY.privkey) log.info('to=%s', to) - resp = requests.post(to, data=payload, auth=SigAuth, headers={ + resp = requests.post(to, data=json.dumps(signed_payload), auth=SigAuth, headers={ 'Content-Type': HEADERS[1], 'Accept': HEADERS[1], 'User-Agent': USER_AGENT, diff --git a/tests/federation_test.py b/tests/federation_test.py index 9a6c5a1..e050afc 100644 --- a/tests/federation_test.py +++ b/tests/federation_test.py @@ -5,6 +5,9 @@ import requests from html2text import html2text from utils import activitypub_utils +from typing import Tuple +from typing import List + def resp2plaintext(resp): """Convert the body of a requests reponse to plain text in order to make basic assertions.""" @@ -17,107 +20,149 @@ class Instance(object): def __init__(self, name, host_url, docker_url=None): self.host_url = host_url self.docker_url = docker_url or host_url - self.session = requests.Session() self._create_delay = 10 - with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), f'fixtures/{name}/config/admin_api_key.key')) as f: + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), f'fixtures/{name}/config/admin_api_key.key') + ) as f: api_key = f.read() self._auth_headers = {'Authorization': f'Bearer {api_key}'} def _do_req(self, url, headers): + """Used to parse collection.""" url = url.replace(self.docker_url, self.host_url) resp = requests.get(url, headers=headers) resp.raise_for_status() return resp.json() def _parse_collection(self, payload=None, url=None): + """Parses a collection (go through all the pages).""" return activitypub_utils.parse_collection(url=url, payload=payload, do_req=self._do_req) def ping(self): """Ensures the homepage is reachable.""" - resp = self.session.get(f'{self.host_url}/') + resp = requests.get(f'{self.host_url}/') resp.raise_for_status() assert resp.status_code == 200 def debug(self): - resp = self.session.get(f'{self.host_url}/api/debug', headers={'Accept': 'application/json'}) + """Returns the debug infos (number of items in the inbox/outbox.""" + resp = requests.get( + f'{self.host_url}/api/debug', + headers={**self._auth_headers, 'Accept': 'application/json'}, + ) resp.raise_for_status() return resp.json() - + def drop_db(self): - resp = self.session.delete(f'{self.host_url}/api/debug', headers={'Accept': 'application/json'}) + """Drops the MongoDB DB.""" + resp = requests.delete( + f'{self.host_url}/api/debug', + headers={**self._auth_headers, 'Accept': 'application/json'}, + ) resp.raise_for_status() return resp.json() - def login(self): - resp = self.session.post(f'{self.host_url}/login', data={'pass': 'hello'}) - resp.raise_for_status() - assert resp.status_code == 200 - def block(self, actor_url) -> None: + """Blocks an actor.""" # Instance1 follows instance2 - resp = self.session.get(f'{self.host_url}/api/block', params={'actor': actor_url}) + resp = requests.post( + f'{self.host_url}/api/block', + params={'actor': actor_url}, + headers=self._auth_headers, + ) assert resp.status_code == 201 # We need to wait for the Follow/Accept dance time.sleep(self._create_delay/2) - return resp.headers.get('microblogpub-created-activity') + return resp.json().get('activity') - def follow(self, instance: 'Instance') -> None: + def follow(self, instance: 'Instance') -> str: + """Follows another instance.""" # Instance1 follows instance2 - resp = self.session.get(f'{self.host_url}/api/follow', params={'actor': instance.docker_url}) + resp = requests.post( + f'{self.host_url}/api/follow', + json={'actor': instance.docker_url}, + headers=self._auth_headers, + ) assert resp.status_code == 201 # We need to wait for the Follow/Accept dance time.sleep(self._create_delay) - return resp.headers.get('microblogpub-created-activity') + return resp.json().get('activity') - def new_note(self, content, reply=None): + def new_note(self, content, reply=None) -> str: + """Creates a new note.""" params = {'content': content} if reply: params['reply'] = reply - resp = self.session.get(f'{self.host_url}/api/new_note', params=params) - assert resp.status_code == 201 - time.sleep(self._create_delay) - return resp.headers.get('microblogpub-created-activity') - - def boost(self, activity_id): - resp = self.session.get(f'{self.host_url}/api/boost', params={'id': activity_id}) - assert resp.status_code == 201 - - time.sleep(self._create_delay) - return resp.headers.get('microblogpub-created-activity') - - def like(self, activity_id): - resp = self.session.get(f'{self.host_url}/api/like', params={'id': activity_id}) - assert resp.status_code == 201 - - time.sleep(self._create_delay) - return resp.headers.get('microblogpub-created-activity') - - def delete(self, oid: str) -> None: resp = requests.post( - f'{self.host_url}/api/note/delete', - json={'id': oid}, - headers=self._auth_headers, + f'{self.host_url}/api/new_note', + json=params, + headers=self._auth_headers, ) assert resp.status_code == 201 time.sleep(self._create_delay) return resp.json().get('activity') - def undo(self, oid: str) -> None: - resp = self.session.get(f'{self.host_url}/api/undo', params={'id': oid}) + def boost(self, oid: str) -> str: + """Creates an Announce activity.""" + resp = requests.post( + f'{self.host_url}/api/boost', + json={'id': oid}, + headers=self._auth_headers, + ) + assert resp.status_code == 201 + + time.sleep(self._create_delay) + return resp.json().get('activity') + + def like(self, oid: str) -> str: + """Creates a Like activity.""" + resp = requests.post( + f'{self.host_url}/api/like', + json={'id': oid}, + headers=self._auth_headers, + ) + assert resp.status_code == 201 + + time.sleep(self._create_delay) + return resp.json().get('activity') + + def delete(self, oid: str) -> str: + """Creates a Delete activity.""" + resp = requests.post( + f'{self.host_url}/api/note/delete', + json={'id': oid}, + headers=self._auth_headers, + ) + assert resp.status_code == 201 + + time.sleep(self._create_delay) + return resp.json().get('activity') + + def undo(self, oid: str) -> str: + """Creates a Undo activity.""" + resp = requests.post( + f'{self.host_url}/api/undo', + json={'id': oid}, + headers=self._auth_headers, + ) assert resp.status_code == 201 # We need to wait for the Follow/Accept dance time.sleep(self._create_delay) - return resp.headers.get('microblogpub-created-activity') + return resp.json().get('activity') - def followers(self): - resp = self.session.get(f'{self.host_url}/followers', headers={'Accept': 'application/activity+json'}) + def followers(self) -> List[str]: + """Parses the followers collection.""" + resp = requests.get( + f'{self.host_url}/followers', + headers={'Accept': 'application/activity+json'}, + ) resp.raise_for_status() data = resp.json() @@ -125,7 +170,11 @@ class Instance(object): return self._parse_collection(payload=data) def following(self): - resp = self.session.get(f'{self.host_url}/following', headers={'Accept': 'application/activity+json'}) + """Parses the following collection.""" + resp = requests.get( + f'{self.host_url}/following', + headers={'Accept': 'application/activity+json'}, + ) resp.raise_for_status() data = resp.json() @@ -133,38 +182,50 @@ class Instance(object): return self._parse_collection(payload=data) def outbox(self): - resp = self.session.get(f'{self.host_url}/following', headers={'Accept': 'application/activity+json'}) + """Returns the instance outbox.""" + resp = requests.get( + f'{self.host_url}/following', + headers={'Accept': 'application/activity+json'}, + ) resp.raise_for_status() return resp.json() def outbox_get(self, aid): - resp = self.session.get(aid.replace(self.docker_url, self.host_url), headers={'Accept': 'application/activity+json'}) + """Fetches a specific item from the instance outbox.""" + resp = requests.get( + aid.replace(self.docker_url, self.host_url), + headers={'Accept': 'application/activity+json'}, + ) resp.raise_for_status() return resp.json() def stream_jsonfeed(self): - resp = self.session.get(f'{self.host_url}/api/stream', headers={'Accept': 'application/json'}) + """Returns the "stream"'s JSON feed.""" + resp = requests.get( + f'{self.host_url}/api/stream', + headers={**self._auth_headers, 'Accept': 'application/json'}, + ) resp.raise_for_status() return resp.json() -def _instances(): +def _instances() -> Tuple[Instance, Instance]: + """Initializes the client for the two test instances.""" instance1 = Instance('instance1', 'http://localhost:5006', 'http://instance1_web_1:5005') instance1.ping() instance2 = Instance('instance2', 'http://localhost:5007', 'http://instance2_web_1:5005') instance2.ping() - # Login - instance1.login() + # Return the DB instance1.drop_db() - instance2.login() instance2.drop_db() - + return instance1, instance2 -def test_follow(): +def test_follow() -> None: + """instance1 follows instance2.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -181,6 +242,7 @@ def test_follow(): def test_follow_unfollow(): + """instance1 follows instance2, then unfollows it.""" instance1, instance2 = _instances() # Instance1 follows instance2 follow_id = instance1.follow(instance2) @@ -210,6 +272,7 @@ def test_follow_unfollow(): def test_post_content(): + """Instances follow each other, and instance1 creates a note.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -230,6 +293,7 @@ def test_post_content(): def test_block_and_post_content(): + """Instances follow each other, instance2 blocks instance1, instance1 creates a new note.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -251,6 +315,7 @@ def test_block_and_post_content(): def test_post_content_and_delete(): + """Instances follow each other, instance1 creates a new note, then deletes it.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -280,6 +345,7 @@ def test_post_content_and_delete(): def test_post_content_and_like(): + """Instances follow each other, instance1 creates a new note, instance2 likes it.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -302,10 +368,13 @@ def test_post_content_and_like(): note = instance1.outbox_get(f'{create_id}/activity') assert 'likes' in note assert note['likes']['totalItems'] == 1 - # assert note['likes']['items'][0]['id'] == like_id + likes = instance1._parse_collection(url=note['likes']['first']) + assert len(likes) == 1 + assert likes[0]['id'] == like_id -def test_post_content_and_like_unlike(): +def test_post_content_and_like_unlike() -> None: + """Instances follow each other, instance1 creates a new note, instance2 likes it, then unlikes it.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -328,8 +397,9 @@ def test_post_content_and_like_unlike(): note = instance1.outbox_get(f'{create_id}/activity') assert 'likes' in note assert note['likes']['totalItems'] == 1 - # FIXME(tsileo): parse the collection - # assert note['likes']['items'][0]['id'] == like_id + likes = instance1._parse_collection(url=note['likes']['first']) + assert len(likes) == 1 + assert likes[0]['id'] == like_id instance2.undo(like_id) @@ -342,7 +412,8 @@ def test_post_content_and_like_unlike(): assert note['likes']['totalItems'] == 0 -def test_post_content_and_boost(): +def test_post_content_and_boost() -> None: + """Instances follow each other, instance1 creates a new note, instance2 "boost" it.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -365,11 +436,13 @@ def test_post_content_and_boost(): note = instance1.outbox_get(f'{create_id}/activity') assert 'shares' in note assert note['shares']['totalItems'] == 1 - # FIXME(tsileo): parse the collection - # assert note['shares']['items'][0]['id'] == boost_id + shares = instance1._parse_collection(url=note['shares']['first']) + assert len(shares) == 1 + assert shares[0]['id'] == boost_id -def test_post_content_and_boost_unboost(): +def test_post_content_and_boost_unboost() -> None: + """Instances follow each other, instance1 creates a new note, instance2 "boost" it, then "unboost" it.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -392,8 +465,9 @@ def test_post_content_and_boost_unboost(): note = instance1.outbox_get(f'{create_id}/activity') assert 'shares' in note assert note['shares']['totalItems'] == 1 - # FIXME(tsileo): parse the collection - # assert note['shares']['items'][0]['id'] == boost_id + shares = instance1._parse_collection(url=note['shares']['first']) + assert len(shares) == 1 + assert shares[0]['id'] == boost_id instance2.undo(boost_id) @@ -406,7 +480,8 @@ def test_post_content_and_boost_unboost(): assert note['shares']['totalItems'] == 0 -def test_post_content_and_post_reply(): +def test_post_content_and_post_reply() -> None: + """Instances follow each other, instance1 creates a new note, instance2 replies to it.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -425,7 +500,10 @@ def test_post_content_and_post_reply(): assert len(instance2_inbox_stream['items']) == 1 assert instance2_inbox_stream['items'][0]['id'] == instance1_create_id - instance2_create_id = instance2.new_note(f'hey @instance1@{instance1.docker_url}', reply=f'{instance1_create_id}/activity') + instance2_create_id = instance2.new_note( + f'hey @instance1@{instance1.docker_url}', + reply=f'{instance1_create_id}/activity', + ) instance2_debug = instance2.debug() assert instance2_debug['inbox'] == 3 # An Follow, Accept and Create activity should be there assert instance2_debug['outbox'] == 3 # We've sent a Accept and a Follow and a Create activity @@ -441,10 +519,13 @@ def test_post_content_and_post_reply(): instance1_note = instance1.outbox_get(f'{instance1_create_id}/activity') assert 'replies' in instance1_note assert instance1_note['replies']['totalItems'] == 1 - # TODO(tsileo): inspect the `replies` collection + replies = instance1._parse_collection(url=instance1_note['replies']['first']) + assert len(replies) == 1 + assert replies[0]['id'] == f'{instance2_create_id}/activity' -def test_post_content_and_post_reply_and_delete(): +def test_post_content_and_post_reply_and_delete() -> None: + """Instances follow each other, instance1 creates a new note, instance2 replies to it, then deletes its reply.""" instance1, instance2 = _instances() # Instance1 follows instance2 instance1.follow(instance2) @@ -463,7 +544,10 @@ def test_post_content_and_post_reply_and_delete(): assert len(instance2_inbox_stream['items']) == 1 assert instance2_inbox_stream['items'][0]['id'] == instance1_create_id - instance2_create_id = instance2.new_note(f'hey @instance1@{instance1.docker_url}', reply=f'{instance1_create_id}/activity') + instance2_create_id = instance2.new_note( + f'hey @instance1@{instance1.docker_url}', + reply=f'{instance1_create_id}/activity', + ) instance2_debug = instance2.debug() assert instance2_debug['inbox'] == 3 # An Follow, Accept and Create activity should be there assert instance2_debug['outbox'] == 3 # We've sent a Accept and a Follow and a Create activity diff --git a/utils/errors.py b/utils/errors.py index 5356267..7ffe744 100644 --- a/utils/errors.py +++ b/utils/errors.py @@ -18,6 +18,9 @@ class Error(Exception): return f'{self.__class__.__qualname__}({self.message!r}, payload={self.payload!r}, status_code={self.status_code})' +class NotFromOutboxError(Error): + pass + class ActivityNotFoundError(Error): status_code = 404 diff --git a/utils/key.py b/utils/key.py index 056ee36..f5a2455 100644 --- a/utils/key.py +++ b/utils/key.py @@ -25,6 +25,7 @@ def get_secret_key(name: str, new_key: Callable[[], str] = _new_key) -> str: class Key(object): + DEFAULT_KEY_SIZE = 2048 def __init__(self, user: str, domain: str, create: bool = True) -> None: user = user.replace('.', '_') domain = domain.replace('.', '_') @@ -37,7 +38,7 @@ class Key(object): else: if not create: raise Exception('must init private key first') - k = RSA.generate(4096) + k = RSA.generate(self.DEFAULT_KEY_SIZE) self.privkey_pem = k.exportKey('PEM').decode('utf-8') self.pubkey_pem = k.publickey().exportKey('PEM').decode('utf-8') with open(key_path, 'w') as f: