Lot of cleanup

This commit is contained in:
Thomas Sileo 2018-05-29 21:36:05 +02:00
parent 559c65f474
commit 6aea610fb6
9 changed files with 143 additions and 25 deletions

View file

@ -87,6 +87,32 @@ $ docker-compose -f docker-compose-dev.yml up -d
$ MICROBLOGPUB_DEBUG=1 FLASK_APP=app.py flask run -p 5005 --with-threads $ MICROBLOGPUB_DEBUG=1 FLASK_APP=app.py flask run -p 5005 --with-threads
``` ```
## 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.
### POST /api/note/delete{?id}
Delete the given note `id`.
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/note/delete Authorization:'Bearer <token>' id=http://microblob.pub/outbox/<node_id>/activity
```
#### Response
```json
{
"activity": "https://microblog.pub/outbox/<delete_id>"
}
```
## Contributions ## Contributions
PRs are welcome, please open an issue to start a discussion before your start any work. PRs are welcome, please open an issue to start a discussion before your start any work.

View file

@ -968,13 +968,18 @@ _ACTIVITY_TYPE_TO_CLS = {
} }
def parse_activity(payload: ObjectType) -> BaseActivity: def parse_activity(payload: ObjectType, expected: Optional[ActivityType] = None) -> BaseActivity:
t = ActivityType(payload['type']) t = ActivityType(payload['type'])
if expected and t != expected:
raise UnexpectedActivityTypeError(f'expected a {expected.name} activity, got a {payload["type"]}')
if t not in _ACTIVITY_TYPE_TO_CLS: if t not in _ACTIVITY_TYPE_TO_CLS:
raise ValueError('unsupported activity type') raise BadActivityTypeError(f'unsupported activity type {payload["type"]}')
return _ACTIVITY_TYPE_TO_CLS[t](**payload) activity = _ACTIVITY_TYPE_TO_CLS[t](**payload)
return activity
def gen_feed(): def gen_feed():
fg = FeedGenerator() fg = FeedGenerator()

80
app.py
View file

@ -57,8 +57,9 @@ from utils.httpsig import HTTPSigAuth, verify_request
from utils.key import get_secret_key from utils.key import get_secret_key
from utils.webfinger import get_remote_follow_template from utils.webfinger import get_remote_follow_template
from utils.webfinger import get_actor_url from utils.webfinger import get_actor_url
from utils.errors import Error
from utils.errors import UnexpectedActivityTypeError
from utils.errors import BadActivityError
from typing import Dict, Any from typing import Dict, Any
@ -81,8 +82,10 @@ root_logger.setLevel(gunicorn_logger.level)
JWT_SECRET = get_secret_key('jwt') JWT_SECRET = get_secret_key('jwt')
JWT = JSONWebSignatureSerializer(JWT_SECRET) JWT = JSONWebSignatureSerializer(JWT_SECRET)
with open('config/jwt_token', 'wb+') as f: def _admin_jwt_token() -> str:
f.write(JWT.dumps({'type': 'admin_token'})) # type: ignore return JWT.dumps({'me': 'ADMIN', 'ts': datetime.now().timestamp()}).decode('utf-8')
ADMIN_API_KEY = get_secret_key('admin_api_key', _admin_jwt_token)
SIG_AUTH = HTTPSigAuth(ID+'#main-key', KEY.privkey) SIG_AUTH = HTTPSigAuth(ID+'#main-key', KEY.privkey)
@ -208,15 +211,20 @@ def login_required(f):
def _api_required(): def _api_required():
if session.get('logged_in'): if session.get('logged_in'):
#if request.method not in ['GET', 'HEAD']:
# # If a standard API request is made with a "login session", it must havw a CSRF token
# csrf.protect()
return return
# Token verification # Token verification
token = request.headers.get('Authorization', '').replace('Bearer ', '') token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token: if not token:
# IndieAuth token
token = request.form.get('access_token', '') token = request.form.get('access_token', '')
# Will raise a BadSignature on bad auth # Will raise a BadSignature on bad auth
payload = JWT.loads(token) payload = JWT.loads(token)
logger.info(f'api call by {payload}')
def api_required(f): def api_required(f):
@ -249,6 +257,23 @@ def is_api_request():
return True return True
return False return False
@app.errorhandler(ValueError)
def handle_value_error(error):
logger.error(f'caught value error: {error!r}')
response = flask_jsonify(message=error.args[0])
response.status_code = 400
return response
@app.errorhandler(Error)
def handle_activitypub_error(error):
logger.error(f'caught activitypub error {error!r}')
response = flask_jsonify(error.to_dict())
response.status_code = error.status_code
return response
# App routes # App routes
####### #######
@ -636,20 +661,43 @@ def notifications():
) )
@app.route('/api/delete') @app.route('/api/key')
@api_required @login_required
def api_delete(): def api_user_key():
# FIXME(tsileo): ensure a Note and not a Create is given return flask_jsonify(api_key=ADMIN_API_KEY)
oid = request.args.get('id')
obj = activitypub.parse_activity(OBJECT_SERVICE.get(oid))
delete = obj.build_delete() def _user_api_get_note():
delete.post_to_outbox() if request.is_json():
oid = request.json.get('id')
else:
oid = request.args.get('id') or request.form.get('id')
if not oid:
raise ValueError('missing id')
return activitypub.parse_activity(OBJECT_SERVICE.get(oid), expected=ActivityType.NOTE)
def _user_api_response(**kwargs):
if request.args.get('redirect'): if request.args.get('redirect'):
return redirect(request.args.get('redirect')) return redirect(request.args.get('redirect'))
return Response(
status=201, resp = flask_jsonify(**kwargs)
headers={'Microblogpub-Created-Activity': delete.id}, resp.status_code = 201
) return resp
@app.route('/api/note/delete', methods=['POST'])
@api_required
def api_delete():
"""API endpoint to delete a Note activity."""
note = _user_api_get_note()
delete = note.build_delete()
delete.post_to_outbox()
return _user_api_response(activity=delete.id)
@app.route('/api/boost') @app.route('/api/boost')
@api_required @api_required

View file

@ -19,6 +19,7 @@ class Instance(object):
self.docker_url = docker_url or host_url self.docker_url = docker_url or host_url
self.session = requests.Session() self.session = requests.Session()
self._create_delay = 10 self._create_delay = 10
self._auth_headers = {}
def _do_req(self, url, headers): def _do_req(self, url, headers):
url = url.replace(self.docker_url, self.host_url) url = url.replace(self.docker_url, self.host_url)
@ -51,6 +52,8 @@ class Instance(object):
resp = self.session.post(f'{self.host_url}/login', data={'pass': 'hello'}) resp = self.session.post(f'{self.host_url}/login', data={'pass': 'hello'})
resp.raise_for_status() resp.raise_for_status()
assert resp.status_code == 200 assert resp.status_code == 200
api_key = self.session.get(f'{self.host_url}/api/key').json().get('api_key')
self._auth_headers = {'Authorization': f'Bearer {api_key}'}
def block(self, actor_url) -> None: def block(self, actor_url) -> None:
# Instance1 follows instance2 # Instance1 follows instance2
@ -95,11 +98,15 @@ class Instance(object):
return resp.headers.get('microblogpub-created-activity') return resp.headers.get('microblogpub-created-activity')
def delete(self, oid: str) -> None: def delete(self, oid: str) -> None:
resp = self.session.get(f'{self.host_url}/api/delete', params={'id': oid}) resp = requests.post(
f'{self.host_url}/api/note/delete',
json={'id': oid},
headers=self._auth_headers,
)
assert resp.status_code == 201 assert resp.status_code == 201
time.sleep(self._create_delay) time.sleep(self._create_delay)
return resp.headers.get('microblogpub-created-activity') return resp.json().get('activity')
def undo(self, oid: str) -> None: def undo(self, oid: str) -> None:
resp = self.session.get(f'{self.host_url}/api/undo', params={'id': oid}) resp = self.session.get(f'{self.host_url}/api/undo', params={'id': oid})

View file

@ -5,6 +5,7 @@ from urllib.parse import urlparse
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from .urlutils import check_url from .urlutils import check_url
from .errors import ActivityNotFoundError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -32,6 +33,9 @@ class ActorService(object):
'Accept': 'application/activity+json', 'Accept': 'application/activity+json',
'User-Agent': self._user_agent, 'User-Agent': self._user_agent,
}) })
if resp.status_code == 404:
raise ActivityNotFoundError(f'{actor_url} cannot be fetched, 404 not found error')
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()

View file

@ -1,6 +1,25 @@
class Error(Exception): class Error(Exception):
pass status_code = 400
def __init__(self, message, status_code=None, payload=None):
Exception.__init__(self)
self.message = message
if status_code is not None:
self.status_code = status_code
self.payload = payload
def to_dict(self):
rv = dict(self.payload or ())
rv['message'] = self.message
return rv
def __repr__(self):
return f'{self.__class__.__qualname__}({self.message!r}, payload={self.payload!r}, status_code={self.status_code})'
class ActivityNotFoundError(Error):
status_code = 404
class BadActivityError(Error): class BadActivityError(Error):

View file

@ -2,14 +2,18 @@ import os
import binascii import binascii
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from typing import Callable
KEY_DIR = 'config/' KEY_DIR = 'config/'
def get_secret_key(name:str) -> str: def _new_key() -> str:
return binascii.hexlify(os.urandom(32)).decode('utf-8')
def get_secret_key(name: str, new_key: Callable[[], str] = _new_key) -> str:
key_path = f'{KEY_DIR}{name}.key' key_path = f'{KEY_DIR}{name}.key'
if not os.path.exists(key_path): if not os.path.exists(key_path):
k = binascii.hexlify(os.urandom(32)).decode('utf-8') k = new_key()
with open(key_path, 'w+') as f: with open(key_path, 'w+') as f:
f.write(k) f.write(k)
return k return k

View file

@ -2,6 +2,7 @@ import requests
from urllib.parse import urlparse from urllib.parse import urlparse
from .urlutils import check_url from .urlutils import check_url
from .errors import ActivityNotFoundError
class ObjectService(object): class ObjectService(object):
@ -20,6 +21,9 @@ class ObjectService(object):
'Accept': 'application/activity+json', 'Accept': 'application/activity+json',
'User-Agent': self._user_agent, 'User-Agent': self._user_agent,
}) })
if resp.status_code == 404:
raise ActivityNotFoundError(f'{object_id} cannot be fetched, 404 error not found')
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()

View file

@ -5,11 +5,12 @@ import ipaddress
from urllib.parse import urlparse from urllib.parse import urlparse
from . import strtobool from . import strtobool
from .errors import Error
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class InvalidURLError(Exception): class InvalidURLError(Error):
pass pass