Merge remote-tracking branch 'origin/main'

# Conflicts:
#	app/activitypub/routes.py
#	app/activitypub/util.py
This commit is contained in:
rimu 2024-03-27 16:07:21 +13:00
commit bf11490011
4 changed files with 222 additions and 59 deletions

View file

@ -18,8 +18,8 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti
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, \
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, render_template, \
update_post_from_activity, undo_vote, undo_downvote, post_to_page
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_post, awaken_dormant_instance, shorten_string, can_create_post_reply, sha256_digest, \
community_moderators
@ -432,20 +432,43 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
activity_log.result = 'success'
else:
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]
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
community_ap_id = ''
locations = ['audience', 'cc', 'to']
if 'object' in request_json:
rjs = [ request_json, request_json['object'] ]
else:
rjs = [ request_json ]
local_community_prefix = f"https://{current_app.config['SERVER_NAME']}/c/"
followers_suffix = '/followers'
for rj in rjs:
for loc in locations:
if loc in rj:
id = rj[loc]
if isinstance(id, str):
if id.startswith(local_community_prefix) and not id.endswith(followers_suffix):
community_ap_id = id
if isinstance(id, list):
for c in id:
if c.startswith(local_community_prefix) and not c.endswith(followers_suffix):
community_ap_id = c
break
if community_ap_id:
break
if community_ap_id:
break
if not community_ap_id and 'object' in request_json and '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
else:
comment_being_replied_to = PostReply.query.filter_by(ap_id=request_json['object']['inReplyTo']).first()
if comment_being_replied_to:
community_ap_id = comment_being_replied_to.community.ap_profile_id
if not community_ap_id:
activity_log.result = 'failure'
activity_log.exception_message = 'Unable to extract community'
db.session.commit()
return
except:
activity_log.activity_type = 'exception'
db.session.commit()
@ -1029,6 +1052,27 @@ def community_outbox(actor):
return jsonify(community_data)
@bp.route('/c/<actor>/featured', methods=['GET'])
def community_featured(actor):
actor = actor.strip()
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None:
posts = Post.query.filter_by(community_id=community.id, sticky=True).all()
community_data = {
"@context": default_context(),
"type": "OrderedCollection",
"id": f"https://{current_app.config['SERVER_NAME']}/c/{actor}/featured",
"totalItems": len(posts),
"orderedItems": []
}
for post in posts:
community_data['orderedItems'].append(post_to_page(post, community))
return jsonify(community_data)
@bp.route('/c/<actor>/moderators', methods=['GET'])
def community_moderators_route(actor):
actor = actor.strip()
@ -1069,7 +1113,7 @@ def community_followers(actor):
if community is not None:
result = {
"@context": default_context(),
"id": f'https://{current_app.config["SERVER_NAME"]}/c/actor/followers',
"id": f'https://{current_app.config["SERVER_NAME"]}/c/{actor}/followers',
"type": "Collection",
"totalItems": community_members(community.id),
"items": []

View file

@ -25,7 +25,7 @@ import pytesseract
from app.utils import get_request, allowlist_html, get_setting, ap_datetime, markdown_to_html, \
is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text, head_request, post_ranking, \
shorten_string, reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, remove_tracking_from_link, \
blocked_phrases
blocked_phrases, microblog_content_to_title
def public_key():
@ -178,6 +178,43 @@ def post_to_activity(post: Post, community: Community):
return activity_data
def post_to_page(post: Post, community: Community):
activity_data = {
"type": "Page",
"id": post.ap_id,
"attributedTo": post.author.ap_public_url,
"to": [
f"https://{current_app.config['SERVER_NAME']}/c/{community.name}",
"https://www.w3.org/ns/activitystreams#Public"
],
"name": post.title,
"cc": [],
"content": post.body_html if post.body_html else '',
"mediaType": "text/html",
"source": {
"content": post.body if post.body else '',
"mediaType": "text/markdown"
},
"attachment": [],
"commentsEnabled": post.comments_enabled,
"sensitive": post.nsfw or post.nsfl,
"published": ap_datetime(post.created_at),
"stickied": post.sticky,
"audience": f"https://{current_app.config['SERVER_NAME']}/c/{community.name}"
}
if post.edited_at is not None:
activity_data["updated"] = ap_datetime(post.edited_at)
if post.language is not None:
activity_data["language"] = {"identifier": post.language}
if post.type == POST_TYPE_LINK and post.url is not None:
activity_data["attachment"] = [{"href": post.url, "type": "Link"}]
if post.image_id is not None:
activity_data["image"] = {"url": post.image.view_url(), "type": "Image"}
if post.image.alt_text:
activity_data["image"]['altText'] = post.image.alt_text
return activity_data
def banned_user_agents():
return [] # todo: finish this function
@ -1258,11 +1295,17 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
activity_log.exception_message = 'Community is local only, post discarded'
activity_log.result = 'ignored'
return None
if 'name' not in request_json['object']: # Microblog posts sometimes get Announced by lemmy. They don't have a title, so we can't use them.
return None
nsfl_in_title = '[NSFL]' in request_json['object']['name'].upper() or '(NSFL)' in request_json['object']['name'].upper()
if 'name' not in request_json['object']: # Microblog posts
if 'content' in request_json['object'] and request_json['object']['content'] is not None:
name = "[Microblog]"
else:
return None
else:
name = request_json['object']['name']
nsfl_in_title = '[NSFL]' in name.upper() or '(NSFL)' in name.upper()
post = Post(user_id=user.id, community_id=community.id,
title=html.unescape(request_json['object']['name']),
title=html.unescape(name),
comments_enabled=request_json['object']['commentsEnabled'] if 'commentsEnabled' in request_json['object'] else True,
sticky=request_json['object']['stickied'] if 'stickied' in request_json['object'] else False,
nsfw=request_json['object']['sensitive'] if 'sensitive' in request_json['object'] else False,
@ -1283,7 +1326,12 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
post.body_html = markdown_to_html(post.body)
elif 'content' in request_json['object'] and request_json['object']['content'] is not None: # Kbin
post.body_html = allowlist_html(request_json['object']['content'])
post.body = ''
post.body = html_to_markdown(post.body_html)
if name == "[Microblog]":
name += ' ' + microblog_content_to_title(post.body_html)
if '[NSFL]' in name.upper() or '(NSFL)' in name.upper():
post.nsfl = True
post.title = name
# Discard post if it contains certain phrases. Good for stopping spam floods.
blocked_phrases_list = blocked_phrases()
for blocked_phrase in blocked_phrases_list:
@ -1296,7 +1344,10 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
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']
post.url = request_json['object']['attachment'][0]['href'] # Lemmy
if request_json['object']['attachment'][0]['type'] == 'Document':
post.url = request_json['object']['attachment'][0]['url'] # Mastodon
if post.url:
if is_image_url(post.url):
post.type = POST_TYPE_IMAGE
if 'image' in request_json['object'] and 'url' in request_json['object']['image']:

View file

@ -539,14 +539,13 @@ def add_reply(post_id: int, comment_id: int):
reply_json = {
'type': 'Note',
'id': reply.profile_id(),
'attributedTo': current_user.profile_id(),
'attributedTo': current_user.public_url(),
'to': [
'https://www.w3.org/ns/activitystreams#Public',
in_reply_to.author.profile_id()
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
post.community.profile_id(),
current_user.followers_url()
post.community.public_url(),
in_reply_to.author.public_url()
],
'content': reply.body_html,
'inReplyTo': in_reply_to.profile_id(),
@ -558,7 +557,7 @@ def add_reply(post_id: int, comment_id: int):
},
'published': ap_datetime(utcnow()),
'distinguished': False,
'audience': post.community.profile_id(),
'audience': post.community.public_url(),
'contentMap': {
'en': reply.body_html
}
@ -566,15 +565,14 @@ def add_reply(post_id: int, comment_id: int):
create_json = {
'@context': default_context(),
'type': 'Create',
'actor': current_user.profile_id(),
'audience': post.community.profile_id(),
'actor': current_user.public_url(),
'audience': post.community.public_url(),
'to': [
'https://www.w3.org/ns/activitystreams#Public',
in_reply_to.author.profile_id()
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
post.community.profile_id(),
current_user.followers_url()
post.community.public_url(),
in_reply_to.author.public_url()
],
'object': reply_json,
'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}"
@ -582,8 +580,15 @@ def add_reply(post_id: int, comment_id: int):
if in_reply_to.notify_author and in_reply_to.author.ap_id is not None:
reply_json['tag'] = [
{
'href': in_reply_to.author.ap_profile_id,
'name': '@' + in_reply_to.author.ap_id,
'href': in_reply_to.author.public_url(),
'name': in_reply_to.author.mention_tag(),
'type': 'Mention'
}
]
create_json['tag'] = [
{
'href': in_reply_to.author.public_url(),
'name': in_reply_to.author.mention_tag(),
'type': 'Mention'
}
]
@ -599,7 +604,7 @@ def add_reply(post_id: int, comment_id: int):
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"actor": post.community.ap_profile_id,
"actor": post.community.public_url(),
"cc": [
post.community.ap_followers_url
],
@ -611,6 +616,17 @@ def add_reply(post_id: int, comment_id: int):
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance.id, post.community.id, announce)
# send copy of Note to comment author (who won't otherwise get it if no-one else on their instance is subscribed to the community)
if not in_reply_to.author.is_local() and in_reply_to.author.ap_domain != reply.community.ap_domain:
if not post.community.is_local() or (post.community.is_local and not post.community.has_followers_from_domain(in_reply_to.author.ap_domain)):
success = post_request(in_reply_to.author.ap_inbox_url, create_json, current_user.private_key,
current_user.ap_profile_id + '#main-key')
if not success:
# sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers
personal_inbox = in_reply_to.author.public_url() + '/inbox'
post_request(personal_inbox, create_json, current_user.private_key,
current_user.ap_profile_id + '#main-key')
if reply.depth <= constants.THREAD_CUTOFF_DEPTH:
return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.id}'))
else:
@ -1047,14 +1063,13 @@ def post_reply_edit(post_id: int, comment_id: int):
reply_json = {
'type': 'Note',
'id': post_reply.profile_id(),
'attributedTo': current_user.profile_id(),
'attributedTo': current_user.public_url(),
'to': [
'https://www.w3.org/ns/activitystreams#Public',
in_reply_to.author.profile_id()
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
post.community.profile_id(),
current_user.followers_url()
post.community.public_url(),
in_reply_to.author.public_url()
],
'content': post_reply.body_html,
'inReplyTo': in_reply_to.profile_id(),
@ -1067,37 +1082,54 @@ def post_reply_edit(post_id: int, comment_id: int):
'published': ap_datetime(post_reply.posted_at),
'updated': ap_datetime(post_reply.edited_at),
'distinguished': False,
'audience': post.community.profile_id(),
'audience': post.community.public_url(),
'contentMap': {
'en': post_reply.body_html
}
}
update_json = {
'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}",
'@context': default_context(),
'type': 'Update',
'actor': current_user.profile_id(),
'audience': post.community.profile_id(),
'to': [post.community.profile_id(), 'https://www.w3.org/ns/activitystreams#Public'],
'published': ap_datetime(utcnow()),
'actor': current_user.public_url(),
'audience': post.community.public_url(),
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
current_user.followers_url()
post.community.public_url(),
in_reply_to.author.public_url()
],
'object': reply_json,
'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}"
}
if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it
if in_reply_to.notify_author and in_reply_to.author.ap_id is not None:
reply_json['tag'] = [
{
'href': in_reply_to.author.public_url(),
'name': in_reply_to.author.mention_tag(),
'type': 'Mention'
}
]
update_json['tag'] = [
{
'href': in_reply_to.author.public_url(),
'name': in_reply_to.author.mention_tag(),
'type': 'Mention'
}
]
if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it
success = post_request(post.community.ap_inbox_url, update_json, current_user.private_key,
current_user.ap_profile_id + '#main-key')
current_user.ap_profile_id + '#main-key')
if not success:
flash('Failed to send edit to remote server', 'error')
else: # local community - send it to followers on remote instances
flash('Failed to send send edit to remote server', 'error')
else: # local community - send it to followers on remote instances
announce = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}",
"type": 'Announce',
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"actor": post.community.ap_profile_id,
"actor": post.community.public_url(),
"cc": [
post.community.ap_followers_url
],
@ -1106,9 +1138,20 @@ def post_reply_edit(post_id: int, comment_id: int):
}
for instance in post.community.following_instances():
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(
instance.domain):
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance.id, post.community.id, announce)
# send copy of Note to post author (who won't otherwise get it if no-one else on their instance is subscribed to the community)
if not in_reply_to.author.is_local() and in_reply_to.author.ap_domain != post_reply.community.ap_domain:
if not post.community.is_local() or (post.community.is_local and not post.community.has_followers_from_domain(in_reply_to.author.ap_domain)):
success = post_request(in_reply_to.author.ap_inbox_url, update_json, current_user.private_key,
current_user.ap_profile_id + '#main-key')
if not success:
# sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers
personal_inbox = in_reply_to.author.public_url() + '/inbox'
post_request(personal_inbox, update_json, current_user.private_key,
current_user.ap_profile_id + '#main-key')
return redirect(url_for('activitypub.post_ap', post_id=post.id))
else:
form.body.data = post_reply.body

View file

@ -227,6 +227,31 @@ def markdown_to_text(markdown_text) -> str:
return markdown_text.replace("# ", '')
def microblog_content_to_title(html: str) -> str:
soup = BeautifulSoup(html, 'html.parser')
title_found = False
for tag in soup.find_all():
if tag.name == 'p':
if not title_found:
title_found = True
continue
else:
tag = tag.extract()
if title_found:
result = soup.text
if len(result) > 150:
for i in range(149, -1, -1):
if result[i] == ' ':
break;
result = result[:i] + ' ...' if i > 0 else ''
else:
result = ''
return result
def domain_from_url(url: str, create=True) -> Domain:
parsed_url = urlparse(url.lower().replace('www.', ''))
if parsed_url and parsed_url.hostname: