accept and follow with activitypub message logging

This commit is contained in:
rimu 2023-09-09 20:46:40 +12:00
parent 20dfc5a43b
commit bfc4b243bf
5 changed files with 136 additions and 10 deletions

View file

@ -1,3 +1,4 @@
import werkzeug.exceptions
from sqlalchemy import text from sqlalchemy import text
from app import db 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.activitypub.signature import HttpSignature
from app.community.routes import show_community 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, \ 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 post_to_activity, find_actor_or_create
from app.utils import gibberish
INBOX = [] INBOX = []
@ -200,18 +202,77 @@ def community_profile(actor):
@bp.route('/inbox', methods=['GET', 'POST']) @bp.route('/inbox', methods=['GET', 'POST'])
def shared_inbox(): def shared_inbox():
if request.method == 'POST': 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']) actor = find_actor_or_create(request_json['actor'])
if actor is not None: if actor is not None:
if HttpSignature.verify_request(request, actor.public_key, skip_date=True): if HttpSignature.verify_request(request, actor.public_key, skip_date=True):
if 'type' in request_json: if 'type' in request_json:
activity_log.activity_type = request_json['type']
if request_json['type'] == 'Announce': if request_json['type'] == 'Announce':
... ...
# remote user wants to follow one of our communities
elif request_json['type'] == 'Follow': elif request_json['type'] == 'Follow':
# todo: send accept message if not banned user_ap_id = request_json['actor']
banned = CommunityBan.query.filter_by(user_id=current_user.id, 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() 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': elif request_json['type'] == 'Accept':
if request_json['object']['type'] == 'Follow': if request_json['object']['type'] == 'Follow':
community_ap_id = request_json['actor'] community_ap_id = request_json['actor']
@ -224,7 +285,15 @@ def shared_inbox():
member = CommunityMember(user_id=user.id, community_id=community.id) member = CommunityMember(user_id=user.id, community_id=community.id)
db.session.add(member) db.session.add(member)
db.session.commit() 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/<actor>/outbox', methods=['GET']) @bp.route('/c/<actor>/outbox', methods=['GET'])

View file

@ -26,7 +26,7 @@ def add_local():
private_key, public_key = RsaKeys.generate_keypair() private_key, public_key = RsaKeys.generate_keypair()
community = Community(title=form.community_name.data, name=form.url.data, description=form.description.data, 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, 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) subscriptions_count=1)
db.session.add(community) db.session.add(community)
db.session.commit() db.session.commit()

View file

@ -310,6 +310,7 @@ class CommunityMember(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
# people banned from communities
class CommunityBan(db.Model): class CommunityBan(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) 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) 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')) 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 @login.user_loader
def load_user(id): def load_user(id):
return User.query.get(int(id)) return User.query.get(int(id))

View file

@ -1,10 +1,8 @@
import functools import functools
import random
import requests import requests
import os import os
from flask import current_app, json from flask import current_app, json
from app import db from app import db
from app.models import Settings from app.models import Settings
@ -30,6 +28,7 @@ def get_request(uri, params=None, headers=None) -> requests.Response:
return 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) @functools.lru_cache(maxsize=100)
def get_setting(name: str, default=None): def get_setting(name: str, default=None):
setting = Settings.query.filter_by(name=name).first() setting = Settings.query.filter_by(name=name).first()
@ -39,6 +38,7 @@ def get_setting(name: str, default=None):
return json.loads(setting.value) return json.loads(setting.value)
# retrieves arbitrary object from persistent key-value store
def set_setting(name: str, value): def set_setting(name: str, value):
setting = Settings.query.filter_by(name=name).first() setting = Settings.query.filter_by(name=name).first()
if setting is None: if setting is None:
@ -54,3 +54,10 @@ def file_get_contents(filename):
with open(filename, 'r') as file: with open(filename, 'r') as file:
contents = file.read() contents = file.read()
return contents return contents
random_chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
def gibberish(length: int = 10) -> str:
return "".join([random.choice(random_chars) for x in range(length)])

View file

@ -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 ###