mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2024-12-22 13:14:28 +00:00
Start migration to little_boxes
This commit is contained in:
parent
eb25a28679
commit
c5295524c7
12 changed files with 370 additions and 2527 deletions
1328
activitypub.py
1328
activitypub.py
File diff suppressed because it is too large
Load diff
174
app.py
174
app.py
|
@ -67,7 +67,7 @@ from utils.errors import ActivityNotFoundError
|
||||||
|
|
||||||
|
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = get_secret_key('flask')
|
app.secret_key = get_secret_key('flask')
|
||||||
app.config.update(
|
app.config.update(
|
||||||
|
@ -137,23 +137,23 @@ def clean_html(html):
|
||||||
return bleach.clean(html, tags=ALLOWED_TAGS)
|
return bleach.clean(html, tags=ALLOWED_TAGS)
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter()
|
@app.template_filter()
|
||||||
def quote_plus(t):
|
def quote_plus(t):
|
||||||
return urllib.parse.quote_plus(t)
|
return urllib.parse.quote_plus(t)
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter()
|
@app.template_filter()
|
||||||
def is_from_outbox(t):
|
def is_from_outbox(t):
|
||||||
return t.startswith(ID)
|
return t.startswith(ID)
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter()
|
@app.template_filter()
|
||||||
def clean(html):
|
def clean(html):
|
||||||
return clean_html(html)
|
return clean_html(html)
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter()
|
@app.template_filter()
|
||||||
def html2plaintext(body):
|
def html2plaintext(body):
|
||||||
return html2text(body)
|
return html2text(body)
|
||||||
|
|
||||||
|
|
||||||
|
@ -183,7 +183,7 @@ def format_timeago(val):
|
||||||
return timeago.format(datetime.strptime(val, '%Y-%m-%dT%H:%M:%SZ'), datetime.utcnow())
|
return timeago.format(datetime.strptime(val, '%Y-%m-%dT%H:%M:%SZ'), datetime.utcnow())
|
||||||
except:
|
except:
|
||||||
return timeago.format(datetime.strptime(val, '%Y-%m-%dT%H:%M:%S.%fZ'), datetime.utcnow())
|
return timeago.format(datetime.strptime(val, '%Y-%m-%dT%H:%M:%S.%fZ'), datetime.utcnow())
|
||||||
|
|
||||||
return val
|
return val
|
||||||
|
|
||||||
def _is_img(filename):
|
def _is_img(filename):
|
||||||
|
@ -279,7 +279,7 @@ def handle_activitypub_error(error):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# App routes
|
# App routes
|
||||||
|
|
||||||
#######
|
#######
|
||||||
# Login
|
# Login
|
||||||
|
@ -487,7 +487,7 @@ def _build_thread(data, include_children=True):
|
||||||
def _flatten(node, level=0):
|
def _flatten(node, level=0):
|
||||||
node['_level'] = level
|
node['_level'] = level
|
||||||
thread.append(node)
|
thread.append(node)
|
||||||
|
|
||||||
for snode in sorted(idx[node['activity']['object']['id']]['_nodes'], key=lambda d: d['activity']['object']['published']):
|
for snode in sorted(idx[node['activity']['object']['id']]['_nodes'], key=lambda d: d['activity']['object']['published']):
|
||||||
_flatten(snode, level=level+1)
|
_flatten(snode, level=level+1)
|
||||||
_flatten(idx[root_id])
|
_flatten(idx[root_id])
|
||||||
|
@ -495,10 +495,10 @@ def _build_thread(data, include_children=True):
|
||||||
return thread
|
return thread
|
||||||
|
|
||||||
|
|
||||||
@app.route('/note/<note_id>')
|
@app.route('/note/<note_id>')
|
||||||
def note_by_id(note_id):
|
def note_by_id(note_id):
|
||||||
data = DB.outbox.find_one({'id': note_id})
|
data = DB.outbox.find_one({'id': note_id})
|
||||||
if not data:
|
if not data:
|
||||||
abort(404)
|
abort(404)
|
||||||
if data['meta'].get('deleted', False):
|
if data['meta'].get('deleted', False):
|
||||||
abort(410)
|
abort(410)
|
||||||
|
@ -511,7 +511,7 @@ def note_by_id(note_id):
|
||||||
'$or': [{'activity.object.id': data['activity']['object']['id']},
|
'$or': [{'activity.object.id': data['activity']['object']['id']},
|
||||||
{'activity.object': data['activity']['object']['id']}],
|
{'activity.object': data['activity']['object']['id']}],
|
||||||
}))
|
}))
|
||||||
likes = [ACTOR_SERVICE.get(doc['activity']['actor']) for doc in likes]
|
likes = [ACTOR_SERVICE.get(doc['activity']['actor']) for doc in likes]
|
||||||
|
|
||||||
shares = list(DB.inbox.find({
|
shares = list(DB.inbox.find({
|
||||||
'meta.undo': False,
|
'meta.undo': False,
|
||||||
|
@ -519,7 +519,7 @@ def note_by_id(note_id):
|
||||||
'$or': [{'activity.object.id': data['activity']['object']['id']},
|
'$or': [{'activity.object.id': data['activity']['object']['id']},
|
||||||
{'activity.object': data['activity']['object']['id']}],
|
{'activity.object': data['activity']['object']['id']}],
|
||||||
}))
|
}))
|
||||||
shares = [ACTOR_SERVICE.get(doc['activity']['actor']) for doc in shares]
|
shares = [ACTOR_SERVICE.get(doc['activity']['actor']) for doc in shares]
|
||||||
|
|
||||||
return render_template('note.html', likes=likes, shares=shares, me=ME, thread=thread, note=data)
|
return render_template('note.html', likes=likes, shares=shares, me=ME, thread=thread, note=data)
|
||||||
|
|
||||||
|
@ -536,7 +536,7 @@ def nodeinfo():
|
||||||
'openRegistrations': False,
|
'openRegistrations': False,
|
||||||
'usage': {'users': {'total': 1}, 'localPosts': DB.outbox.count()},
|
'usage': {'users': {'total': 1}, 'localPosts': DB.outbox.count()},
|
||||||
'metadata': {
|
'metadata': {
|
||||||
'sourceCode': 'https://github.com/tsileo/microblog.pub',
|
'sourceCode': 'https://github.com/tsileo/microblog.pub',
|
||||||
'nodeName': f'@{USERNAME}@{DOMAIN}',
|
'nodeName': f'@{USERNAME}@{DOMAIN}',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -551,7 +551,7 @@ def wellknown_nodeinfo():
|
||||||
'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
|
'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
|
||||||
'href': f'{ID}/nodeinfo',
|
'href': f'{ID}/nodeinfo',
|
||||||
}
|
}
|
||||||
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -616,11 +616,11 @@ def activity_from_doc(raw_doc: Dict[str, Any], embed: bool = False) -> Dict[str,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/outbox', methods=['GET', 'POST'])
|
@app.route('/outbox', methods=['GET', 'POST'])
|
||||||
def outbox():
|
def outbox():
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
if not is_api_request():
|
if not is_api_request():
|
||||||
abort(404)
|
abort(404)
|
||||||
# TODO(tsileo): filter the outbox if not authenticated
|
# TODO(tsileo): filter the outbox if not authenticated
|
||||||
# FIXME(tsileo): filter deleted, add query support for build_ordered_collection
|
# FIXME(tsileo): filter deleted, add query support for build_ordered_collection
|
||||||
q = {
|
q = {
|
||||||
|
@ -639,7 +639,7 @@ def outbox():
|
||||||
_api_required()
|
_api_required()
|
||||||
except BadSignature:
|
except BadSignature:
|
||||||
abort(401)
|
abort(401)
|
||||||
|
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
print(data)
|
print(data)
|
||||||
activity = activitypub.parse_activity(data)
|
activity = activitypub.parse_activity(data)
|
||||||
|
@ -785,7 +785,7 @@ def admin():
|
||||||
col_followers=DB.followers.count(),
|
col_followers=DB.followers.count(),
|
||||||
col_following=DB.following.count(),
|
col_following=DB.following.count(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/new', methods=['GET'])
|
@app.route('/new', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -833,7 +833,7 @@ def notifications():
|
||||||
'meta.deleted': False,
|
'meta.deleted': False,
|
||||||
}
|
}
|
||||||
# TODO(tsileo): also include replies via regex on Create replyTo
|
# TODO(tsileo): also include replies via regex on Create replyTo
|
||||||
q = {'$or': [q, {'type': 'Follow'}, {'type': 'Accept'}, {'type': 'Undo', 'activity.object.type': 'Follow'},
|
q = {'$or': [q, {'type': 'Follow'}, {'type': 'Accept'}, {'type': 'Undo', 'activity.object.type': 'Follow'},
|
||||||
{'type': 'Announce', 'activity.object': {'$regex': f'^{BASE_URL}'}},
|
{'type': 'Announce', 'activity.object': {'$regex': f'^{BASE_URL}'}},
|
||||||
{'type': 'Create', 'activity.object.inReplyTo': {'$regex': f'^{BASE_URL}'}},
|
{'type': 'Create', 'activity.object.inReplyTo': {'$regex': f'^{BASE_URL}'}},
|
||||||
]}
|
]}
|
||||||
|
@ -1004,27 +1004,27 @@ def stream():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/inbox', methods=['GET', 'POST'])
|
@app.route('/inbox', methods=['GET', 'POST'])
|
||||||
def inbox():
|
def inbox():
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
if not is_api_request():
|
if not is_api_request():
|
||||||
abort(404)
|
abort(404)
|
||||||
try:
|
try:
|
||||||
_api_required()
|
_api_required()
|
||||||
except BadSignature:
|
except BadSignature:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
return jsonify(**activitypub.build_ordered_collection(
|
return jsonify(**activitypub.build_ordered_collection(
|
||||||
DB.inbox,
|
DB.inbox,
|
||||||
q={'meta.deleted': False},
|
q={'meta.deleted': False},
|
||||||
cursor=request.args.get('cursor'),
|
cursor=request.args.get('cursor'),
|
||||||
map_func=lambda doc: remove_context(doc['activity']),
|
map_func=lambda doc: remove_context(doc['activity']),
|
||||||
))
|
))
|
||||||
|
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
logger.debug(f'req_headers={request.headers}')
|
logger.debug(f'req_headers={request.headers}')
|
||||||
logger.debug(f'raw_data={data}')
|
logger.debug(f'raw_data={data}')
|
||||||
try:
|
try:
|
||||||
if not verify_request(ACTOR_SERVICE):
|
if not verify_request(ACTOR_SERVICE):
|
||||||
raise Exception('failed to verify request')
|
raise Exception('failed to verify request')
|
||||||
except Exception:
|
except Exception:
|
||||||
|
@ -1039,13 +1039,13 @@ def inbox():
|
||||||
response=json.dumps({'error': 'failed to verify request (using HTTP signatures or fetching the IRI)'}),
|
response=json.dumps({'error': 'failed to verify request (using HTTP signatures or fetching the IRI)'}),
|
||||||
)
|
)
|
||||||
|
|
||||||
activity = activitypub.parse_activity(data)
|
activity = activitypub.parse_activity(data)
|
||||||
logger.debug(f'inbox activity={activity}/{data}')
|
logger.debug(f'inbox activity={activity}/{data}')
|
||||||
activity.process_from_inbox()
|
activity.process_from_inbox()
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
status=201,
|
status=201,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/debug', methods=['GET', 'DELETE'])
|
@app.route('/api/debug', methods=['GET', 'DELETE'])
|
||||||
|
@ -1082,17 +1082,17 @@ def api_upload():
|
||||||
print('upload OK')
|
print('upload OK')
|
||||||
print(filename)
|
print(filename)
|
||||||
attachment = [
|
attachment = [
|
||||||
{'mediaType': mtype,
|
{'mediaType': mtype,
|
||||||
'name': rfilename,
|
'name': rfilename,
|
||||||
'type': 'Document',
|
'type': 'Document',
|
||||||
'url': BASE_URL + f'/static/media/{filename}'
|
'url': BASE_URL + f'/static/media/{filename}'
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
print(attachment)
|
print(attachment)
|
||||||
content = request.args.get('content')
|
content = request.args.get('content')
|
||||||
to = request.args.get('to')
|
to = request.args.get('to')
|
||||||
note = activitypub.Note(
|
note = activitypub.Note(
|
||||||
cc=[ID+'/followers'],
|
cc=[ID+'/followers'],
|
||||||
to=[to if to else config.AS_PUBLIC],
|
to=[to if to else config.AS_PUBLIC],
|
||||||
content=content, # TODO(tsileo): handle markdown
|
content=content, # TODO(tsileo): handle markdown
|
||||||
attachment=attachment,
|
attachment=attachment,
|
||||||
|
@ -1104,30 +1104,30 @@ def api_upload():
|
||||||
print(create.to_dict())
|
print(create.to_dict())
|
||||||
create.post_to_outbox()
|
create.post_to_outbox()
|
||||||
print('posted')
|
print('posted')
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
status=201,
|
status=201,
|
||||||
response='OK',
|
response='OK',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/new_note', methods=['POST'])
|
@app.route('/api/new_note', methods=['POST'])
|
||||||
@api_required
|
@api_required
|
||||||
def api_new_note():
|
def api_new_note():
|
||||||
source = _user_api_arg('content')
|
source = _user_api_arg('content')
|
||||||
if not source:
|
if not source:
|
||||||
raise ValueError('missing content')
|
raise ValueError('missing content')
|
||||||
|
|
||||||
_reply, reply = None, None
|
_reply, reply = None, None
|
||||||
try:
|
try:
|
||||||
_reply = _user_api_arg('reply')
|
_reply = _user_api_arg('reply')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
content, tags = parse_markdown(source)
|
content, tags = parse_markdown(source)
|
||||||
to = request.args.get('to')
|
to = request.args.get('to')
|
||||||
cc = [ID+'/followers']
|
cc = [ID+'/followers']
|
||||||
|
|
||||||
if _reply:
|
if _reply:
|
||||||
reply = activitypub.parse_activity(OBJECT_SERVICE.get(_reply))
|
reply = activitypub.parse_activity(OBJECT_SERVICE.get(_reply))
|
||||||
cc.append(reply.attributedTo)
|
cc.append(reply.attributedTo)
|
||||||
|
@ -1136,8 +1136,8 @@ def api_new_note():
|
||||||
if tag['type'] == 'Mention':
|
if tag['type'] == 'Mention':
|
||||||
cc.append(tag['href'])
|
cc.append(tag['href'])
|
||||||
|
|
||||||
note = activitypub.Note(
|
note = activitypub.Note(
|
||||||
cc=list(set(cc)),
|
cc=list(set(cc)),
|
||||||
to=[to if to else config.AS_PUBLIC],
|
to=[to if to else config.AS_PUBLIC],
|
||||||
content=content,
|
content=content,
|
||||||
tag=tags,
|
tag=tags,
|
||||||
|
@ -1193,20 +1193,20 @@ def api_follow():
|
||||||
return _user_api_response(activity=follow.id)
|
return _user_api_response(activity=follow.id)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/followers')
|
@app.route('/followers')
|
||||||
def followers():
|
def followers():
|
||||||
if is_api_request():
|
if is_api_request():
|
||||||
return jsonify(
|
return jsonify(
|
||||||
**activitypub.build_ordered_collection(
|
**activitypub.build_ordered_collection(
|
||||||
DB.followers,
|
DB.followers,
|
||||||
cursor=request.args.get('cursor'),
|
cursor=request.args.get('cursor'),
|
||||||
map_func=lambda doc: doc['remote_actor'],
|
map_func=lambda doc: doc['remote_actor'],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
followers = [ACTOR_SERVICE.get(doc['remote_actor']) for doc in DB.followers.find(limit=50)]
|
followers = [ACTOR_SERVICE.get(doc['remote_actor']) for doc in DB.followers.find(limit=50)]
|
||||||
return render_template(
|
return render_template(
|
||||||
'followers.html',
|
'followers.html',
|
||||||
me=ME,
|
me=ME,
|
||||||
notes=DB.inbox.find({'object.object.type': 'Note'}).count(),
|
notes=DB.inbox.find({'object.object.type': 'Note'}).count(),
|
||||||
followers=DB.followers.count(),
|
followers=DB.followers.count(),
|
||||||
|
@ -1225,7 +1225,7 @@ def following():
|
||||||
map_func=lambda doc: doc['remote_actor'],
|
map_func=lambda doc: doc['remote_actor'],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
following = [ACTOR_SERVICE.get(doc['remote_actor']) for doc in DB.following.find(limit=50)]
|
following = [ACTOR_SERVICE.get(doc['remote_actor']) for doc in DB.following.find(limit=50)]
|
||||||
return render_template(
|
return render_template(
|
||||||
'following.html',
|
'following.html',
|
||||||
|
@ -1327,13 +1327,13 @@ def get_client_id_data(url):
|
||||||
|
|
||||||
|
|
||||||
@app.route('/indieauth/flow', methods=['POST'])
|
@app.route('/indieauth/flow', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def indieauth_flow():
|
def indieauth_flow():
|
||||||
auth = dict(
|
auth = dict(
|
||||||
scope=' '.join(request.form.getlist('scopes')),
|
scope=' '.join(request.form.getlist('scopes')),
|
||||||
me=request.form.get('me'),
|
me=request.form.get('me'),
|
||||||
client_id=request.form.get('client_id'),
|
client_id=request.form.get('client_id'),
|
||||||
state=request.form.get('state'),
|
state=request.form.get('state'),
|
||||||
redirect_uri=request.form.get('redirect_uri'),
|
redirect_uri=request.form.get('redirect_uri'),
|
||||||
response_type=request.form.get('response_type'),
|
response_type=request.form.get('response_type'),
|
||||||
)
|
)
|
||||||
|
@ -1354,14 +1354,14 @@ def indieauth_flow():
|
||||||
return redirect(red)
|
return redirect(red)
|
||||||
|
|
||||||
|
|
||||||
# @app.route('/indieauth', methods=['GET', 'POST'])
|
# @app.route('/indieauth', methods=['GET', 'POST'])
|
||||||
def indieauth_endpoint():
|
def indieauth_endpoint():
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
if not session.get('logged_in'):
|
if not session.get('logged_in'):
|
||||||
return redirect(url_for('login', next=request.url))
|
return redirect(url_for('login', next=request.url))
|
||||||
|
|
||||||
me = request.args.get('me')
|
me = request.args.get('me')
|
||||||
# FIXME(tsileo): ensure me == ID
|
# FIXME(tsileo): ensure me == ID
|
||||||
client_id = request.args.get('client_id')
|
client_id = request.args.get('client_id')
|
||||||
redirect_uri = request.args.get('redirect_uri')
|
redirect_uri = request.args.get('redirect_uri')
|
||||||
state = request.args.get('state', '')
|
state = request.args.get('state', '')
|
||||||
|
@ -1397,7 +1397,7 @@ def indieauth_endpoint():
|
||||||
abort(403)
|
abort(403)
|
||||||
return
|
return
|
||||||
|
|
||||||
session['logged_in'] = True
|
session['logged_in'] = True
|
||||||
me = auth['me']
|
me = auth['me']
|
||||||
state = auth['state']
|
state = auth['state']
|
||||||
scope = ' '.join(auth['scope'])
|
scope = ' '.join(auth['scope'])
|
||||||
|
|
28
config.py
28
config.py
|
@ -6,10 +6,9 @@ import requests
|
||||||
from itsdangerous import JSONWebSignatureSerializer
|
from itsdangerous import JSONWebSignatureSerializer
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from utils import strtobool
|
from little_boxes.utils import strtobool
|
||||||
from utils.key import Key, KEY_DIR, get_secret_key
|
from utils.key import KEY_DIR, get_key, get_secret_key
|
||||||
from utils.actor_service import ActorService
|
|
||||||
from utils.object_service import ObjectService
|
|
||||||
|
|
||||||
def noop():
|
def noop():
|
||||||
pass
|
pass
|
||||||
|
@ -17,7 +16,7 @@ def noop():
|
||||||
|
|
||||||
CUSTOM_CACHE_HOOKS = False
|
CUSTOM_CACHE_HOOKS = False
|
||||||
try:
|
try:
|
||||||
from cache_hooks import purge as custom_cache_purge_hook
|
from cache_hooks import purge as custom_cache_purge_hook
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
custom_cache_purge_hook = noop
|
custom_cache_purge_hook = noop
|
||||||
|
|
||||||
|
@ -58,8 +57,6 @@ USER_AGENT = (
|
||||||
f'(microblog.pub/{VERSION}; +{BASE_URL})'
|
f'(microblog.pub/{VERSION}; +{BASE_URL})'
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO(tsileo): use 'mongo:27017;
|
|
||||||
# mongo_client = MongoClient(host=['mongo:27017'])
|
|
||||||
mongo_client = MongoClient(
|
mongo_client = MongoClient(
|
||||||
host=[os.getenv('MICROBLOGPUB_MONGODB_HOST', 'localhost:27017')],
|
host=[os.getenv('MICROBLOGPUB_MONGODB_HOST', 'localhost:27017')],
|
||||||
)
|
)
|
||||||
|
@ -67,23 +64,26 @@ mongo_client = MongoClient(
|
||||||
DB_NAME = '{}_{}'.format(USERNAME, DOMAIN.replace('.', '_'))
|
DB_NAME = '{}_{}'.format(USERNAME, DOMAIN.replace('.', '_'))
|
||||||
DB = mongo_client[DB_NAME]
|
DB = mongo_client[DB_NAME]
|
||||||
|
|
||||||
|
|
||||||
def _drop_db():
|
def _drop_db():
|
||||||
if not DEBUG_MODE:
|
if not DEBUG_MODE:
|
||||||
return
|
return
|
||||||
|
|
||||||
mongo_client.drop_database(DB_NAME)
|
mongo_client.drop_database(DB_NAME)
|
||||||
|
|
||||||
KEY = Key(USERNAME, DOMAIN, create=True)
|
|
||||||
|
KEY = get_key(ID, USERNAME, DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
JWT_SECRET = get_secret_key('jwt')
|
JWT_SECRET = get_secret_key('jwt')
|
||||||
JWT = JSONWebSignatureSerializer(JWT_SECRET)
|
JWT = JSONWebSignatureSerializer(JWT_SECRET)
|
||||||
|
|
||||||
|
|
||||||
def _admin_jwt_token() -> str:
|
def _admin_jwt_token() -> str:
|
||||||
return JWT.dumps({'me': 'ADMIN', 'ts': datetime.now().timestamp()}).decode('utf-8') # type: ignore
|
return JWT.dumps({'me': 'ADMIN', 'ts': datetime.now().timestamp()}).decode('utf-8') # type: ignore
|
||||||
|
|
||||||
ADMIN_API_KEY = get_secret_key('admin_api_key', _admin_jwt_token)
|
|
||||||
|
|
||||||
|
ADMIN_API_KEY = get_secret_key('admin_api_key', _admin_jwt_token)
|
||||||
|
|
||||||
ME = {
|
ME = {
|
||||||
"@context": [
|
"@context": [
|
||||||
|
@ -107,13 +107,5 @@ ME = {
|
||||||
"type": "Image",
|
"type": "Image",
|
||||||
"url": ICON_URL,
|
"url": ICON_URL,
|
||||||
},
|
},
|
||||||
"publicKey": {
|
"publicKey": KEY.to_dict(),
|
||||||
"id": ID+"#main-key",
|
|
||||||
"owner": ID,
|
|
||||||
"publicKeyPem": KEY.pubkey_pem,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
print(ME)
|
|
||||||
|
|
||||||
ACTOR_SERVICE = ActorService(USER_AGENT, DB.actors_cache, ID, ME, DB.instances)
|
|
||||||
OBJECT_SERVICE = ObjectService(USER_AGENT, DB.objects_cache, DB.inbox, DB.outbox, DB.instances)
|
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
# Little Boxes
|
|
||||||
|
|
||||||
Tiny ActivityPub framework written in Python, both database and server agnostic.
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
```python
|
|
||||||
from little_boxes import activitypub as ap
|
|
||||||
|
|
||||||
from mydb import db_client
|
|
||||||
|
|
||||||
|
|
||||||
class MyBackend(BaseBackend):
|
|
||||||
|
|
||||||
def __init__(self, db_connection):
|
|
||||||
self.db_connection = db_connection
|
|
||||||
|
|
||||||
def inbox_new(self, as_actor, activity):
|
|
||||||
# Save activity as "as_actor"
|
|
||||||
# [...]
|
|
||||||
|
|
||||||
def post_to_remote_inbox(self, as_actor, payload, recipient):
|
|
||||||
# Send the activity to the remote actor
|
|
||||||
# [...]
|
|
||||||
|
|
||||||
|
|
||||||
db_con = db_client()
|
|
||||||
my_backend = MyBackend(db_con)
|
|
||||||
|
|
||||||
ap.use_backend(my_backend)
|
|
||||||
|
|
||||||
me = ap.Person({}) # Init an actor
|
|
||||||
outbox = ap.Outbox(me)
|
|
||||||
|
|
||||||
follow = ap.Follow(actor=me, object='http://iri-i-want-follow')
|
|
||||||
outbox.post(follow)
|
|
||||||
```
|
|
|
@ -1,12 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def strtobool(s: str) -> bool:
|
|
||||||
if s in ['y', 'yes', 'true', 'on', '1']:
|
|
||||||
return True
|
|
||||||
if s in ['n', 'no', 'false', 'off', '0']:
|
|
||||||
return False
|
|
||||||
|
|
||||||
raise ValueError(f'cannot convert {s} to bool')
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,55 +0,0 @@
|
||||||
"""Errors raised by this package."""
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Dict
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class Error(Exception):
|
|
||||||
"""HTTP-friendly base error, with a status code, a message and an optional payload."""
|
|
||||||
status_code = 400
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, message: str,
|
|
||||||
status_code: Optional[int] = None,
|
|
||||||
payload: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> 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) -> Dict[str, Any]:
|
|
||||||
rv = dict(self.payload or ())
|
|
||||||
rv['message'] = self.message
|
|
||||||
return rv
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f'{self.__class__.__qualname__}({self.message!r}, payload={self.payload!r}, status_code={self.status_code})'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ActorBlockedError(Error):
|
|
||||||
"""Raised when an activity from a blocked actor is received."""
|
|
||||||
|
|
||||||
|
|
||||||
class NotFromOutboxError(Error):
|
|
||||||
"""Raised when an activity targets an object from the inbox when an object from the oubox was expected."""
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityNotFoundError(Error):
|
|
||||||
"""Raised when an activity is not found."""
|
|
||||||
status_code = 404
|
|
||||||
|
|
||||||
|
|
||||||
class BadActivityError(Error):
|
|
||||||
"""Raised when an activity could not be parsed/initialized."""
|
|
||||||
|
|
||||||
|
|
||||||
class RecursionLimitExceededError(BadActivityError):
|
|
||||||
"""Raised when the recursion limit for fetching remote object was exceeded (likely a collection)."""
|
|
||||||
|
|
||||||
|
|
||||||
class UnexpectedActivityTypeError(BadActivityError):
|
|
||||||
"""Raised when an another activty was expected."""
|
|
|
@ -1,47 +0,0 @@
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import ipaddress
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from . import strtobool
|
|
||||||
from .errors import Error
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidURLError(Error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def is_url_valid(url: str) -> bool:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
if parsed.scheme not in ['http', 'https']:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# XXX in debug mode, we want to allow requests to localhost to test the federation with local instances
|
|
||||||
debug_mode = strtobool(os.getenv('MICROBLOGPUB_DEBUG', 'false'))
|
|
||||||
if debug_mode:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if parsed.hostname in ['localhost']:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
ip_address = socket.getaddrinfo(parsed.hostname, parsed.port or 80)[0][4][0]
|
|
||||||
except socket.gaierror:
|
|
||||||
logger.exception(f'failed to lookup url {url}')
|
|
||||||
return False
|
|
||||||
|
|
||||||
if ipaddress.ip_address(ip_address).is_private:
|
|
||||||
logger.info(f'rejecting private URL {url}')
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def check_url(url: str) -> None:
|
|
||||||
if not is_url_valid(url):
|
|
||||||
raise InvalidURLError(f'"{url}" is invalid')
|
|
||||||
|
|
||||||
return None
|
|
|
@ -1,60 +0,0 @@
|
||||||
"""Contains some ActivityPub related utils."""
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Callable
|
|
||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
from .errors import RecursionLimitExceededError
|
|
||||||
from .errors import UnexpectedActivityTypeError
|
|
||||||
|
|
||||||
|
|
||||||
def parse_collection(
|
|
||||||
payload: Optional[Dict[str, Any]] = None,
|
|
||||||
url: Optional[str] = None,
|
|
||||||
level: int = 0,
|
|
||||||
fetcher: Optional[Callable[[str], Dict[str, Any]]] = None,
|
|
||||||
) -> List[Any]:
|
|
||||||
"""Resolve/fetch a `Collection`/`OrderedCollection`."""
|
|
||||||
if not fetcher:
|
|
||||||
raise Exception('must provide a fetcher')
|
|
||||||
if level > 3:
|
|
||||||
raise RecursionLimitExceededError('recursion limit exceeded')
|
|
||||||
|
|
||||||
# Go through all the pages
|
|
||||||
out: List[Any] = []
|
|
||||||
if url:
|
|
||||||
payload = fetcher(url)
|
|
||||||
if not payload:
|
|
||||||
raise ValueError('must at least prove a payload or an URL')
|
|
||||||
|
|
||||||
if payload['type'] in ['Collection', 'OrderedCollection']:
|
|
||||||
if 'orderedItems' in payload:
|
|
||||||
return payload['orderedItems']
|
|
||||||
if 'items' in payload:
|
|
||||||
return payload['items']
|
|
||||||
if 'first' in payload:
|
|
||||||
if 'orderedItems' in payload['first']:
|
|
||||||
out.extend(payload['first']['orderedItems'])
|
|
||||||
if 'items' in payload['first']:
|
|
||||||
out.extend(payload['first']['items'])
|
|
||||||
n = payload['first'].get('next')
|
|
||||||
if n:
|
|
||||||
out.extend(parse_collection(url=n, level=level+1, fetcher=fetcher))
|
|
||||||
return out
|
|
||||||
|
|
||||||
while payload:
|
|
||||||
if payload['type'] in ['CollectionPage', 'OrderedCollectionPage']:
|
|
||||||
if 'orderedItems' in payload:
|
|
||||||
out.extend(payload['orderedItems'])
|
|
||||||
if 'items' in payload:
|
|
||||||
out.extend(payload['items'])
|
|
||||||
n = payload.get('next')
|
|
||||||
if n is None:
|
|
||||||
break
|
|
||||||
payload = fetcher(n)
|
|
||||||
else:
|
|
||||||
raise UnexpectedActivityTypeError('unexpected activity type {}'.format(payload['type']))
|
|
||||||
|
|
||||||
return out
|
|
|
@ -2,21 +2,19 @@ libsass
|
||||||
gunicorn
|
gunicorn
|
||||||
piexif
|
piexif
|
||||||
requests
|
requests
|
||||||
markdown
|
|
||||||
python-u2flib-server
|
python-u2flib-server
|
||||||
Flask
|
Flask
|
||||||
Flask-WTF
|
Flask-WTF
|
||||||
Celery
|
Celery
|
||||||
pymongo
|
pymongo
|
||||||
pyld
|
|
||||||
timeago
|
timeago
|
||||||
bleach
|
bleach
|
||||||
pycryptodome
|
|
||||||
html2text
|
html2text
|
||||||
feedgen
|
feedgen
|
||||||
itsdangerous
|
itsdangerous
|
||||||
bcrypt
|
bcrypt
|
||||||
mf2py
|
mf2py
|
||||||
passlib
|
passlib
|
||||||
pyyaml
|
|
||||||
git+https://github.com/erikriver/opengraph.git
|
git+https://github.com/erikriver/opengraph.git
|
||||||
|
git+https://github.com/tsileo/little-boxes.git
|
||||||
|
pyyaml
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
from little_boxes.activitypub import use_backend
|
|
||||||
from little_boxes.activitypub import BaseBackend
|
|
||||||
from little_boxes.activitypub import Outbox
|
|
||||||
from little_boxes.activitypub import Person
|
|
||||||
from little_boxes.activitypub import Follow
|
|
||||||
|
|
||||||
def test_little_boxes_follow():
|
|
||||||
back = BaseBackend()
|
|
||||||
use_backend(back)
|
|
||||||
|
|
||||||
me = back.setup_actor('Thomas', 'tom')
|
|
||||||
|
|
||||||
other = back.setup_actor('Thomas', 'tom2')
|
|
||||||
|
|
||||||
outbox = Outbox(me)
|
|
||||||
f = Follow(
|
|
||||||
actor=me.id,
|
|
||||||
object=other.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
outbox.post(f)
|
|
||||||
assert back.followers(other) == [me.id]
|
|
||||||
assert back.following(other) == []
|
|
||||||
|
|
||||||
assert back.followers(me) == []
|
|
||||||
assert back.following(me) == [other.id]
|
|
51
utils/key.py
51
utils/key.py
|
@ -1,22 +1,23 @@
|
||||||
import os
|
import os
|
||||||
import binascii
|
import binascii
|
||||||
|
|
||||||
from Crypto.PublicKey import RSA
|
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
KEY_DIR = os.path.join(
|
from little_boxes.key import Key
|
||||||
os.path.dirname(os.path.abspath(__file__)), '..', 'config'
|
|
||||||
)
|
KEY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config")
|
||||||
|
|
||||||
|
|
||||||
def _new_key() -> str:
|
def _new_key() -> str:
|
||||||
return binascii.hexlify(os.urandom(32)).decode('utf-8')
|
return binascii.hexlify(os.urandom(32)).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def get_secret_key(name: str, new_key: Callable[[], str] = _new_key) -> str:
|
def get_secret_key(name: str, new_key: Callable[[], str] = _new_key) -> str:
|
||||||
key_path = os.path.join(KEY_DIR, f'{name}.key')
|
"""Loads or generates a cryptographic key."""
|
||||||
|
key_path = os.path.join(KEY_DIR, f"{name}.key")
|
||||||
if not os.path.exists(key_path):
|
if not os.path.exists(key_path):
|
||||||
k = new_key()
|
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
|
||||||
|
|
||||||
|
@ -24,23 +25,19 @@ def get_secret_key(name: str, new_key: Callable[[], str] = _new_key) -> str:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
class Key(object):
|
def get_key(owner: str, user: str, domain: str) -> Key:
|
||||||
DEFAULT_KEY_SIZE = 2048
|
""""Loads or generates an RSA key."""
|
||||||
def __init__(self, user: str, domain: str, create: bool = True) -> None:
|
k = Key(owner)
|
||||||
user = user.replace('.', '_')
|
user = user.replace(".", "_")
|
||||||
domain = domain.replace('.', '_')
|
domain = domain.replace(".", "_")
|
||||||
key_path = os.path.join(KEY_DIR, f'key_{user}_{domain}.pem')
|
key_path = os.path.join(KEY_DIR, f"key_{user}_{domain}.pem")
|
||||||
if os.path.isfile(key_path):
|
if os.path.isfile(key_path):
|
||||||
with open(key_path) as f:
|
with open(key_path) as f:
|
||||||
self.privkey_pem = f.read()
|
privkey_pem = f.read()
|
||||||
self.privkey = RSA.importKey(self.privkey_pem)
|
k.load(privkey_pem)
|
||||||
self.pubkey_pem = self.privkey.publickey().exportKey('PEM').decode('utf-8')
|
else:
|
||||||
else:
|
k.new()
|
||||||
if not create:
|
with open(key_path, "w") as f:
|
||||||
raise Exception('must init private key first')
|
f.write(k.privkey_pem)
|
||||||
k = RSA.generate(self.DEFAULT_KEY_SIZE)
|
|
||||||
self.privkey_pem = k.exportKey('PEM').decode('utf-8')
|
return k
|
||||||
self.pubkey_pem = k.publickey().exportKey('PEM').decode('utf-8')
|
|
||||||
with open(key_path, 'w') as f:
|
|
||||||
f.write(self.privkey_pem)
|
|
||||||
self.privkey = k
|
|
||||||
|
|
Loading…
Reference in a new issue