pyfedi/app/activitypub/routes.py

997 lines
52 KiB
Python
Raw Normal View History

2024-01-09 20:44:08 +13:00
from flask_login import current_user
2023-12-30 13:23:12 +13:00
from app import db, constants, cache, celery
2023-08-05 21:24:10 +12:00
from app.activitypub import bp
2024-01-12 20:21:41 +13:00
from flask import request, current_app, abort, jsonify, json, g, url_for, redirect
2023-12-29 17:32:35 +13:00
from app.activitypub.signature import HttpSignature, post_request
from app.community.routes import show_community
from app.community.util import send_to_remote_instance
from app.post.routes import continue_discussion, show_post
2023-10-07 21:32:19 +13:00
from app.user.routes import show_profile
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, SUBSCRIPTION_MEMBER
2023-09-10 20:20:53 +12:00
from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog, Post, \
PostReply, Instance, PostVote, PostReplyVote, File, AllowedInstances, BannedInstances, utcnow, Site, Notification
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, \
2023-12-22 14:05:39 +13:00
lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \
upvote_post, activity_already_ingested, delete_post_or_comment, community_members, \
2023-12-30 13:23:12 +13:00
user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \
update_post_from_activity, undo_vote, undo_downvote
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, markdown_to_text, ip_address, can_downvote, \
can_upvote, can_create, awaken_dormant_instance
2023-10-10 22:25:37 +13:00
import werkzeug.exceptions
2023-08-05 21:24:10 +12:00
@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')
2023-12-10 15:10:09 +13:00
@cache.cached(timeout=600)
2023-08-05 21:24:10 +12:00
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')
2023-12-10 15:10:09 +13:00
@cache.cached(timeout=600)
2023-08-05 21:24:10 +12:00
def nodeinfo2():
nodeinfo_data = {
"version": "2.0",
"software": {
"name": "PieFed",
2023-08-05 21:24:10 +12:00
"version": "0.1"
},
"protocols": [
"activitypub"
],
"usage": {
"users": {
"total": users_total(),
"activeHalfyear": active_half_year(),
"activeMonth": active_month()
2023-08-05 21:24:10 +12:00
},
"localPosts": local_posts(),
"localComments": local_comments()
2023-08-05 21:24:10 +12:00
},
"openRegistrations": g.site.registration_mode == 'Open'
2023-08-05 21:24:10 +12:00
}
return jsonify(nodeinfo_data)
@bp.route('/api/v3/site')
2023-12-10 15:10:09 +13:00
@cache.cached(timeout=600)
def lemmy_site():
return jsonify(lemmy_site_data())
@bp.route('/api/v3/federated_instances')
2023-12-10 15:10:09 +13:00
@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
}
})
2023-12-29 17:32:35 +13:00
@bp.route('/u/<actor>', methods=['GET', 'HEAD'])
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()
2024-01-09 20:44:08 +13:00
if current_user.is_authenticated and current_user.is_admin():
if '@' in actor:
user: User = User.query.filter_by(ap_id=actor).first()
else:
user: User = User.query.filter_by(user_name=actor, ap_id=None).first()
2023-11-24 20:22:58 +13:00
else:
2024-01-09 20:44:08 +13:00
if '@' in actor:
user: User = User.query.filter_by(ap_id=actor, deleted=False, banned=False).first()
else:
user: User = User.query.filter_by(user_name=actor, deleted=False, ap_id=None).first()
2023-11-24 20:22:58 +13:00
if user is not None:
2023-12-29 17:32:35 +13:00
if request.method == 'HEAD':
if is_activitypub_request():
resp = jsonify('')
resp.content_type = 'application/activity+json'
return resp
else:
return ''
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,
2024-01-01 14:49:15 +13:00
"name": user.title if user.title else user.user_name,
"inbox": f"https://{server}/u/{actor}/inbox",
"outbox": f"https://{server}/u/{actor}/outbox",
2023-12-29 17:32:35 +13:00
"discoverable": user.searchable,
"indexable": user.indexable,
"manuallyApprovesFollowers": user.ap_manually_approves_followers,
"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()}"
}
2023-10-07 21:32:19 +13:00
if user.about:
actor_data['source'] = {
"content": user.about,
"mediaType": "text/markdown"
}
2023-10-10 22:25:37 +13:00
actor_data['summary'] = markdown_to_html(user.about)
2023-12-28 21:00:26 +13:00
if user.matrix_user_id:
actor_data['matrixUserId'] = user.matrix_user_id
resp = jsonify(actor_data)
resp.content_type = 'application/activity+json'
return resp
else:
2023-10-07 21:32:19 +13:00
return show_profile(user)
2023-10-21 15:49:01 +13:00
else:
abort(404)
2023-12-22 15:34:45 +13:00
@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', ''):
2023-12-12 18:28:49 +13:00
abort(400)
community: Community = Community.query.filter_by(ap_id=actor, banned=False).first()
else:
2024-01-01 11:38:24 +13:00
community: Community = Community.query.filter_by(name=actor, 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 or community.local_only,
"newModsWanted": community.new_mods_wanted,
"privateMods": community.private_mods,
"url": f"https://{server}/c/{actor}",
"publicKey": {
"id": f"https://{server}/c/{actor}#main-key",
"owner": f"https://{server}/c/{actor}",
2023-11-22 22:12:58 +13:00
"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':
2023-12-22 14:05:39 +13:00
# save all incoming data to aid in debugging and development. Set result to 'success' if things go well
2024-01-13 11:12:31 +13:00
activity_log = ActivityPubLog(direction='in', 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'
activity_log.exception_message = 'Unnecessary retry attempt'
db.session.add(activity_log)
db.session.commit()
return ''
activity_log.activity_id = request_json['id']
2024-01-13 11:12:31 +13:00
if g.site.log_activitypub_json:
activity_log.activity_json = json.dumps(request_json)
activity_log.result = 'processing'
2023-12-29 17:32:35 +13:00
db.session.add(activity_log)
db.session.commit()
2023-12-29 17:32:35 +13:00
# When a user is deleted, the only way to be fairly sure they get deleted everywhere is to tell the whole fediverse.
if 'type' in request_json and request_json['type'] == 'Delete' and request_json['id'].endswith('#delete'):
2023-12-29 17:32:35 +13:00
if current_app.debug:
2024-01-01 14:49:15 +13:00
process_delete_request(request_json, activity_log.id, ip_address())
2023-12-29 17:32:35 +13:00
else:
2024-01-01 14:49:15 +13:00
process_delete_request.delay(request_json, activity_log.id, ip_address())
return ''
else:
activity_log.activity_id = ''
2024-01-13 11:12:31 +13:00
if g.site.log_activitypub_json:
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:
2024-01-01 14:49:15 +13:00
process_inbox_request(request_json, activity_log.id, ip_address())
else:
2024-01-01 14:49:15 +13:00
process_inbox_request.delay(request_json, activity_log.id, ip_address())
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
2024-01-01 14:49:15 +13:00
def process_inbox_request(request_json, activitypublog_id, ip_address):
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']
2024-01-05 08:45:33 +13:00
try:
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]
2024-01-05 08:45:33 +13:00
if community_ap_id.endswith('/followers'): # mastodon
if 'inReplyTo' in request_json['object']:
post_being_replied_to = Post.query.filter_by(ap_id=request_json['object']['inReplyTo']).first()
if post_being_replied_to:
community_ap_id = post_being_replied_to.community.ap_profile_id
except:
activity_log.activity_type = 'exception'
db.session.commit()
return
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:
2023-12-30 13:23:12 +13:00
post = create_post(activity_log, community, request_json, user)
else:
2023-12-30 13:23:12 +13:00
post = create_post_reply(activity_log, community, in_reply_to, request_json, user)
else:
2023-12-30 13:23:12 +13:00
activity_log.exception_message = 'Unacceptable type (create): ' + 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 that happened on a remote server.
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']
2024-01-05 08:45:33 +13:00
try:
community_ap_id = request_json['object']['audience'] if 'audience' in request_json['object'] else request_json['actor']
except KeyError:
activity_log.activity_type = 'exception'
db.session.commit()
return
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 can_create(user, community):
if not in_reply_to:
post = create_post(activity_log, community, request_json['object'], user, announce_id=request_json['id'])
else:
post = create_post_reply(activity_log, community, in_reply_to, request_json['object'], user, announce_id=request_json['id'])
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)
liked = find_liked_object(liked_ap_id)
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
2024-01-04 16:56:37 +13:00
elif liked is None:
activity_log.exception_message = 'Unfound object ' + liked_ap_id
elif user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif can_upvote(user, liked.community):
# 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: if vote was on content in local community, federate the vote out to followers
else:
activity_log.exception_message = 'Cannot upvote this'
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)
disliked = find_liked_object(liked_ap_id)
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
2024-01-04 16:56:37 +13:00
elif disliked is None:
activity_log.exception_message = 'Unfound object ' + liked_ap_id
elif user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif can_downvote(user, disliked.community, site):
# insert into voted table
if disliked is None:
activity_log.exception_message = 'Liked object not found'
elif isinstance(disliked, (Post, PostReply)):
if isinstance(disliked, Post):
downvote_post(disliked, user)
elif isinstance(disliked, PostReply):
downvote_post_reply(disliked, user)
2023-11-22 20:48:27 +13:00
activity_log.result = 'success'
# todo: recalculate 'hotness' of liked post/reply
# todo: if vote was on content in the local community, federate the vote out to followers
else:
activity_log.exception_message = 'Could not detect type of like'
else:
activity_log.exception_message = 'Cannot downvote this'
activity_log.result = 'ignored'
2023-12-26 12:36:02 +13:00
elif request_json['object']['type'] == 'Delete':
activity_log.activity_type = request_json['object']['type']
user_ap_id = request_json['object']['actor']
2024-01-05 08:45:33 +13:00
community_ap_id = request_json['object']['audience'] if 'audience' in request_json['object'] else request_json['actor']
2023-12-26 12:36:02 +13:00
to_be_deleted_ap_id = request_json['object']['object']
if isinstance(to_be_deleted_ap_id, dict):
activity_log.result = 'failure'
activity_log.exception_message = 'dict instead of string ' + str(to_be_deleted_ap_id)
else:
delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id)
activity_log.result = 'success'
elif request_json['object']['type'] == 'Page': # Editing a post
post = Post.query.filter_by(ap_id=request_json['object']['id']).first()
if post:
2024-01-05 09:02:54 +13:00
try:
2024-01-05 09:13:00 +13:00
update_post_from_activity(post, request_json)
2024-01-05 09:02:54 +13:00
except KeyError:
activity_log.result = 'exception'
db.session.commit()
return
activity_log.result = 'success'
else:
activity_log.exception_message = 'Post not found'
elif request_json['object']['type'] == 'Note': # Editing a reply
reply = PostReply.query.filter_by(ap_id=request_json['object']['id']).first()
if reply:
2024-01-05 09:02:54 +13:00
try:
2024-01-05 09:13:00 +13:00
update_post_reply_from_activity(reply, request_json)
2024-01-05 09:02:54 +13:00
except KeyError:
activity_log.result = 'exception'
db.session.commit()
return
activity_log.result = 'success'
else:
activity_log.exception_message = 'PostReply not found'
2024-01-05 09:13:00 +13:00
elif request_json['object']['type'] == 'Update': # Editing a post or comment
2024-01-05 09:02:54 +13:00
if request_json['object']['object']['type'] == 'Page':
post = Post.query.filter_by(ap_id=request_json['object']['object']['id']).first()
if post:
try:
update_post_from_activity(post, request_json['object'])
except KeyError:
activity_log.result = 'exception'
db.session.commit()
return
activity_log.result = 'success'
else:
activity_log.exception_message = 'Post not found'
elif request_json['object']['object']['type'] == 'Note':
reply = PostReply.query.filter_by(ap_id=request_json['object']['object']['id']).first()
if reply:
try:
update_post_reply_from_activity(reply, request_json['object'])
except KeyError:
activity_log.result = 'exception'
db.session.commit()
return
activity_log.result = 'success'
else:
activity_log.exception_message = 'PostReply not found'
2024-01-05 09:39:20 +13:00
elif request_json['object']['type'] == 'Undo':
if request_json['object']['object']['type'] == 'Like':
activity_log.activity_type = request_json['object']['object']['type']
user_ap_id = request_json['object']['actor']
user = find_actor_or_create(user_ap_id)
post = None
comment = None
target_ap_id = request_json['object']['object']['object'] # object object object!
post = undo_vote(activity_log, comment, post, target_ap_id, user)
activity_log.result = 'success'
else:
activity_log.exception_message = 'Invalid type for Announce'
2023-12-26 12:36:02 +13:00
# 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()
2024-01-04 17:07:02 +13:00
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)
}
2023-12-30 13:23:12 +13:00
if post_request(user.ap_inbox_url, accept, community.private_key, f"https://{current_app.config['SERVER_NAME']}/c/{community.name}#main-key"):
activity_log.result = 'success'
else:
activity_log.exception_message = 'Error sending Accept'
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'
2024-01-04 17:07:02 +13:00
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)
2023-12-30 13:23:12 +13:00
community.subscriptions_count -= 1
if join_request:
db.session.delete(join_request)
db.session.commit()
2024-01-04 17:07:02 +13:00
cache.delete_memoized(community_membership, user, community)
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']
2023-12-30 13:23:12 +13:00
post = undo_vote(activity_log, comment, post, target_ap_id, user)
2024-01-05 09:39:20 +13:00
activity_log.result = 'success'
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']
2023-12-30 13:23:12 +13:00
post = undo_downvote(activity_log, comment, post, target_ap_id, user)
2024-01-05 09:39:20 +13:00
activity_log.result = 'success'
elif request_json['type'] == 'Update':
activity_log.activity_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:
2023-12-30 13:23:12 +13:00
update_post_from_activity(post, request_json)
activity_log.result = 'success'
else:
activity_log.exception_message = 'Post not found'
elif request_json['object']['type'] == 'Note': # Editing a reply
reply = PostReply.query.filter_by(ap_id=request_json['object']['id']).first()
if reply:
2023-12-30 13:23:12 +13:00
update_post_reply_from_activity(reply, request_json)
activity_log.result = 'success'
else:
activity_log.exception_message = 'PostReply not found'
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()
post.community.post_count -= 1
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'
reply.post.reply_count -= 1
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)
liked = find_liked_object(request_json['object'])
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
2024-01-04 16:56:37 +13:00
elif liked is None:
activity_log.exception_message = 'Unfound object ' + request_json['object']
elif user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif can_upvote(user, liked.community):
# 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: if vote was on content in local community, federate the vote out to followers
else:
activity_log.exception_message = 'Cannot upvote this'
activity_log.result = 'ignored'
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']
disliked = find_liked_object(target_ap_id)
if user is None:
activity_log.exception_message = 'Blocked or unfound user'
2024-01-04 16:56:37 +13:00
elif disliked is None:
activity_log.exception_message = 'Unfound object' + target_ap_id
elif user.is_local():
activity_log.exception_message = 'Activity about local content which is already present'
activity_log.result = 'ignored'
elif can_downvote(user, disliked.community, site):
# insert into voted table
if disliked is None:
activity_log.exception_message = 'Liked object not found'
elif isinstance(disliked, (Post, PostReply)):
if isinstance(disliked, Post):
downvote_post(disliked, user)
elif isinstance(disliked, PostReply):
downvote_post_reply(disliked, user)
activity_log.result = 'success'
# todo: if vote was on content in the local community, federate the vote out to followers
else:
activity_log.exception_message = 'Could not detect type of like'
else:
activity_log.exception_message = 'Cannot downvote this'
activity_log.result = 'ignored'
# 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 user.instance_id and user.instance_id != 1:
user.instance.last_seen = utcnow()
2024-01-04 22:08:32 +13:00
# user.instance.ip_address = ip_address
user.instance.dormant = False
if 'community' in vars() and community is not None:
2024-01-03 20:14:39 +13:00
if community.is_local() and request_json['type'] not in ['Announce', 'Follow', 'Accept', 'Undo']:
announce_activity_to_followers(community, user, request_json)
# community.flush_cache()
if 'post' in vars() and post is not None:
post.flush_cache()
else:
activity_log.exception_message = 'Instance blocked'
if activity_log.exception_message is not None and activity_log.result == 'processing':
activity_log.result = 'failure'
db.session.commit()
2023-12-22 14:05:39 +13:00
2023-12-29 17:32:35 +13:00
@celery.task
2024-01-01 14:49:15 +13:00
def process_delete_request(request_json, activitypublog_id, ip_address):
2023-12-29 17:32:35 +13:00
with current_app.app_context():
activity_log = ActivityPubLog.query.get(activitypublog_id)
if 'type' in request_json and request_json['type'] == 'Delete':
actor_to_delete = request_json['object']
user = User.query.filter_by(ap_profile_id=actor_to_delete).first()
if user:
# check that the user really has been deleted, to avoid spoofing attacks
2024-01-01 14:49:15 +13:00
if not user.is_local():
if user_removed_from_remote_server(actor_to_delete, is_piefed=user.instance.software == 'PieFed'):
# Delete all their images to save moderators from having to see disgusting stuff.
files = File.query.join(Post).filter(Post.user_id == user.id).all()
for file in files:
file.delete_from_disk()
file.source_url = ''
if user.avatar_id:
user.avatar.delete_from_disk()
user.avatar.source_url = ''
if user.cover_id:
user.cover.delete_from_disk()
user.cover.source_url = ''
user.banned = True
user.deleted = True
activity_log.result = 'success'
else:
activity_log.result = 'ignored'
activity_log.exception_message = 'User not actually deleted.'
2023-12-29 17:32:35 +13:00
else:
activity_log.result = 'ignored'
activity_log.exception_message = 'Only remote users can be deleted remotely'
else:
activity_log.result = 'ignored'
activity_log.exception_message = 'Does not exist here'
db.session.commit()
def announce_activity_to_followers(community, creator, activity):
announce_activity = {
'@context': default_context(),
"actor": community.profile_id(),
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"object": activity,
"cc": [
f"{community.profile_id()}/followers"
],
"type": "Announce",
}
for instance in community.following_instances(include_dormant=True):
# awaken dormant instances if they've been sleeping for long enough to be worth trying again
awaken_dormant_instance(instance)
# All good? Send!
if instance and instance.online() and not instance_blocked(instance.inbox):
if creator.instance_id != instance.id: # don't send it to the instance that hosts the creator as presumably they already have the content
announce_activity['id'] = f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}"
send_to_remote_instance(instance.id, community.id, announce_activity)
@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))
2023-08-05 21:24:10 +12:00
return jsonify(community_data)
2023-08-05 21:24:10 +12:00
2023-11-26 23:20:51 +13:00
@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)
2023-12-27 14:38:41 +13:00
@bp.route('/u/<actor>/inbox', methods=['GET', 'POST'])
def user_inbox(actor):
resp = jsonify('ok')
resp.content_type = 'application/activity+json'
return resp
2023-08-05 21:24:10 +12:00
2023-12-27 14:38:41 +13:00
@bp.route('/c/<actor>/inbox', methods=['GET', 'POST'])
def community_inbox(actor):
return shared_inbox()
2023-08-05 21:24:10 +12:00
2023-12-27 14:38:41 +13:00
@bp.route('/c/<actor>/followers', methods=['GET'])
def community_followers(actor):
actor = actor.strip()
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None:
result = {
"@context": default_context(),
"id": f'https://{current_app.config["SERVER_NAME"]}/c/actor/followers',
"type": "Collection",
"totalItems": community_members(community.id),
"items": []
}
resp = jsonify(result)
resp.content_type = 'application/activity+json'
return resp
else:
abort(404)
@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,
'summary': markdown_to_text(reply.body),
'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)
return continue_discussion(reply.post.id, comment_id)
2024-01-12 20:21:41 +13:00
@bp.route('/post/<int:post_id>/', methods=['GET'])
def post_ap2(post_id):
return redirect(url_for('activitypub.post_ap', post_id=post_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)
2023-12-22 14:05:39 +13:00
@bp.route('/activities/<type>/<id>')
@cache.cached(timeout=600)
2023-12-22 14:05:39 +13:00
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:
2023-12-22 15:34:45 +13:00
activity_json = json.loads(activity.activity_json)
resp = jsonify(activity_json)
resp.content_type = 'application/activity+json'
return resp
2023-12-22 14:05:39 +13:00
else:
abort(404)