User API cleanup

This commit is contained in:
Thomas Sileo 2018-06-01 20:29:44 +02:00
parent 45afd99098
commit f8ee19b4d1
11 changed files with 422 additions and 178 deletions

View file

@ -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

View file

@ -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

144
README.md
View file

@ -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 <token>' id=http://microblob.pub/outbox/<node_id>/activity
$ http POST https://microblog.pub/api/note/delete Authorization:'Bearer <token>' id=http://microblob.pub/outbox/<note_id>/activity
```
#### Response
@ -115,6 +129,132 @@ $ http POST https://microblog.pub/api/note/delete Authorization:'Bearer <token>'
}
```
### 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 <token>' id=http://activity-iri.tld
```
#### Response
```json
{
"activity": "https://microblog.pub/outbox/<like_id>"
}
```
### 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 <token>' id=http://activity-iri.tld
```
#### Response
```json
{
"activity": "https://microblog.pub/outbox/<announce_id>"
}
```
### 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 <token>' actor=http://actor-iri.tld/
```
#### Response
```json
{
"activity": "https://microblog.pub/outbox/<block_id>"
}
```
### 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 <token>' actor=http://actor-iri.tld/
```
#### Response
```json
{
"activity": "https://microblog.pub/outbox/<follow_id>"
}
```
### 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 <token>' content=hello
```
#### Response
```json
{
"activity": "https://microblog.pub/outbox/<create_id>"
}
```
### GET /api/stream
#### Example
```shell
$ http GET https://microblog.pub/api/stream Authorization:'Bearer <token>'
```
#### Response
```json
```
## Contributions
PRs are welcome, please open an issue to start a discussion before your start any work.

View file

@ -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=<first item cursor>
if first_page:
return resp['first']
# XXX(tsileo): implements prev with prev=<first item cursor>?
return resp

174
app.py
View file

@ -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')

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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: