pyfedi/app/activitypub/routes.py
2023-12-26 12:36:02 +13:00

1009 lines
58 KiB
Python

from app import db, constants, cache, celery
from app.activitypub import bp
from flask import request, Response, current_app, abort, jsonify, json, g
from app.activitypub.signature import HttpSignature
from app.community.routes import show_community
from app.post.routes import continue_discussion, show_post
from app.user.routes import show_profile
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, SUBSCRIPTION_MEMBER
from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog, Post, \
PostReply, Instance, PostVote, PostReplyVote, File, AllowedInstances, BannedInstances, utcnow, Site
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, default_context, instance_blocked, find_reply_parent, find_liked_object, \
lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \
upvote_post, activity_already_ingested, make_image_sizes, delete_post_or_comment
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \
domain_from_url, markdown_to_html, community_membership, ap_datetime
import werkzeug.exceptions
INBOX = []
@bp.route('/.well-known/webfinger')
def webfinger():
if request.args.get('resource'):
query = request.args.get('resource') # acct:alice@tada.club
if 'acct:' in query:
actor = query.split(':')[1].split('@')[0] # alice
elif 'https:' in query or 'http:' in query:
actor = query.split('/')[-1]
else:
return 'Webfinger regex failed to match'
seperator = 'u'
type = 'Person'
user = User.query.filter_by(user_name=actor.strip(), deleted=False, banned=False, ap_id=None).first()
if user is None:
community = Community.query.filter_by(name=actor.strip(), ap_id=None).first()
if community is None:
return ''
seperator = 'c'
type = 'Group'
webfinger_data = {
"subject": f"acct:{actor}@{current_app.config['SERVER_NAME']}",
"aliases": [f"https://{current_app.config['SERVER_NAME']}/{seperator}/{actor}"],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": f"https://{current_app.config['SERVER_NAME']}/{seperator}/{actor}"
},
{
"rel": "self",
"type": "application/activity+json",
"href": f"https://{current_app.config['SERVER_NAME']}/{seperator}/{actor}",
"properties": {
"https://www.w3.org/ns/activitystreams#type": type
}
}
]
}
resp = jsonify(webfinger_data)
resp.headers.add_header('Access-Control-Allow-Origin', '*')
return resp
else:
abort(404)
@bp.route('/.well-known/nodeinfo')
@cache.cached(timeout=600)
def nodeinfo():
nodeinfo_data = {"links": [{"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": f"https://{current_app.config['SERVER_NAME']}/nodeinfo/2.0"}]}
return jsonify(nodeinfo_data)
@bp.route('/nodeinfo/2.0')
@bp.route('/nodeinfo/2.0.json')
@cache.cached(timeout=600)
def nodeinfo2():
nodeinfo_data = {
"version": "2.0",
"software": {
"name": "PieFed",
"version": "0.1"
},
"protocols": [
"activitypub"
],
"usage": {
"users": {
"total": users_total(),
"activeHalfyear": active_half_year(),
"activeMonth": active_month()
},
"localPosts": local_posts(),
"localComments": local_comments()
},
"openRegistrations": g.site.registration_mode == 'Open'
}
return jsonify(nodeinfo_data)
@bp.route('/api/v3/site')
@cache.cached(timeout=600)
def lemmy_site():
return jsonify(lemmy_site_data())
@bp.route('/api/v3/federated_instances')
@cache.cached(timeout=600)
def lemmy_federated_instances():
instances = Instance.query.all()
linked = []
allowed = []
blocked = []
for instance in instances:
instance_data = {"id": instance.id, "domain": instance.domain, "published": instance.created_at.isoformat(), "updated": instance.updated_at.isoformat()}
if instance.software:
instance_data['software'] = instance.software
if instance.version:
instance_data['version'] = instance.version
linked.append(instance_data)
for instance in AllowedInstances.query.all():
allowed.append({"id": instance.id, "domain": instance.domain, "published": utcnow(), "updated": utcnow()})
for instance in BannedInstances.query.all():
blocked.append({"id": instance.id, "domain": instance.domain, "published": utcnow(), "updated": utcnow()})
return jsonify({
"federated_instances": {
"linked": linked,
"allowed": allowed,
"blocked": blocked
}
})
@bp.route('/u/<actor>', methods=['GET'])
def user_profile(actor):
""" Requests to this endpoint can be for a JSON representation of the user, or a HTML rendering of their profile.
The two types of requests are differentiated by the header """
actor = actor.strip()
if '@' in actor:
user = User.query.filter_by(ap_id=actor, deleted=False, banned=False).first()
else:
user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
if user is not None:
if is_activitypub_request():
server = current_app.config['SERVER_NAME']
actor_data = { "@context": default_context(),
"type": "Person",
"id": f"https://{server}/u/{actor}",
"preferredUsername": actor,
"inbox": f"https://{server}/u/{actor}/inbox",
"outbox": f"https://{server}/u/{actor}/outbox",
"publicKey": {
"id": f"https://{server}/u/{actor}#main-key",
"owner": f"https://{server}/u/{actor}",
"publicKeyPem": user.public_key # .replace("\n", "\\n") #LOOKSWRONG
},
"endpoints": {
"sharedInbox": f"https://{server}/inbox"
},
"published": ap_datetime(user.created),
}
if user.avatar_id is not None:
actor_data["icon"] = {
"type": "Image",
"url": f"https://{current_app.config['SERVER_NAME']}{user.avatar_image()}"
}
if user.cover_id is not None:
actor_data["image"] = {
"type": "Image",
"url": f"https://{current_app.config['SERVER_NAME']}{user.cover_image()}"
}
if user.about:
actor_data['source'] = {
"content": user.about,
"mediaType": "text/markdown"
}
actor_data['summary'] = markdown_to_html(user.about)
resp = jsonify(actor_data)
resp.content_type = 'application/activity+json'
return resp
else:
return show_profile(user)
else:
abort(404)
@bp.route('/u/<actor>/outbox', methods=['GET'])
def user_outbox(actor):
outbox = {
"@context": default_context(),
'type': 'OrderedCollection',
'id': f"https://{current_app.config['SERVER_NAME']}/u/{actor}/outbox",
'orderedItems': [],
'totalItems': 0
}
resp = jsonify(outbox)
resp.content_type = 'application/activity+json'
return resp
@bp.route('/c/<actor>', methods=['GET'])
def community_profile(actor):
""" Requests to this endpoint can be for a JSON representation of the community, or a HTML rendering of it.
The two types of requests are differentiated by the header """
actor = actor.strip()
if '@' in actor:
# don't provide activitypub info for remote communities
if 'application/ld+json' in request.headers.get('Accept', '') or 'application/activity+json' in request.headers.get('Accept', ''):
abort(400)
community: Community = Community.query.filter_by(ap_id=actor, banned=False).first()
else:
community: Community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None:
if is_activitypub_request():
server = current_app.config['SERVER_NAME']
actor_data = {"@context": default_context(),
"type": "Group",
"id": f"https://{server}/c/{actor}",
"name": community.title,
"summary": community.description,
"sensitive": True if community.nsfw or community.nsfl else False,
"preferredUsername": actor,
"inbox": f"https://{server}/c/{actor}/inbox",
"outbox": f"https://{server}/c/{actor}/outbox",
"followers": f"https://{server}/c/{actor}/followers",
"moderators": f"https://{server}/c/{actor}/moderators",
"featured": f"https://{server}/c/{actor}/featured",
"attributedTo": f"https://{server}/c/{actor}/moderators",
"postingRestrictedToMods": community.restricted_to_mods,
"url": f"https://{server}/c/{actor}",
"publicKey": {
"id": f"https://{server}/c/{actor}#main-key",
"owner": f"https://{server}/c/{actor}",
"publicKeyPem": community.public_key
},
"endpoints": {
"sharedInbox": f"https://{server}/inbox"
},
"published": ap_datetime(community.created_at),
"updated": ap_datetime(community.last_active),
}
if community.icon_id is not None:
actor_data["icon"] = {
"type": "Image",
"url": f"https://{current_app.config['SERVER_NAME']}{community.icon_image()}"
}
if community.image_id is not None:
actor_data["image"] = {
"type": "Image",
"url": f"https://{current_app.config['SERVER_NAME']}{community.header_image()}"
}
resp = jsonify(actor_data)
resp.content_type = 'application/activity+json'
return resp
else: # browser request - return html
return show_community(community)
else:
abort(404)
@bp.route('/inbox', methods=['GET', 'POST'])
def shared_inbox():
if request.method == 'POST':
# save all incoming data to aid in debugging and development. Set result to 'success' if things go well
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
activity_log.result = 'failure'
db.session.add(activity_log)
db.session.commit()
return ''
if 'id' in request_json:
if activity_already_ingested(request_json['id']): # Lemmy has an extremely short POST timeout and tends to retry unnecessarily. Ignore their retries.
activity_log.result = 'ignored'
db.session.add(activity_log)
db.session.commit()
return ''
activity_log.activity_id = request_json['id']
activity_log.activity_json = json.dumps(request_json)
activity_log.result = 'processing'
# Mastodon spams the whole fediverse whenever any of their users are deleted. Ignore them, for now. The Activity includes the Actor signature so it should be possible to verify the POST and do the delete if valid, without a call to find_actor_or_create() and all the network activity that involves. One day.
if 'type' in request_json and request_json['type'] == 'Delete' and request_json['id'].endswith('#delete'):
activity_log.result = 'ignored'
activity_log.activity_type = 'Delete'
db.session.add(activity_log)
db.session.commit()
return ''
else:
db.session.add(activity_log)
db.session.commit()
else:
activity_log.activity_id = ''
activity_log.activity_json = json.dumps(request_json)
db.session.add(activity_log)
db.session.commit()
actor = find_actor_or_create(request_json['actor']) if 'actor' in request_json else None
if actor is not None:
if HttpSignature.verify_request(request, actor.public_key, skip_date=True):
if current_app.debug:
process_inbox_request(request_json, activity_log.id)
else:
process_inbox_request.delay(request_json, activity_log.id)
return ''
else:
activity_log.exception_message = 'Could not verify signature'
else:
actor_name = request_json['actor'] if 'actor' in request_json else ''
activity_log.exception_message = f'Actor could not be found: {actor_name}'
if activity_log.exception_message is not None:
activity_log.result = 'failure'
db.session.commit()
return ''
@celery.task
def process_inbox_request(request_json, activitypublog_id):
with current_app.app_context():
activity_log = ActivityPubLog.query.get(activitypublog_id)
site = Site.query.get(1) # can't use g.site because celery doesn't use Flask's g variable
if 'type' in request_json:
activity_log.activity_type = request_json['type']
if not instance_blocked(request_json['id']):
# Create is new content
if request_json['type'] == 'Create':
activity_log.activity_type = 'Create'
user_ap_id = request_json['object']['attributedTo']
community_ap_id = request_json['to'][0]
if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public': # kbin does this when posting a reply
if 'to' in request_json['object'] and request_json['object']['to']:
community_ap_id = request_json['object']['to'][0]
if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public' and 'cc' in \
request_json['object'] and request_json['object']['cc']:
community_ap_id = request_json['object']['cc'][0]
elif 'cc' in request_json['object'] and request_json['object']['cc']:
community_ap_id = request_json['object']['cc'][0]
community = find_actor_or_create(community_ap_id)
user = find_actor_or_create(user_ap_id)
if (user and not user.is_local()) and community:
user.last_seen = community.last_active = site.last_active = utcnow()
object_type = request_json['object']['type']
new_content_types = ['Page', 'Article', 'Link', 'Note']
if object_type in new_content_types: # create a new post
in_reply_to = request_json['object']['inReplyTo'] if 'inReplyTo' in request_json['object'] else None
if not in_reply_to:
post = Post(user_id=user.id, community_id=community.id,
title=request_json['object']['name'],
comments_enabled=request_json['object']['commentsEnabled'],
sticky=request_json['object']['stickied'] if 'stickied' in request_json['object'] else False,
nsfw=request_json['object']['sensitive'],
nsfl=request_json['object']['nsfl'] if 'nsfl' in request_json['object'] else False,
ap_id=request_json['object']['id'],
ap_create_id=request_json['id'],
ap_announce_id=None,
type=constants.POST_TYPE_ARTICLE,
up_votes=1,
score=instance_weight(user.ap_domain)
)
if 'source' in request_json['object'] and request_json['object']['source']['mediaType'] == 'text/markdown':
post.body = request_json['object']['source']['content']
post.body_html = markdown_to_html(post.body)
elif 'content' in request_json['object'] and request_json['object']['content'] is not None:
post.body_html = allowlist_html(request_json['object']['content'])
post.body = html_to_markdown(post.body_html)
if 'attachment' in request_json['object'] and len(request_json['object']['attachment']) > 0 and \
'type' in request_json['object']['attachment'][0]:
if request_json['object']['attachment'][0]['type'] == 'Link':
post.url = request_json['object']['attachment'][0]['href']
if is_image_url(post.url):
post.type = POST_TYPE_IMAGE
else:
post.type = POST_TYPE_LINK
domain = domain_from_url(post.url)
if not domain.banned:
post.domain_id = domain.id
else:
post = None
activity_log.exception_message = domain.name + ' is blocked by admin'
if 'image' in request_json['object']:
image = File(source_url=request_json['object']['image']['url'])
db.session.add(image)
post.image = image
if post is not None:
db.session.add(post)
community.post_count += 1
community.last_active = utcnow()
activity_log.result = 'success'
db.session.commit()
if post.image_id:
make_image_sizes(post.image_id, 266, None, 'posts')
vote = PostVote(user_id=user.id, author_id=post.user_id,
post_id=post.id,
effect=instance_weight(user.ap_domain))
db.session.add(vote)
else:
post_id, parent_comment_id, root_id = find_reply_parent(in_reply_to)
post_reply = PostReply(user_id=user.id, community_id=community.id,
post_id=post_id, parent_id=parent_comment_id,
root_id=root_id,
nsfw=community.nsfw,
nsfl=community.nsfl,
up_votes=1,
score=instance_weight(user.ap_domain),
ap_id=request_json['object']['id'],
ap_create_id=request_json['id'],
ap_announce_id=None)
if 'source' in request_json['object'] and \
request_json['object']['source']['mediaType'] == 'text/markdown':
post_reply.body = request_json['object']['source']['content']
post_reply.body_html = markdown_to_html(post_reply.body)
elif 'content' in request_json['object']:
post_reply.body_html = allowlist_html(request_json['object']['content'])
post_reply.body = html_to_markdown(post_reply.body_html)
if post_reply is not None:
post = Post.query.get(post_id)
if post.comments_enabled:
db.session.add(post_reply)
post.reply_count += 1
community.post_reply_count += 1
community.last_active = post.last_active = utcnow()
activity_log.result = 'success'
db.session.commit()
vote = PostReplyVote(user_id=user.id, author_id=post_reply.user_id,
post_reply_id=post_reply.id,
effect=instance_weight(user.ap_domain))
db.session.add(vote)
else:
activity_log.exception_message = 'Comments disabled'
else:
activity_log.exception_message = 'Unacceptable type (kbin): ' + object_type
else:
if user is None or community is None:
activity_log.exception_message = 'Blocked or unfound user or community'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
# Announce is new content and votes, lemmy and mastodon style
if request_json['type'] == 'Announce':
if request_json['object']['type'] == 'Create':
activity_log.activity_type = request_json['object']['type']
user_ap_id = request_json['object']['object']['attributedTo']
community_ap_id = request_json['object']['audience']
community = find_actor_or_create(community_ap_id)
user = find_actor_or_create(user_ap_id)
if (user and not user.is_local()) and community:
user.last_seen = community.last_active = site.last_active = utcnow()
object_type = request_json['object']['object']['type']
new_content_types = ['Page', 'Article', 'Link', 'Note']
if object_type in new_content_types: # create a new post
in_reply_to = request_json['object']['object']['inReplyTo'] if 'inReplyTo' in \
request_json['object']['object'] else None
if not in_reply_to:
post = Post(user_id=user.id, community_id=community.id,
title=request_json['object']['object']['name'],
comments_enabled=request_json['object']['object']['commentsEnabled'],
sticky=request_json['object']['object']['stickied'] if 'stickied' in request_json['object']['object'] else False,
nsfw=request_json['object']['object']['sensitive'] if 'sensitive' in request_json['object']['object'] else False,
nsfl=request_json['object']['object']['nsfl'] if 'nsfl' in request_json['object']['object'] else False,
ap_id=request_json['object']['object']['id'],
ap_create_id=request_json['object']['id'],
ap_announce_id=request_json['id'],
type=constants.POST_TYPE_ARTICLE
)
if 'source' in request_json['object']['object'] and \
request_json['object']['object']['source']['mediaType'] == 'text/markdown':
post.body = request_json['object']['object']['source']['content']
post.body_html = markdown_to_html(post.body)
elif 'content' in request_json['object']['object']:
post.body_html = allowlist_html(request_json['object']['object']['content'])
post.body = html_to_markdown(post.body_html)
if 'attachment' in request_json['object']['object'] and \
len(request_json['object']['object']['attachment']) > 0 and \
'type' in request_json['object']['object']['attachment'][0]:
if request_json['object']['object']['attachment'][0]['type'] == 'Link':
post.url = request_json['object']['object']['attachment'][0]['href']
if is_image_url(post.url):
post.type = POST_TYPE_IMAGE
else:
post.type = POST_TYPE_LINK
domain = domain_from_url(post.url)
if not domain.banned:
post.domain_id = domain.id
else:
post = None
activity_log.exception_message = domain.name + ' is blocked by admin'
if 'image' in request_json['object']['object']:
image = File(source_url=request_json['object']['object']['image']['url'])
db.session.add(image)
post.image = image
if post is not None:
db.session.add(post)
community.post_count += 1
db.session.commit()
if post.image_id:
make_image_sizes(post.image_id, 266, None, 'posts')
else:
post_id, parent_comment_id, root_id = find_reply_parent(in_reply_to)
if post_id or parent_comment_id or root_id:
post_reply = PostReply(user_id=user.id, community_id=community.id,
post_id=post_id, parent_id=parent_comment_id,
root_id=root_id,
nsfw=community.nsfw,
nsfl=community.nsfl,
ap_id=request_json['object']['object']['id'],
ap_create_id=request_json['object']['id'],
ap_announce_id=request_json['id'])
if 'source' in request_json['object']['object'] and \
request_json['object']['object']['source']['mediaType'] == 'text/markdown':
post_reply.body = request_json['object']['object']['source']['content']
post_reply.body_html = markdown_to_html(post_reply.body)
elif 'content' in request_json['object']['object']:
post_reply.body_html = allowlist_html(
request_json['object']['object']['content'])
post_reply.body = html_to_markdown(post_reply.body_html)
if post_reply is not None:
post = Post.query.get(post_id)
if post.comments_enabled:
db.session.add(post_reply)
community.post_reply_count += 1
community.last_active = utcnow()
post.last_active = utcnow()
post.reply_count += 1
activity_log.result = 'success'
db.session.commit()
else:
activity_log.exception_message = 'Comments disabled'
else:
activity_log.exception_message = 'Parent not found'
else:
activity_log.exception_message = 'Unacceptable type: ' + object_type
else:
if user is None or community is None:
activity_log.exception_message = 'Blocked or unfound user or community'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif request_json['object']['type'] == 'Like':
activity_log.activity_type = request_json['object']['type']
user_ap_id = request_json['object']['actor']
liked_ap_id = request_json['object']['object']
user = find_actor_or_create(user_ap_id)
if user and not user.is_local():
liked = find_liked_object(liked_ap_id)
# insert into voted table
if liked is None:
activity_log.exception_message = 'Liked object not found'
elif liked is not None and isinstance(liked, Post):
upvote_post(liked, user)
activity_log.result = 'success'
elif liked is not None and isinstance(liked, PostReply):
upvote_post_reply(liked, user)
activity_log.result = 'success'
else:
activity_log.exception_message = 'Could not detect type of like'
if activity_log.result == 'success':
...
# todo: recalculate 'hotness' of liked post/reply
# todo: if vote was on content in local community, federate the vote out to followers
else:
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif request_json['object']['type'] == 'Dislike':
activity_log.activity_type = request_json['object']['type']
if site.enable_downvotes is False:
activity_log.exception_message = 'Dislike ignored because of allow_dislike setting'
else:
user_ap_id = request_json['object']['actor']
liked_ap_id = request_json['object']['object']
user = find_actor_or_create(user_ap_id)
if user and not user.is_local():
disliked = find_liked_object(liked_ap_id)
# insert into voted table
if disliked is None:
activity_log.exception_message = 'Liked object not found'
elif disliked is not None and isinstance(disliked, Post):
downvote_post(disliked, user)
activity_log.result = 'success'
elif disliked is not None and isinstance(disliked, PostReply):
downvote_post_reply(disliked, user)
activity_log.result = 'success'
else:
activity_log.exception_message = 'Could not detect type of like'
if activity_log.result == 'success':
... # todo: recalculate 'hotness' of liked post/reply
# todo: if vote was on content in local community, federate the vote out to followers
else:
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif request_json['object']['type'] == 'Delete':
activity_log.activity_type = request_json['object']['type']
user_ap_id = request_json['object']['actor']
community_ap_id = request_json['object']['audience']
to_be_deleted_ap_id = request_json['object']['object']
delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id)
activity_log.result = 'success'
# Follow: remote user wants to join/follow one of our communities
elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community
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:
user.last_seen = utcnow()
if community_membership(user, community) != SUBSCRIPTION_MEMBER:
member = CommunityMember(user_id=user.id, community_id=community.id)
db.session.add(member)
db.session.commit()
cache.delete_memoized(community_membership, user, community)
# send accept message to acknowledge the follow
accept = {
"@context": default_context(),
"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'
# Accept: 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']
user_ap_id = request_json['object']['actor']
user = find_actor_or_create(user_ap_id)
community = find_actor_or_create(community_ap_id)
if user and community:
join_request = CommunityJoinRequest.query.filter_by(user_id=user.id, community_id=community.id).first()
if join_request:
member = CommunityMember(user_id=user.id, community_id=community.id)
db.session.add(member)
community.subscriptions_count += 1
db.session.commit()
activity_log.result = 'success'
cache.delete_memoized(community_membership, user, community)
elif request_json['type'] == 'Undo':
if request_json['object']['type'] == 'Follow': # Unsubscribe from a community
community_ap_id = request_json['object']['object']
user_ap_id = request_json['object']['actor']
user = find_actor_or_create(user_ap_id)
community = find_actor_or_create(community_ap_id)
if user and community:
user.last_seen = utcnow()
member = CommunityMember.query.filter_by(user_id=user.id, community_id=community.id).first()
join_request = CommunityJoinRequest.query.filter_by(user_id=user.id, community_id=community.id).first()
if member:
db.session.delete(member)
if join_request:
db.session.delete(join_request)
db.session.commit()
activity_log.result = 'success'
elif request_json['object']['type'] == 'Like': # Undoing an upvote or downvote
activity_log.activity_type = request_json['object']['type']
user_ap_id = request_json['actor']
user = find_actor_or_create(user_ap_id)
post = None
comment = None
target_ap_id = request_json['object']['object']
if '/comment/' in target_ap_id:
comment = PostReply.query.filter_by(ap_id=target_ap_id).first()
if '/post/' in target_ap_id:
post = Post.query.filter_by(ap_id=target_ap_id).first()
if (user and not user.is_local()) and post:
user.last_seen = utcnow()
existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first()
if existing_vote:
post.author.reputation -= existing_vote.effect
if existing_vote.effect < 0: # Lemmy sends 'like' for upvote and 'dislike' for down votes. Cool! When it undoes an upvote it sends an 'Undo Like'. Fine. When it undoes a downvote it sends an 'Undo Like' - not 'Undo Dislike'?!
post.down_votes -= 1
else:
post.up_votes -= 1
post.score -= existing_vote.effect
db.session.delete(existing_vote)
activity_log.result = 'success'
if (user and not user.is_local()) and comment:
existing_vote = PostReplyVote.query.filter_by(user_id=user.id, post_reply_id=comment.id).first()
if existing_vote:
comment.author.reputation -= existing_vote.effect
if existing_vote.effect < 0: # Lemmy sends 'like' for upvote and 'dislike' for down votes. Cool! When it undoes an upvote it sends an 'Undo Like'. Fine. When it undoes a downvote it sends an 'Undo Like' - not 'Undo Dislike'?!
comment.down_votes -= 1
else:
comment.up_votes -= 1
comment.score -= existing_vote.effect
db.session.delete(existing_vote)
activity_log.result = 'success'
else:
if user is None or comment is None:
activity_log.exception_message = 'Blocked or unfound user or comment'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif request_json['object']['type'] == 'Dislike': # Undoing a downvote - probably unused
activity_log.activity_type = request_json['object']['type']
user_ap_id = request_json['actor']
user = find_actor_or_create(user_ap_id)
post = None
comment = None
target_ap_id = request_json['object']['object']
if '/comment/' in target_ap_id:
comment = PostReply.query.filter_by(ap_id=target_ap_id).first()
if '/post/' in target_ap_id:
post = Post.query.filter_by(ap_id=target_ap_id).first()
if (user and not user.is_local()) and post:
existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first()
if existing_vote:
post.author.reputation -= existing_vote.effect
post.down_votes -= 1
post.score -= existing_vote.effect
db.session.delete(existing_vote)
activity_log.result = 'success'
if (user and not user.is_local()) and comment:
existing_vote = PostReplyVote.query.filter_by(user_id=user.id,
post_reply_id=comment.id).first()
if existing_vote:
comment.author.reputation -= existing_vote.effect
comment.down_votes -= 1
comment.score -= existing_vote.effect
db.session.delete(existing_vote)
activity_log.result = 'success'
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
if user and user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif request_json['type'] == 'Update':
if request_json['object']['type'] == 'Page': # Editing a post
post = Post.query.filter_by(ap_id=request_json['object']['id']).first()
if post:
if 'source' in request_json['object'] and request_json['object']['source']['mediaType'] == 'text/markdown':
post.body = request_json['object']['source']['content']
post.body_html = markdown_to_html(post.body)
elif 'content' in request_json['object']:
post.body_html = allowlist_html(request_json['object']['content'])
post.body = html_to_markdown(post.body_html)
post.edited_at = utcnow()
db.session.commit()
activity_log.result = 'success'
elif request_json['object']['type'] == 'Note': # Editing a reply
reply = PostReply.query.filter_by(ap_id=request_json['object']['id']).first()
if reply:
if 'source' in request_json['object'] and request_json['object']['source']['mediaType'] == 'text/markdown':
reply.body = request_json['object']['source']['content']
reply.body_html = markdown_to_html(reply.body)
elif 'content' in request_json['object']:
reply.body_html = allowlist_html(request_json['object']['content'])
reply.body = html_to_markdown(reply.body_html)
reply.edited_at = utcnow()
db.session.commit()
activity_log.result = 'success'
elif request_json['type'] == 'Delete':
if isinstance(request_json['object'], str):
ap_id = request_json['object'] # lemmy
else:
ap_id = request_json['object']['id'] # kbin
post = Post.query.filter_by(ap_id=ap_id).first()
if post:
post.delete_dependencies()
db.session.delete(post)
else:
reply = PostReply.query.filter_by(ap_id=ap_id).first()
if reply:
reply.body_html = '<p><em>deleted</em></p>'
reply.body = 'deleted'
db.session.commit()
activity_log.result = 'success'
elif request_json['type'] == 'Like': # Upvote
activity_log.activity_type = request_json['type']
user_ap_id = request_json['actor']
user = find_actor_or_create(user_ap_id)
target_ap_id = request_json['object']
post = None
comment = None
if '/comment/' in target_ap_id:
comment = PostReply.query.filter_by(ap_id=target_ap_id).first()
if '/post/' in target_ap_id:
post = Post.query.filter_by(ap_id=target_ap_id).first()
if (user and not user.is_local()) and post:
upvote_post(post, user)
activity_log.result = 'success'
elif (user and not user.is_local()) and comment:
upvote_post_reply(comment, user)
activity_log.result = 'success'
elif request_json['type'] == 'Dislike': # Downvote
if get_setting('allow_dislike', True) is False:
activity_log.exception_message = 'Dislike ignored because of allow_dislike setting'
else:
activity_log.activity_type = request_json['type']
user_ap_id = request_json['actor']
user = find_actor_or_create(user_ap_id)
target_ap_id = request_json['object']
post = None
comment = None
if '/comment/' in target_ap_id:
comment = PostReply.query.filter_by(ap_id=target_ap_id).first()
if '/post/' in target_ap_id:
post = Post.query.filter_by(ap_id=target_ap_id).first()
if (user and not user.is_local()) and comment:
downvote_post_reply(comment, user)
activity_log.result = 'success'
elif (user and not user.is_local()) and post:
downvote_post(post, user)
activity_log.result = 'success'
else:
activity_log.exception_message = 'Could not find user or content for vote'
# Flush the caches of any major object that was created. To be sure.
if 'user' in vars() and user is not None:
user.flush_cache()
# if 'community' in vars() and community is not None:
# community.flush_cache()
if 'post' in vars() and post is not None:
post.flush_cache()
else:
activity_log.exception_message = 'Instance banned'
if activity_log.exception_message is not None and activity_log.result == 'processing':
activity_log.result = 'failure'
db.session.commit()
@bp.route('/c/<actor>/outbox', methods=['GET'])
def community_outbox(actor):
actor = actor.strip()
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None:
posts = community.posts.limit(50).all()
community_data = {
"@context": default_context(),
"type": "OrderedCollection",
"id": f"https://{current_app.config['SERVER_NAME']}/c/{actor}/outbox",
"totalItems": len(posts),
"orderedItems": []
}
for post in posts:
community_data['orderedItems'].append(post_to_activity(post, community))
return jsonify(community_data)
@bp.route('/c/<actor>/moderators', methods=['GET'])
def community_moderators(actor):
actor = actor.strip()
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None:
moderator_ids = community.moderators()
moderators = User.query.filter(User.id.in_([mod.user_id for mod in moderator_ids])).all()
community_data = {
"@context": default_context(),
"type": "OrderedCollection",
"id": f"https://{current_app.config['SERVER_NAME']}/c/{actor}/moderators",
"totalItems": len(moderators),
"orderedItems": []
}
for moderator in moderators:
community_data['orderedItems'].append(moderator.ap_profile_id)
return jsonify(community_data)
@bp.route('/inspect')
def inspect():
return Response(b'<br><br>'.join(INBOX), status=200)
@bp.route('/users/<actor>/inbox', methods=['GET', 'POST'])
def inbox(actor):
""" To post to this inbox, you could use curl:
$ curl -d '{"key" : "value"}' -H "Content-Type: application/json" -X POST http://localhost:5001/users/test/inbox
Or, with an actual Mastodon follow request:
$ curl -d '{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"manuallyApprovesFollowers":"as:manuallyApprovesFollowers","sensitive":"as:sensitive","movedTo":{"@id":"as:movedTo","@type":"@id"},"Hashtag":"as:Hashtag","ostatus":"http://ostatus.org#","atomUri":"ostatus:atomUri","inReplyToAtomUri":"ostatus:inReplyToAtomUri","conversation":"ostatus:conversation","toot":"http://joinmastodon.org/ns#","Emoji":"toot:Emoji","focalPoint":{"@container":"@list","@id":"toot:focalPoint"},"featured":{"@id":"toot:featured","@type":"@id"},"schema":"http://schema.org#","PropertyValue":"schema:PropertyValue","value":"schema:value"}],"id":"https://post.lurk.org/02d04ed5-dda6-48f3-a551-2e9c554de745","type":"Follow","actor":"https://post.lurk.org/users/manetta","object":"https://ap.virtualprivateserver.space/users/test","signature":{"type":"RsaSignature2017","creator":"https://post.lurk.org/users/manetta#main-key","created":"2018-11-28T16:15:35Z","signatureValue":"XUdBg+Zj9pkdOXlAYHhOtZlmU1Jdt63zwh2cXoJ8E8C1C+KvgGilkyfPTud9VNymVwdUQRl+YEW9KAZiiGaHb9H+tdVUr9BEkuR5E/tGehbMZr1sakC+qPehe4s3bRKEpJjTTJnTiSHaW7V6Qvr1u6+MVts6oj32az/ixuB/CfodSr3K/K+jZmmOl6SIUqX7Xg7xGwOxIsYaR7g9wbcJ4qyzKcTPZonPMsONq9/RSm3SeQBo7WO1FKlQiFxVP/y5eFaFP8GYDLZyK7Nj5kDL5TannfEpuF8f3oyTBErQhcFQYKcBZNbuaqX/WiIaGjtHIL2ctJe0Psb5Nfshx4MXmQ=="}}' -H "Content-Type: application/json" -X POST http://localhost:5001/users/test/inbox
"""
if request.method == 'GET':
return '''This has been a <em>{}</em> request. <br>
It came with the following header: <br><br><em>{}</em><br><br>
You have searched for the actor <em>{}</em>. <br>
This is <em>{}</em>'s shared inbox: <br><br><em>{}</em>'''.format(request.method, request.headers, actor,
current_app.config['SERVER_NAME'], str(INBOX))
if request.method == 'POST':
INBOX.append(request.data)
return Response(status=200)
@bp.route('/comment/<int:comment_id>', methods=['GET'])
def comment_ap(comment_id):
if is_activitypub_request():
reply = PostReply.query.get_or_404(comment_id)
reply_data = {
"@context": default_context(),
"type": "Note",
"id": reply.ap_id,
"attributedTo": reply.author.profile_id(),
"inReplyTo": reply.in_reply_to(),
"to": [
"https://www.w3.org/ns/activitystreams#Public",
reply.to()
],
"cc": [
reply.community.profile_id(),
reply.author.followers_url()
],
'content': reply.body_html,
'mediaType': 'text/html',
'published': ap_datetime(reply.created_at),
'distinguished': False,
'audience': reply.community.profile_id()
}
if reply.edited_at:
reply_data['updated'] = ap_datetime(reply.edited_at)
if reply.body.strip():
reply_data['source'] = {
'content': reply.body,
'mediaType': 'text/markdown'
}
resp = jsonify(reply_data)
resp.content_type = 'application/activity+json'
return resp
else:
reply = PostReply.query.get(comment_id)
continue_discussion(reply.post.id, comment_id)
@bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
def post_ap(post_id):
if request.method == 'GET' and is_activitypub_request():
post = Post.query.get_or_404(post_id)
post_data = post_to_activity(post, post.community)
post_data = post_data['object']['object']
post_data['@context'] = default_context()
resp = jsonify(post_data)
resp.content_type = 'application/activity+json'
return resp
else:
return show_post(post_id)
@bp.route('/activities/<type>/<id>')
@cache.cached(timeout=600)
def activities_json(type, id):
activity = ActivityPubLog.query.filter_by(activity_id=f"https://{current_app.config['SERVER_NAME']}/activities/{type}/{id}").first()
if activity:
activity_json = json.loads(activity.activity_json)
resp = jsonify(activity_json)
resp.content_type = 'application/activity+json'
return resp
else:
abort(404)