Lot of cleanup
This commit is contained in:
parent
559c65f474
commit
6aea610fb6
9 changed files with 143 additions and 25 deletions
26
README.md
26
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 <token>' id=http://microblob.pub/outbox/<node_id>/activity
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"activity": "https://microblog.pub/outbox/<delete_id>"
|
||||
}
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
PRs are welcome, please open an issue to start a discussion before your start any work.
|
||||
|
|
|
@ -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()
|
||||
|
|
80
app.py
80
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
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue