forked from forks/microblog.pub
User API cleanup
This commit is contained in:
parent
45afd99098
commit
f8ee19b4d1
11 changed files with 422 additions and 178 deletions
|
@ -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
|
||||
|
|
11
Makefile
11
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
|
||||
|
|
144
README.md
144
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 <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.
|
||||
|
|
|
@ -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
|
||||
|
|
164
app.py
164
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:
|
||||
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()
|
||||
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},
|
||||
)
|
||||
|
||||
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,
|
||||
actor = _user_api_arg('actor')
|
||||
|
||||
existing = DB.outbox.find_one({
|
||||
'type': ActivityType.BLOCK.value,
|
||||
'activity.object': actor,
|
||||
'meta.undo': False}):
|
||||
return Response(status=201)
|
||||
'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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
9
tasks.py
9
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,
|
||||
|
|
|
@ -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,87 +20,120 @@ 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)
|
||||
|
||||
resp = requests.post(
|
||||
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.headers.get('microblogpub-created-activity')
|
||||
return resp.json().get('activity')
|
||||
|
||||
def boost(self, activity_id):
|
||||
resp = self.session.get(f'{self.host_url}/api/boost', params={'id': activity_id})
|
||||
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.headers.get('microblogpub-created-activity')
|
||||
return resp.json().get('activity')
|
||||
|
||||
def like(self, activity_id):
|
||||
resp = self.session.get(f'{self.host_url}/api/like', params={'id': activity_id})
|
||||
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.headers.get('microblogpub-created-activity')
|
||||
return resp.json().get('activity')
|
||||
|
||||
def delete(self, oid: str) -> None:
|
||||
def delete(self, oid: str) -> str:
|
||||
"""Creates a Delete activity."""
|
||||
resp = requests.post(
|
||||
f'{self.host_url}/api/note/delete',
|
||||
json={'id': oid},
|
||||
|
@ -108,16 +144,25 @@ class Instance(object):
|
|||
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 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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue