diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 56813f9d..25df9423 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -1,3 +1,4 @@ +import werkzeug.exceptions from sqlalchemy import text from app import db @@ -6,9 +7,10 @@ from flask import request, Response, render_template, current_app, abort, jsonif from app.activitypub.signature import HttpSignature from app.community.routes import show_community -from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan +from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \ post_to_activity, find_actor_or_create +from app.utils import gibberish INBOX = [] @@ -200,18 +202,77 @@ def community_profile(actor): @bp.route('/inbox', methods=['GET', 'POST']) def shared_inbox(): if request.method == 'POST': - request_json = request.get_json() + # save all incoming data to aid in debugging and development + activity_log = ActivityPubLog(direction='in', activity_json=request.data, result='failure') + + try: + request_json = request.get_json(force=True) + except werkzeug.exceptions.BadRequest as e: + activity_log.exception_message = 'Unable to parse json body: ' + e.description + db.session.add(activity_log) + db.session.commit() + return + else: + if 'id' in request_json: + activity_log.activity_id = request_json['id'] + actor = find_actor_or_create(request_json['actor']) if actor is not None: if HttpSignature.verify_request(request, actor.public_key, skip_date=True): if 'type' in request_json: + activity_log.activity_type = request_json['type'] if request_json['type'] == 'Announce': ... + # remote user wants to follow one of our communities elif request_json['type'] == 'Follow': - # todo: send accept message if not banned - banned = CommunityBan.query.filter_by(user_id=current_user.id, - community_id=community.id).first() - ... + user_ap_id = request_json['actor'] + community_ap_id = request_json['object'] + follow_id = request_json['id'] + user = find_actor_or_create(user_ap_id) + community = find_actor_or_create(community_ap_id) + if user is not None and community is not None: + # check if user is banned from this community + banned = CommunityBan.query.filter_by(user_id=user.id, + community_id=community.id).first() + if banned is None: + if not user.subscribed(community): + member = CommunityMember(user_id=user.id, community_id=community.id) + db.session.add(member) + db.session.commit() + # send accept message to acknowledge the follow + accept = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + "actor": community.ap_profile_id, + "to": [ + user.ap_profile_id + ], + "object": { + "actor": user.ap_profile_id, + "to": None, + "object": community.ap_profile_id, + "type": "Follow", + "id": follow_id + }, + "type": "Accept", + "id": f"https://{current_app.config['SERVER_NAME']}/activities/accept/" + gibberish(32) + } + try: + HttpSignature.signed_request(user.ap_inbox_url, accept, community.private_key, + f"https://{current_app.config['SERVER_NAME']}/c/{community.name}#main-key") + except Exception as e: + accept_log = ActivityPubLog(direction='out', activity_json=json.dumps(accept), + result='failure', activity_id=accept['id'], + exception_message = 'could not send Accept' + str(e)) + db.session.add(accept_log) + db.session.commit() + return + activity_log.result = 'success' + else: + activity_log.exception_message = 'user is banned from this community' + # remote server is accepting our previous follow request elif request_json['type'] == 'Accept': if request_json['object']['type'] == 'Follow': community_ap_id = request_json['actor'] @@ -224,7 +285,15 @@ def shared_inbox(): member = CommunityMember(user_id=user.id, community_id=community.id) db.session.add(member) db.session.commit() + activity_log.result = 'success' + else: + activity_log.exception_message = 'Could not verify signature' + else: + activity_log.exception_message = 'Actor could not be found: ' + request_json['actor'] + + db.session.add(activity_log) + db.session.commit() @bp.route('/c//outbox', methods=['GET']) diff --git a/app/community/routes.py b/app/community/routes.py index 6a13b146..04118966 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -26,7 +26,7 @@ def add_local(): private_key, public_key = RsaKeys.generate_keypair() community = Community(title=form.community_name.data, name=form.url.data, description=form.description.data, rules=form.rules.data, nsfw=form.nsfw.data, private_key=private_key, - public_key=public_key, + public_key=public_key, ap_profile_id=current_app.config['SERVER_NAME'] + '/c/' + form.url.data, subscriptions_count=1) db.session.add(community) db.session.commit() diff --git a/app/models.py b/app/models.py index 3899ca25..3fb5f1b1 100644 --- a/app/models.py +++ b/app/models.py @@ -310,6 +310,7 @@ class CommunityMember(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow) +# people banned from communities class CommunityBan(db.Model): user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) community_id = db.Column(db.Integer, db.ForeignKey('community.id'), primary_key=True) @@ -373,6 +374,18 @@ class UserFollowRequest(db.Model): follow_id = db.Column(db.Integer, db.ForeignKey('user.id')) +# save every activity to a log, to aid debugging +class ActivityPubLog(db.Model): + id = db.Column(db.Integer, primary_key=True) + direction = db.Column(db.String(3)) # 'in' or 'out' + activity_id = db.Column(db.String(100), indexed=True) + activity_type = db.Column(db.String(50)) # e.g. 'Follow', 'Accept', 'Like', etc + activity_json = db.Column(db.Text) # the full json of the activity + result = db.Column(db.String(10)) # 'success' or 'failure' + exception_message = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + @login.user_loader def load_user(id): return User.query.get(int(id)) diff --git a/app/utils.py b/app/utils.py index d8d8c6fb..60ebd274 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,10 +1,8 @@ import functools - +import random import requests import os - from flask import current_app, json - from app import db from app.models import Settings @@ -30,6 +28,7 @@ def get_request(uri, params=None, headers=None) -> requests.Response: return response +# saves an arbitrary object into a persistent key-value store. Possibly redis would be faster than using the DB @functools.lru_cache(maxsize=100) def get_setting(name: str, default=None): setting = Settings.query.filter_by(name=name).first() @@ -39,6 +38,7 @@ def get_setting(name: str, default=None): return json.loads(setting.value) +# retrieves arbitrary object from persistent key-value store def set_setting(name: str, value): setting = Settings.query.filter_by(name=name).first() if setting is None: @@ -54,3 +54,10 @@ def file_get_contents(filename): with open(filename, 'r') as file: contents = file.read() return contents + + +random_chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + + +def gibberish(length: int = 10) -> str: + return "".join([random.choice(random_chars) for x in range(length)]) diff --git a/migrations/versions/f032dbdfbd1d_activitypub_debug_log.py b/migrations/versions/f032dbdfbd1d_activitypub_debug_log.py new file mode 100644 index 00000000..9c54d9c6 --- /dev/null +++ b/migrations/versions/f032dbdfbd1d_activitypub_debug_log.py @@ -0,0 +1,37 @@ +"""activitypub debug log + +Revision ID: f032dbdfbd1d +Revises: cc98a471a1ad +Create Date: 2023-09-09 20:06:28.257769 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f032dbdfbd1d' +down_revision = 'cc98a471a1ad' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('activity_pub_log', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('direction', sa.String(length=3), nullable=True), + sa.Column('activity_type', sa.String(length=50), nullable=True), + sa.Column('activity_json', sa.Text(), nullable=True), + sa.Column('result', sa.String(length=10), nullable=True), + sa.Column('exception_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('activity_pub_log') + # ### end Alembic commands ###