diff --git a/README.md b/README.md index 5784e93..21e8eab 100644 --- a/README.md +++ b/README.md @@ -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 ``` +## 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 ' id=http://microblob.pub/outbox//activity +``` + +#### Response + +```json +{ + "activity": "https://microblog.pub/outbox/" +} +``` + ## 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 e4a64f3..b63af90 100644 --- a/activitypub.py +++ b/activitypub.py @@ -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']) + + 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: - 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(): fg = FeedGenerator() diff --git a/app.py b/app.py index 3d15b95..164e098 100644 --- a/app.py +++ b/app.py @@ -57,8 +57,9 @@ from utils.httpsig import HTTPSigAuth, verify_request from utils.key import get_secret_key from utils.webfinger import get_remote_follow_template 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 @@ -81,8 +82,10 @@ root_logger.setLevel(gunicorn_logger.level) JWT_SECRET = get_secret_key('jwt') JWT = JSONWebSignatureSerializer(JWT_SECRET) -with open('config/jwt_token', 'wb+') as f: - f.write(JWT.dumps({'type': 'admin_token'})) # type: ignore +def _admin_jwt_token() -> str: + 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) @@ -208,15 +211,20 @@ def login_required(f): def _api_required(): 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 # Token verification token = request.headers.get('Authorization', '').replace('Bearer ', '') if not token: + # IndieAuth token token = request.form.get('access_token', '') # Will raise a BadSignature on bad auth payload = JWT.loads(token) + logger.info(f'api call by {payload}') def api_required(f): @@ -249,6 +257,23 @@ def is_api_request(): return True 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 ####### @@ -636,20 +661,43 @@ def notifications(): ) -@app.route('/api/delete') -@api_required -def api_delete(): - # FIXME(tsileo): ensure a Note and not a Create is given - oid = request.args.get('id') - obj = activitypub.parse_activity(OBJECT_SERVICE.get(oid)) - delete = obj.build_delete() - delete.post_to_outbox() +@app.route('/api/key') +@login_required +def api_user_key(): + return flask_jsonify(api_key=ADMIN_API_KEY) + + +def _user_api_get_note(): + 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'): return redirect(request.args.get('redirect')) - return Response( - status=201, - headers={'Microblogpub-Created-Activity': delete.id}, - ) + + resp = flask_jsonify(**kwargs) + 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') @api_required diff --git a/tests/federation_test.py b/tests/federation_test.py index d65c35d..bc6045c 100644 --- a/tests/federation_test.py +++ b/tests/federation_test.py @@ -19,6 +19,7 @@ class Instance(object): self.docker_url = docker_url or host_url self.session = requests.Session() self._create_delay = 10 + self._auth_headers = {} def _do_req(self, url, headers): 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.raise_for_status() 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: # Instance1 follows instance2 @@ -95,11 +98,15 @@ class Instance(object): return resp.headers.get('microblogpub-created-activity') 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 time.sleep(self._create_delay) - return resp.headers.get('microblogpub-created-activity') + return resp.json().get('activity') def undo(self, oid: str) -> None: resp = self.session.get(f'{self.host_url}/api/undo', params={'id': oid}) diff --git a/utils/actor_service.py b/utils/actor_service.py index 6c1f35a..9982235 100644 --- a/utils/actor_service.py +++ b/utils/actor_service.py @@ -5,6 +5,7 @@ from urllib.parse import urlparse from Crypto.PublicKey import RSA from .urlutils import check_url +from .errors import ActivityNotFoundError logger = logging.getLogger(__name__) @@ -32,6 +33,9 @@ class ActorService(object): 'Accept': 'application/activity+json', '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() return resp.json() diff --git a/utils/errors.py b/utils/errors.py index 31e678e..5356267 100644 --- a/utils/errors.py +++ b/utils/errors.py @@ -1,6 +1,25 @@ 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): diff --git a/utils/key.py b/utils/key.py index 526b3be..e101af7 100644 --- a/utils/key.py +++ b/utils/key.py @@ -2,14 +2,18 @@ import os import binascii from Crypto.PublicKey import RSA +from typing import Callable 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' 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: f.write(k) return k diff --git a/utils/object_service.py b/utils/object_service.py index 9445550..1ebc0ce 100644 --- a/utils/object_service.py +++ b/utils/object_service.py @@ -2,6 +2,7 @@ import requests from urllib.parse import urlparse from .urlutils import check_url +from .errors import ActivityNotFoundError class ObjectService(object): @@ -20,6 +21,9 @@ class ObjectService(object): 'Accept': 'application/activity+json', '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() return resp.json() diff --git a/utils/urlutils.py b/utils/urlutils.py index be37c99..99f900d 100644 --- a/utils/urlutils.py +++ b/utils/urlutils.py @@ -5,11 +5,12 @@ import ipaddress from urllib.parse import urlparse from . import strtobool +from .errors import Error logger = logging.getLogger(__name__) -class InvalidURLError(Exception): +class InvalidURLError(Error): pass