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
|
$ 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.
|
||||||
|
|
|
@ -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
80
app.py
|
@ -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
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue