Merge remote-tracking branch 'origin/main'

This commit is contained in:
rimu 2025-01-19 14:59:58 +13:00
commit ab217dc318
22 changed files with 1197 additions and 920 deletions

View file

@ -672,6 +672,7 @@ def process_inbox_request(request_json, store_ap_json):
if community_membership(user, community) != SUBSCRIPTION_MEMBER:
member = CommunityMember(user_id=user.id, community_id=community.id)
db.session.add(member)
community.subscriptions_count += 1
db.session.commit()
cache.delete_memoized(community_membership, user, community)
# send accept message to acknowledge the follow

View file

@ -1483,7 +1483,7 @@ def ban_user(blocker, blocked, community, core_activity):
# Notify banned person
notify = Notification(title=shorten_string('You have been banned from ' + community.title),
url=f'/notifications', user_id=blocked.id,
url=f'/chat/ban_from_mod/{blocked.id}/{community.id}', user_id=blocked.id,
author_id=blocker.id)
db.session.add(notify)
if not current_app.debug: # user.unread_notifications += 1 hangs app if 'user' is the same person
@ -1503,7 +1503,10 @@ def ban_user(blocker, blocked, community, core_activity):
def unban_user(blocker, blocked, community, core_activity):
reason = core_activity['summary'] if 'summary' in core_activity else ''
if 'object' in core_activity and 'summary' in core_activity['object']:
reason = core_activity['object']['summary']
else:
reason = ''
db.session.query(CommunityBan).filter(CommunityBan.community_id == community.id, CommunityBan.user_id == blocked.id).delete()
community_membership_record = CommunityMember.query.filter_by(community_id=community.id, user_id=blocked.id).first()
if community_membership_record:
@ -1513,7 +1516,7 @@ def unban_user(blocker, blocked, community, core_activity):
if blocked.is_local():
# Notify unbanned person
notify = Notification(title=shorten_string('You have been unbanned from ' + community.title),
url=f'/notifications', user_id=blocked.id, author_id=blocker.id)
url=f'/chat/ban_from_mod/{blocked.id}/{community.id}', user_id=blocked.id, author_id=blocker.id)
db.session.add(notify)
if not current_app.debug: # user.unread_notifications += 1 hangs app if 'user' is the same person
blocked.unread_notifications += 1 # who pressed 'Re-submit this activity'.
@ -1571,8 +1574,8 @@ def create_post_reply(store_ap_json, community: Community, in_reply_to, request_
# Check for Mentions of local users
reply_parent = parent_comment if parent_comment else post
local_users_to_notify = []
if 'tag' in request_json and isinstance(request_json['tag'], list):
for json_tag in request_json['tag']:
if 'tag' in request_json['object'] and isinstance(request_json['object']['tag'], list):
for json_tag in request_json['object']['tag']:
if 'type' in json_tag and json_tag['type'] == 'Mention':
profile_id = json_tag['href'] if 'href' in json_tag else None
if profile_id and isinstance(profile_id, str) and profile_id.startswith('https://' + current_app.config['SERVER_NAME']):
@ -1690,8 +1693,8 @@ def update_post_reply_from_activity(reply: PostReply, request_json: dict):
reply.edited_at = utcnow()
# Check for Mentions of local users (that weren't in the original)
if 'tag' in request_json and isinstance(request_json['tag'], list):
for json_tag in request_json['tag']:
if 'tag' in request_json['object'] and isinstance(request_json['object']['tag'], list):
for json_tag in request_json['object']['tag']:
if 'type' in json_tag and json_tag['type'] == 'Mention':
profile_id = json_tag['href'] if 'href' in json_tag else None
if profile_id and isinstance(profile_id, str) and profile_id.startswith('https://' + current_app.config['SERVER_NAME']):
@ -1778,6 +1781,21 @@ def update_post_from_activity(post: Post, request_json: dict):
hashtag = find_hashtag_or_create(json_tag['name'])
if hashtag:
post.tags.append(hashtag)
if 'type' in json_tag and json_tag['type'] == 'Mention':
profile_id = json_tag['href'] if 'href' in json_tag else None
if profile_id and isinstance(profile_id, str) and profile_id.startswith('https://' + current_app.config['SERVER_NAME']):
profile_id = profile_id.lower()
recipient = User.query.filter_by(ap_profile_id=profile_id, ap_id=None).first()
if recipient:
blocked_senders = blocked_users(recipient.id)
if post.user_id not in blocked_senders:
existing_notification = Notification.query.filter(Notification.user_id == recipient.id, Notification.url == f"https://{current_app.config['SERVER_NAME']}/post/{post.id}").first()
if not existing_notification:
notification = Notification(user_id=recipient.id, title=_(f"You have been mentioned in post {post.id}"),
url=f"https://{current_app.config['SERVER_NAME']}/post/{post.id}",
author_id=post.user_id)
recipient.unread_notifications += 1
db.session.add(notification)
post.comments_enabled = request_json['object']['commentsEnabled'] if 'commentsEnabled' in request_json['object'] else True
post.edited_at = utcnow()
@ -2385,112 +2403,92 @@ def resolve_remote_post(uri: str, community_id: int, announce_actor=None, store_
return None
# called from UI, via 'search' option in navbar
def resolve_remote_post_from_search(uri: str) -> Union[Post, None]:
post = Post.query.filter_by(ap_id=uri).first()
if post:
return post
site = Site.query.get(1)
parsed_url = urlparse(uri)
uri_domain = parsed_url.netloc
actor_domain = None
actor = None
post_request = get_request(uri, headers={'Accept': 'application/activity+json'})
if post_request.status_code == 200:
post_data = post_request.json()
post_request.close()
# check again that it doesn't already exist (can happen with different but equivalent URLs)
post = Post.query.filter_by(ap_id=post_data['id']).first()
if post:
return post
# find the author of the post. Make sure their domain matches the site hosting it to mitigate impersonation attempts
if 'attributedTo' in post_data:
attributed_to = post_data['attributedTo']
if isinstance(attributed_to, str):
actor = attributed_to
parsed_url = urlparse(actor)
actor_domain = parsed_url.netloc
elif isinstance(attributed_to, list):
for a in attributed_to:
if isinstance(a, dict) and a.get('type') == 'Person':
actor = a.get('id')
if isinstance(actor, str): # Ensure `actor` is a valid string
parsed_url = urlparse(actor)
actor_domain = parsed_url.netloc
break
elif isinstance(a, str):
actor = a
try:
object_request = get_request(uri, headers={'Accept': 'application/activity+json'})
except httpx.HTTPError:
time.sleep(3)
try:
object_request = get_request(uri, headers={'Accept': 'application/activity+json'})
except httpx.HTTPError:
return None
if object_request.status_code == 200:
try:
post_data = object_request.json()
except:
object_request.close()
return None
object_request.close()
elif object_request.status_code == 401:
try:
site = Site.query.get(1)
object_request = signed_get_request(uri, site.private_key, f"https://{current_app.config['SERVER_NAME']}/actor#main-key")
except httpx.HTTPError:
time.sleep(3)
try:
object_request = signed_get_request(uri, site.private_key, f"https://{current_app.config['SERVER_NAME']}/actor#main-key")
except httpx.HTTPError:
return None
try:
post_data = object_request.json()
except:
object_request.close()
return None
object_request.close()
else:
return None
# check again that it doesn't already exist (can happen with different but equivalent URLs)
post = Post.query.filter_by(ap_id=post_data['id']).first()
if post:
return post
# find the author of the post. Make sure their domain matches the site hosting it to mitigate impersonation attempts
if 'attributedTo' in post_data:
attributed_to = post_data['attributedTo']
if isinstance(attributed_to, str):
actor = attributed_to
parsed_url = urlparse(actor)
actor_domain = parsed_url.netloc
elif isinstance(attributed_to, list):
for a in attributed_to:
if isinstance(a, dict) and a.get('type') == 'Person':
actor = a.get('id')
if isinstance(actor, str): # Ensure `actor` is a valid string
parsed_url = urlparse(actor)
actor_domain = parsed_url.netloc
break
if uri_domain != actor_domain:
return None
break
elif isinstance(a, str):
actor = a
parsed_url = urlparse(actor)
actor_domain = parsed_url.netloc
break
if uri_domain != actor_domain:
return None
# find the community the post was submitted to
community = None
if not community and post_data['type'] == 'Page': # lemmy
if 'audience' in post_data:
community_id = post_data['audience']
community = find_actor_or_create(community_id, community_only=True)
if not community and post_data['type'] == 'Video': # peertube
if 'attributedTo' in post_data and isinstance(post_data['attributedTo'], list):
for a in post_data['attributedTo']:
if a['type'] == 'Group':
community_id = a['id']
community = find_actor_or_create(community_id, community_only=True)
if community:
break
if not community: # mastodon, etc
if 'inReplyTo' not in post_data or post_data['inReplyTo'] != None:
return None
if not community and 'to' in post_data and isinstance(post_data['to'], str):
community_id = post_data['to'].lower()
if not community_id == 'https://www.w3.org/ns/activitystreams#Public' and not community_id.endswith('/followers'):
community = Community.query.filter_by(ap_profile_id=community_id).first()
if not community and 'cc' in post_data and isinstance(post_data['cc'], str):
community_id = post_data['cc'].lower()
if not community_id == 'https://www.w3.org/ns/activitystreams#Public' and not community_id.endswith('/followers'):
community = Community.query.filter_by(ap_profile_id=community_id).first()
if not community and 'to' in post_data and isinstance(post_data['to'], list):
for t in post_data['to']:
community_id = t.lower()
if not community_id == 'https://www.w3.org/ns/activitystreams#Public' and not community_id.endswith('/followers'):
community = Community.query.filter_by(ap_profile_id=community_id).first()
if community:
break
if not community and 'cc' in post_data and isinstance(post_data['to'], list):
for c in post_data['cc']:
community_id = c.lower()
if not community_id == 'https://www.w3.org/ns/activitystreams#Public' and not community_id.endswith('/followers'):
community = Community.query.filter_by(ap_profile_id=community_id).first()
if community:
break
if not community:
return None
activity_log = ActivityPubLog(direction='in', activity_id=post_data['id'], activity_type='Resolve Post', result='failure')
if site.log_activitypub_json:
activity_log.activity_json = json.dumps(post_data)
db.session.add(activity_log)
user = find_actor_or_create(actor)
if user and community and post_data:
request_json = {
'id': f"https://{uri_domain}/activities/create/gibberish(15)",
'object': post_data
}
post = create_post(activity_log, community, request_json, user)
if post:
if 'published' in post_data:
post.posted_at=post_data['published']
post.last_active=post_data['published']
db.session.commit()
return post
# find the community the post was submitted to
community = find_community(post_data)
# find the post's author
user = find_actor_or_create(actor)
if user and community and post_data:
request_json = {'id': f"https://{uri_domain}/activities/create/gibberish(15)", 'object': post_data}
post = create_post(False, community, request_json, user)
if post:
if 'published' in post_data:
post.posted_at=post_data['published']
post.last_active=post_data['published']
db.session.commit()
return post
return None
@ -2569,21 +2567,7 @@ def verify_object_from_source(request_json):
return request_json
# This is for followers on microblog apps
# Used to let them know a Poll has been updated with a new vote
# The plan is to also use it for activities on local user's posts that aren't understood by being Announced (anything beyond the initial Create)
# This would need for posts to have things like a 'Replies' collection and a 'Likes' collection, so these can be downloaded when the post updates
# Using collecions like this (as PeerTube does) circumvents the problem of not having a remote user's private key.
# The problem of what to do for remote user's activity on a remote user's post in a local community still exists (can't Announce it, can't inform of post update)
def inform_followers_of_post_update(post_id: int, sending_instance_id: int):
if current_app.debug:
inform_followers_of_post_update_task(post_id, sending_instance_id)
else:
inform_followers_of_post_update_task.delay(post_id, sending_instance_id)
@celery.task
def inform_followers_of_post_update_task(post_id: int, sending_instance_id: int):
post = Post.query.get(post_id)
page_json = post_to_page(post)
page_json['updated'] = ap_datetime(utcnow())

View file

@ -1,7 +1,7 @@
from app.api.alpha import bp
from app.api.alpha.utils import get_site, post_site_block, \
get_search, \
get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, \
get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, post_post, put_post, post_post_delete, post_post_report, \
get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_delete, post_reply_report, \
get_community_list, get_community, post_community_follow, post_community_block, \
get_user, post_user_block
@ -9,12 +9,14 @@ from app.shared.auth import log_user_in
from flask import current_app, jsonify, request
def enable_api():
return True if current_app.debug else False
# Site
@bp.route('/api/alpha/site', methods=['GET'])
def get_alpha_site():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
return jsonify(get_site(auth))
@ -24,8 +26,8 @@ def get_alpha_site():
@bp.route('/api/alpha/site/block', methods=['POST'])
def get_alpha_site_block():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -37,8 +39,8 @@ def get_alpha_site_block():
# Misc
@bp.route('/api/alpha/search', methods=['GET'])
def get_alpha_search():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.args.to_dict() or None
@ -50,8 +52,8 @@ def get_alpha_search():
# Community
@bp.route('/api/alpha/community', methods=['GET'])
def get_alpha_community():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.args.to_dict() or None
@ -62,8 +64,8 @@ def get_alpha_community():
@bp.route('/api/alpha/community/list', methods=['GET'])
def get_alpha_community_list():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.args.to_dict() or None
@ -74,8 +76,8 @@ def get_alpha_community_list():
@bp.route('/api/alpha/community/follow', methods=['POST'])
def post_alpha_community_follow():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -86,8 +88,8 @@ def post_alpha_community_follow():
@bp.route('/api/alpha/community/block', methods=['POST'])
def post_alpha_community_block():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -99,8 +101,8 @@ def post_alpha_community_block():
# Post
@bp.route('/api/alpha/post/list', methods=['GET'])
def get_alpha_post_list():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.args.to_dict() or None
@ -111,8 +113,8 @@ def get_alpha_post_list():
@bp.route('/api/alpha/post', methods=['GET'])
def get_alpha_post():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.args.to_dict() or None
@ -123,8 +125,8 @@ def get_alpha_post():
@bp.route('/api/alpha/post/like', methods=['POST'])
def post_alpha_post_like():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -135,8 +137,8 @@ def post_alpha_post_like():
@bp.route('/api/alpha/post/save', methods=['PUT'])
def put_alpha_post_save():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -147,8 +149,8 @@ def put_alpha_post_save():
@bp.route('/api/alpha/post/subscribe', methods=['PUT'])
def put_alpha_post_subscribe():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -157,11 +159,59 @@ def put_alpha_post_subscribe():
return jsonify({"error": str(ex)}), 400
@bp.route('/api/alpha/post', methods=['POST'])
def post_alpha_post():
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
return jsonify(post_post(auth, data))
except Exception as ex:
return jsonify({"error": str(ex)}), 400
@bp.route('/api/alpha/post', methods=['PUT'])
def put_alpha_post():
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
return jsonify(put_post(auth, data))
except Exception as ex:
return jsonify({"error": str(ex)}), 400
@bp.route('/api/alpha/post/delete', methods=['POST'])
def post_alpha_post_delete():
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
return jsonify(post_post_delete(auth, data))
except Exception as ex:
return jsonify({"error": str(ex)}), 400
@bp.route('/api/alpha/post/report', methods=['POST'])
def post_alpha_post_report():
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
return jsonify(post_post_report(auth, data))
except Exception as ex:
return jsonify({"error": str(ex)}), 400
# Reply
@bp.route('/api/alpha/comment/list', methods=['GET'])
def get_alpha_comment_list():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.args.to_dict() or None
@ -172,8 +222,8 @@ def get_alpha_comment_list():
@bp.route('/api/alpha/comment/like', methods=['POST'])
def post_alpha_comment_like():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -184,8 +234,8 @@ def post_alpha_comment_like():
@bp.route('/api/alpha/comment/save', methods=['PUT'])
def put_alpha_comment_save():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -196,8 +246,8 @@ def put_alpha_comment_save():
@bp.route('/api/alpha/comment/subscribe', methods=['PUT'])
def put_alpha_comment_subscribe():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -208,8 +258,8 @@ def put_alpha_comment_subscribe():
@bp.route('/api/alpha/comment', methods=['POST'])
def post_alpha_comment():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -220,8 +270,8 @@ def post_alpha_comment():
@bp.route('/api/alpha/comment', methods=['PUT'])
def put_alpha_comment():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -232,8 +282,8 @@ def put_alpha_comment():
@bp.route('/api/alpha/comment/delete', methods=['POST'])
def post_alpha_comment_delete():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -244,8 +294,8 @@ def post_alpha_comment_delete():
@bp.route('/api/alpha/comment/report', methods=['POST'])
def post_alpha_comment_report():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -257,8 +307,8 @@ def post_alpha_comment_report():
# User
@bp.route('/api/alpha/user', methods=['GET'])
def get_alpha_user():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.args.to_dict() or None
@ -269,8 +319,8 @@ def get_alpha_user():
@bp.route('/api/alpha/user/login', methods=['POST'])
def post_alpha_user_login():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
SRC_API = 3 # would be in app.constants
data = request.get_json(force=True) or {}
@ -281,8 +331,8 @@ def post_alpha_user_login():
@bp.route('/api/alpha/user/block', methods=['POST'])
def post_alpha_user_block():
if not current_app.debug:
return jsonify({'error': 'alpha api routes only available in debug mode'})
if not enable_api():
return jsonify({'error': 'alpha api is not enabled'})
try:
auth = request.headers.get('Authorization')
data = request.get_json(force=True) or {}
@ -319,9 +369,6 @@ def alpha_community():
return jsonify({"error": "not_yet_implemented"}), 400
# Post - not yet implemented
@bp.route('/api/alpha/post', methods=['PUT'])
@bp.route('/api/alpha/post', methods=['POST'])
@bp.route('/api/alpha/post/delete', methods=['POST'])
@bp.route('/api/alpha/post/remove', methods=['POST'])
@bp.route('/api/alpha/post/lock', methods=['POST'])
@bp.route('/api/alpha/post/feature', methods=['POST'])

View file

@ -1,6 +1,6 @@
from app.api.alpha.utils.site import get_site, post_site_block
from app.api.alpha.utils.misc import get_search
from app.api.alpha.utils.post import get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe
from app.api.alpha.utils.post import get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, post_post, put_post, post_post_delete, post_post_report
from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_delete, post_reply_report
from app.api.alpha.utils.community import get_community, get_community_list, post_community_follow, post_community_block
from app.api.alpha.utils.user import get_user, post_user_block

View file

@ -1,9 +1,10 @@
from app import cache
from app.api.alpha.views import post_view
from app.api.alpha.utils.validators import required, integer_expected, boolean_expected
from app.api.alpha.views import post_view, post_report_view
from app.api.alpha.utils.validators import required, integer_expected, boolean_expected, string_expected
from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO
from app.models import Post, Community, CommunityMember, utcnow
from app.shared.post import vote_for_post, bookmark_the_post, remove_the_bookmark_from_post, toggle_post_notification
from app.utils import authorise_api_user, blocked_users, blocked_communities, blocked_instances, community_ids_from_instances
from app.shared.post import vote_for_post, bookmark_the_post, remove_the_bookmark_from_post, toggle_post_notification, make_post, edit_post, delete_post, restore_post, report_post
from app.utils import authorise_api_user, blocked_users, blocked_communities, blocked_instances, community_ids_from_instances, is_image_url, is_video_url
from datetime import timedelta
from sqlalchemy import desc
@ -149,3 +150,92 @@ def put_post_subscribe(auth, data):
post_json = post_view(post=post_id, variant=4, user_id=user_id)
return post_json
def post_post(auth, data):
required(['title', 'community_id'], data)
integer_expected(['language_id'], data)
boolean_expected(['nsfw'], data)
string_expected(['title', 'body', 'url'], data)
title = data['title']
community_id = data['community_id']
body = data['body'] if 'body' in data else ''
url = data['url'] if 'url' in data else None
nsfw = data['nsfw'] if 'nsfw' in data else False
language_id = data['language_id'] if 'language_id' in data else 2 # FIXME: use site language
if language_id < 2:
language_id = 2
# change when Polls are supported
type = POST_TYPE_ARTICLE
if url:
type = POST_TYPE_LINK
input = {'title': title, 'body': body, 'url': url, 'nsfw': nsfw, 'language_id': language_id, 'notify_author': True}
community = Community.query.filter_by(id=community_id).one()
user_id, post = make_post(input, community, type, SRC_API, auth)
post_json = post_view(post=post, variant=4, user_id=user_id)
return post_json
def put_post(auth, data):
required(['post_id'], data)
integer_expected(['language_id'], data)
boolean_expected(['nsfw'], data)
string_expected(['title', 'body', 'url'], data)
post_id = data['post_id']
title = data['title']
body = data['body'] if 'body' in data else ''
url = data['url'] if 'url' in data else None
nsfw = data['nsfw'] if 'nsfw' in data else False
language_id = data['language_id'] if 'language_id' in data else 2 # FIXME: use site language
if language_id < 2:
language_id = 2
# change when Polls are supported
type = POST_TYPE_ARTICLE
if url:
type = POST_TYPE_LINK
input = {'title': title, 'body': body, 'url': url, 'nsfw': nsfw, 'language_id': language_id, 'notify_author': True}
post = Post.query.filter_by(id=post_id).one()
user_id, post = edit_post(input, post, type, SRC_API, auth=auth)
post_json = post_view(post=post, variant=4, user_id=user_id)
return post_json
def post_post_delete(auth, data):
required(['post_id', 'deleted'], data)
integer_expected(['post_id'], data)
boolean_expected(['deleted'], data)
post_id = data['post_id']
deleted = data['deleted']
if deleted == True:
user_id, post = delete_post(post_id, SRC_API, auth)
else:
user_id, post = restore_post(post_id, SRC_API, auth)
post_json = post_view(post=post, variant=4, user_id=user_id)
return post_json
def post_post_report(auth, data):
required(['post_id', 'reason'], data)
integer_expected(['post_id'], data)
string_expected(['reason'], data)
post_id = data['post_id']
reason = data['reason']
input = {'reason': reason, 'description': '', 'report_remote': True}
user_id, report = report_post(post_id, input, SRC_API, auth)
post_json = post_report_view(report=report, post_id=post_id, user_id=user_id)
return post_json

View file

@ -373,6 +373,47 @@ def reply_report_view(report, reply_id, user_id):
return v1
def post_report_view(report, post_id, user_id):
# views/post_report_view.dart - /post/report api endpoint
post_json = post_view(post=post_id, variant=2, user_id=user_id)
community_json = community_view(community=post_json['post']['community_id'], variant=1, stub=True)
banned = db.session.execute(text('SELECT user_id FROM "community_ban" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': report.reporter_id, 'community_id': community_json['id']}).scalar()
moderator = db.session.execute(text('SELECT is_moderator FROM "community_member" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': report.reporter_id, 'community_id': community_json['id']}).scalar()
admin = db.session.execute(text('SELECT user_id FROM "user_role" WHERE user_id = :user_id and role_id = 4'), {'user_id': report.reporter_id}).scalar()
creator_banned_from_community = True if banned else False
creator_is_moderator = True if moderator else False
creator_is_admin = True if admin else False
v1 = {
'post_report_view': {
'post_report': {
'id': report.id,
'creator_id': report.reporter_id,
'post_id': report.suspect_post_id,
'original_post_name': post_json['post']['title'],
'original_post_body': '',
'reason': report.reasons,
'resolved': report.status == 3,
'published': report.created_at.isoformat() + 'Z'
},
'post': post_json['post'],
'community': community_json,
'creator': user_view(user=user_id, variant=1, stub=True),
'post_creator': user_view(user=report.suspect_user_id, variant=1, stub=True),
'counts': post_json['counts'],
'creator_banned_from_community': creator_banned_from_community,
'creator_is_moderator': creator_is_moderator,
'creator_is_admin': creator_is_admin,
'creator_blocked': False,
'subscribed': post_json['subscribed'],
'saved': post_json['saved']
}
}
return v1
def search_view(type):
v1 = {
'type_': type,

View file

@ -6,7 +6,7 @@ from sqlalchemy import desc, or_, and_, text
from app import db, celery
from app.chat.forms import AddReply, ReportConversationForm
from app.chat.util import send_message
from app.models import Site, User, Report, ChatMessage, Notification, InstanceBlock, Conversation, conversation_member
from app.models import Site, User, Report, ChatMessage, Notification, InstanceBlock, Conversation, conversation_member, CommunityBan, ModLog
from app.user.forms import ReportUserForm
from app.utils import render_template, moderating_communities, joined_communities, menu_topics
from app.chat import bp
@ -103,6 +103,19 @@ def empty():
return render_template('chat/empty.html')
@bp.route('/chat/ban_from_mod/<int:user_id>/<int:community_id>', methods=['GET'])
@login_required
def ban_from_mod(user_id, community_id):
active_ban = CommunityBan.query.filter_by(user_id=user_id, community_id=community_id).order_by(desc(CommunityBan.created_at)).first()
user_link = 'u/' + current_user.user_name
past_bans = ModLog.query.filter(ModLog.community_id == community_id, ModLog.link == user_link, or_(ModLog.action == 'ban_user', ModLog.action == 'unban_user')).order_by(desc(ModLog.created_at))
if active_ban:
past_bans = past_bans.offset(1)
#if active_ban and len(past_bans) > 1:
#past_bans = past_bans
return render_template('chat/ban_from_mod.html', active_ban=active_ban, past_bans=past_bans)
@bp.route('/chat/<int:conversation_id>/options', methods=['GET', 'POST'])
@login_required
def chat_options(conversation_id):

View file

@ -12,7 +12,7 @@ from app.constants import DOWNVOTE_ACCEPT_ALL, DOWNVOTE_ACCEPT_MEMBERS, DOWNVOTE
DOWNVOTE_ACCEPT_TRUSTED
from app.models import Community, utcnow
from app.utils import domain_from_url, MultiCheckboxField
from PIL import Image, ImageOps
from PIL import Image, ImageOps, UnidentifiedImageError
from io import BytesIO
import pytesseract
@ -173,6 +173,8 @@ class CreateImageForm(CreatePostForm):
image_text = pytesseract.image_to_string(Image.open(BytesIO(uploaded_file.read())).convert('L'))
except FileNotFoundError as e:
image_text = ''
except UnidentifiedImageError as e:
image_text = ''
if 'Anonymous' in image_text and (
'No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345'
self.image_file.errors.append(

View file

@ -24,7 +24,7 @@ from app.community.forms import SearchRemoteCommunity, CreateDiscussionForm, Cre
EscalateReportForm, ResolveReportForm, CreateVideoForm, CreatePollForm, RetrieveRemotePost, \
EditCommunityWikiPageForm
from app.community.util import search_for_community, actor_to_community, \
save_post, save_icon_file, save_banner_file, send_to_remote_instance, \
save_icon_file, save_banner_file, send_to_remote_instance, \
delete_post_from_community, delete_post_reply_from_community, community_in_list, find_local_users, tags_from_string, \
allowed_extensions, end_poll_date
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \
@ -43,6 +43,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
community_moderators, communities_banned_from, show_ban_message, recently_upvoted_posts, recently_downvoted_posts, \
blocked_users, languages_for_form, menu_topics, add_to_modlog, \
blocked_communities, remove_tracking_from_link, piefed_markdown_to_lemmy_markdown, ensure_directory_exists
from app.shared.post import make_post
from feedgen.feed import FeedGenerator
from datetime import timezone, timedelta
from copy import copy
@ -630,110 +631,15 @@ def add_post(actor, type):
form.language_id.choices = languages_for_form()
if not can_create_post(current_user, community):
abort(401)
if form.validate_on_submit():
community = Community.query.get_or_404(form.communities.data)
if not can_create_post(current_user, community):
try:
uploaded_file = request.files['image_file'] if type == 'image' else None
post = make_post(form, community, post_type, 1, uploaded_file=uploaded_file)
except Exception as ex:
flash(_('Your post was not accepted because %(reason)s', reason=str(ex)), 'error')
abort(401)
language = Language.query.get(form.language_id.data)
request_json = {
'id': None,
'object': {
'name': form.title.data,
'type': 'Page',
'stickied': form.sticky.data,
'sensitive': form.nsfw.data,
'nsfl': form.nsfl.data,
'id': gibberish(), # this will be updated once we have the post.id
'mediaType': 'text/markdown',
'content': form.body.data,
'tag': tags_from_string(form.tags.data),
'language': {'identifier': language.code, 'name': language.name}
}
}
if type == 'link':
request_json['object']['attachment'] = [{'type': 'Link', 'href': form.link_url.data}]
elif type == 'image':
uploaded_file = request.files['image_file']
if uploaded_file and uploaded_file.filename != '':
# check if this is an allowed type of file
file_ext = os.path.splitext(uploaded_file.filename)[1]
if file_ext.lower() not in allowed_extensions:
abort(400, description="Invalid image type.")
new_filename = gibberish(15)
# set up the storage directory
directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
final_place = os.path.join(directory, new_filename + file_ext)
uploaded_file.seek(0)
uploaded_file.save(final_place)
if file_ext.lower() == '.heic':
register_heif_opener()
if file_ext.lower() == '.avif':
import pillow_avif
Image.MAX_IMAGE_PIXELS = 89478485
# resize if necessary
if not final_place.endswith('.svg'):
img = Image.open(final_place)
if '.' + img.format.lower() in allowed_extensions:
img = ImageOps.exif_transpose(img)
# limit full sized version to 2000px
img.thumbnail((2000, 2000))
img.save(final_place)
request_json['object']['attachment'] = [{
'type': 'Image',
'url': f'https://{current_app.config["SERVER_NAME"]}/{final_place.replace("app/", "")}',
'name': form.image_alt_text.data,
'file_path': final_place
}]
elif type == 'video':
request_json['object']['attachment'] = [{'type': 'Document', 'url': form.video_url.data}]
elif type == 'poll':
request_json['object']['type'] = 'Question'
choices = [form.choice_1, form.choice_2, form.choice_3, form.choice_4, form.choice_5,
form.choice_6, form.choice_7, form.choice_8, form.choice_9, form.choice_10]
key = 'oneOf' if form.mode.data == 'single' else 'anyOf'
request_json['object'][key] = []
for choice in choices:
choice_data = choice.data.strip()
if choice_data:
request_json['object'][key].append({'name': choice_data})
request_json['object']['endTime'] = end_poll_date(form.finish_in.data)
# todo: add try..except
post = Post.new(current_user, community, request_json)
if form.notify_author.data:
new_notification = NotificationSubscription(name=post.title, user_id=current_user.id, entity_id=post.id, type=NOTIF_POST)
db.session.add(new_notification)
current_user.language_id = form.language_id.data
g.site.last_active = utcnow()
post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}"
db.session.commit()
if post.type == POST_TYPE_POLL:
poll = Poll.query.filter_by(post_id=post.id).first()
if not poll.local_only:
federate_post_to_user_followers(post)
if not community.local_only and not poll.local_only:
federate_post(community, post)
else:
federate_post_to_user_followers(post)
if not community.local_only:
federate_post(community, post)
resp = make_response(redirect(f"/post/{post.id}"))
# remove cookies used to maintain state when switching post type
resp.delete_cookie('post_title')
@ -760,7 +666,6 @@ def add_post(actor, type):
form.language_id.data = source_post.language_id
form.link_url.data = source_post.url
# empty post to pass since add_post.html extends edit_post.html
# and that one checks for a post.image_id for editing image posts
post = None
@ -775,197 +680,6 @@ def add_post(actor, type):
)
def federate_post(community, post):
page = {
'type': 'Page',
'id': post.ap_id,
'attributedTo': current_user.public_url(),
'to': [
community.public_url(),
'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,
'nsfl': post.nsfl,
'stickied': post.sticky,
'published': ap_datetime(utcnow()),
'audience': community.public_url(),
'language': {
'identifier': post.language_code(),
'name': post.language_name()
},
'tag': post.tags_for_activitypub()
}
create = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}",
"actor": current_user.public_url(),
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
community.public_url()
],
"type": "Create",
"audience": community.public_url(),
"object": page,
'@context': default_context()
}
if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO:
page['attachment'] = [{'href': post.url, 'type': 'Link'}]
elif post.image_id:
image_url = ''
if post.image.source_url:
image_url = post.image.source_url
elif post.image.file_path:
image_url = post.image.file_path.replace('app/static/',
f"https://{current_app.config['SERVER_NAME']}/static/")
elif post.image.thumbnail_path:
image_url = post.image.thumbnail_path.replace('app/static/',
f"https://{current_app.config['SERVER_NAME']}/static/")
# NB image is a dict while attachment is a list of dicts (usually just one dict in the list)
page['image'] = {'type': 'Image', 'url': image_url}
if post.type == POST_TYPE_IMAGE:
page['attachment'] = [{'type': 'Image',
'url': post.image.source_url, # source_url is always a https link, no need for .replace() as done above
'name': post.image.alt_text}]
if post.type == POST_TYPE_POLL:
poll = Poll.query.filter_by(post_id=post.id).first()
page['type'] = 'Question'
page['endTime'] = ap_datetime(poll.end_poll)
page['votersCount'] = 0
choices = []
for choice in PollChoice.query.filter_by(post_id=post.id).all():
choices.append({
"type": "Note",
"name": choice.choice_text,
"replies": {
"type": "Collection",
"totalItems": 0
}
})
page['oneOf' if poll.mode == 'single' else 'anyOf'] = choices
if not community.is_local(): # this is a remote community - send the post to the instance that hosts it
post_request_in_background(community.ap_inbox_url, create, current_user.private_key,
current_user.public_url() + '#main-key', timeout=10)
flash(_('Your post to %(name)s has been made.', name=community.title))
else: # local community - send (announce) post out to followers
announce = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}",
"type": 'Announce',
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"actor": community.public_url(),
"cc": [
community.ap_followers_url
],
'@context': default_context(),
'object': create
}
microblog_announce = copy(announce)
microblog_announce['object'] = post.ap_id
sent_to = 0
for instance in community.following_instances():
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(
instance.domain):
if instance.software in MICROBLOG_APPS:
send_to_remote_instance(instance.id, community.id, microblog_announce)
else:
send_to_remote_instance(instance.id, community.id, announce)
sent_to += 1
if sent_to:
flash(_('Your post to %(name)s has been made.', name=community.title))
else:
flash(_('Your post to %(name)s has been made.', name=community.title))
def federate_post_to_user_followers(post):
followers = UserFollower.query.filter_by(local_user_id=post.user_id)
if not followers:
return
note = {
'type': 'Note',
'id': post.ap_id,
'inReplyTo': None,
'attributedTo': current_user.public_url(),
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
current_user.followers_url()
],
'content': '',
'mediaType': 'text/html',
'attachment': [],
'commentsEnabled': post.comments_enabled,
'sensitive': post.nsfw,
'nsfl': post.nsfl,
'stickied': post.sticky,
'published': ap_datetime(utcnow()),
'language': {
'identifier': post.language_code(),
'name': post.language_name()
},
'tag': post.tags_for_activitypub()
}
create = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}",
"actor": current_user.public_url(),
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
current_user.followers_url()
],
"type": "Create",
"object": note,
'@context': default_context()
}
if post.type == POST_TYPE_ARTICLE:
note['content'] = '<p>' + post.title + '</p>'
elif post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO:
note['content'] = '<p><a href=' + post.url + '>' + post.title + '</a></p>'
elif post.type == POST_TYPE_IMAGE:
note['content'] = '<p>' + post.title + '</p>'
if post.image_id and post.image.source_url:
note['attachment'] = [{'type': 'Image', 'url': post.image.source_url, 'name': post.image.alt_text}]
if post.body_html:
note['content'] = note['content'] + '<p>' + post.body_html + '</p>'
if post.type == POST_TYPE_POLL:
poll = Poll.query.filter_by(post_id=post.id).first()
note['type'] = 'Question'
note['endTime'] = ap_datetime(poll.end_poll)
note['votersCount'] = 0
choices = []
for choice in PollChoice.query.filter_by(post_id=post.id).all():
choices.append({
"type": "Note",
"name": choice.choice_text,
"replies": {
"type": "Collection",
"totalItems": 0
}
})
note['oneOf' if poll.mode == 'single' else 'anyOf'] = choices
instances = Instance.query.join(User, User.instance_id == Instance.id).join(UserFollower, UserFollower.remote_user_id == User.id)
instances = instances.filter(UserFollower.local_user_id == post.user_id).filter(Instance.gone_forever == False)
for instance in instances:
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
post_request_in_background(instance.inbox, create, current_user.private_key, current_user.public_url() + '#main-key')
@bp.route('/community/<int:community_id>/report', methods=['GET', 'POST'])
@login_required
def community_report(community_id: int):

View file

@ -235,233 +235,6 @@ def actor_to_community(actor) -> Community:
return community
def save_post(form, post: Post, type: int):
post.indexable = current_user.indexable
post.sticky = form.sticky.data
post.nsfw = form.nsfw.data
post.nsfl = form.nsfl.data
post.notify_author = form.notify_author.data
post.language_id = form.language_id.data
current_user.language_id = form.language_id.data
post.title = form.title.data.strip()
post.body = piefed_markdown_to_lemmy_markdown(form.body.data)
post.body_html = markdown_to_html(post.body)
if not type or type == POST_TYPE_ARTICLE:
post.type = POST_TYPE_ARTICLE
elif type == POST_TYPE_LINK:
url_changed = post.id is None or form.link_url.data != post.url
post.url = remove_tracking_from_link(form.link_url.data.strip())
post.type = POST_TYPE_LINK
domain = domain_from_url(form.link_url.data)
domain.post_count += 1
post.domain = domain
if url_changed:
if post.image_id:
remove_old_file(post.image_id)
post.image_id = None
if post.url.endswith('.mp4') or post.url.endswith('.webm'):
post.type = POST_TYPE_VIDEO
file = File(source_url=form.link_url.data) # make_image_sizes() will take care of turning this into a still image
post.image = file
db.session.add(file)
else:
unused, file_extension = os.path.splitext(form.link_url.data)
# this url is a link to an image - turn it into a image post
if file_extension.lower() in allowed_extensions:
file = File(source_url=form.link_url.data)
post.image = file
db.session.add(file)
post.type = POST_TYPE_IMAGE
else:
# check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag
if not post.type == POST_TYPE_VIDEO:
tn_url = form.link_url.data
if tn_url[:32] == 'https://www.youtube.com/watch?v=':
tn_url = 'https://youtu.be/' + tn_url[32:43] # better chance of thumbnail from youtu.be than youtube.com
opengraph = opengraph_parse(tn_url)
if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''):
filename = opengraph.get('og:image') or opengraph.get('og:image:url')
if not filename.startswith('/'):
file = url_to_thumbnail_file(filename)
if file:
file.alt_text = shorten_string(opengraph.get('og:title'), 295)
post.image = file
db.session.add(file)
elif type == POST_TYPE_IMAGE:
post.type = POST_TYPE_IMAGE
alt_text = form.image_alt_text.data if form.image_alt_text.data else form.title.data
uploaded_file = request.files['image_file']
# If we are uploading new file in the place of existing one just remove the old one
if post.image_id is not None and uploaded_file:
post.image.delete_from_disk()
image_id = post.image_id
post.image_id = None
db.session.add(post)
db.session.commit()
File.query.filter_by(id=image_id).delete()
if uploaded_file and uploaded_file.filename != '':
if post.image_id:
remove_old_file(post.image_id)
post.image_id = None
# check if this is an allowed type of file
file_ext = os.path.splitext(uploaded_file.filename)[1]
if file_ext.lower() not in allowed_extensions:
abort(400)
new_filename = gibberish(15)
# set up the storage directory
directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
# save the file
final_place = os.path.join(directory, new_filename + file_ext)
final_place_medium = os.path.join(directory, new_filename + '_medium.webp')
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
uploaded_file.seek(0)
uploaded_file.save(final_place)
if file_ext.lower() == '.heic':
register_heif_opener()
Image.MAX_IMAGE_PIXELS = 89478485
# resize if necessary
img = Image.open(final_place)
if '.' + img.format.lower() in allowed_extensions:
img = ImageOps.exif_transpose(img)
# limit full sized version to 2000px
img_width = img.width
img_height = img.height
img.thumbnail((2000, 2000))
img.save(final_place)
# medium sized version
img.thumbnail((512, 512))
img.save(final_place_medium, format="WebP", quality=93)
# save a third, smaller, version as a thumbnail
img.thumbnail((170, 170))
img.save(final_place_thumbnail, format="WebP", quality=93)
thumbnail_width = img.width
thumbnail_height = img.height
file = File(file_path=final_place_medium, file_name=new_filename + file_ext, alt_text=alt_text,
width=img_width, height=img_height, thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail,
source_url=final_place.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/"))
db.session.add(file)
db.session.commit()
post.image_id = file.id
elif type == POST_TYPE_VIDEO:
form.video_url.data = form.video_url.data.strip()
url_changed = post.id is None or form.video_url.data != post.url
post.url = remove_tracking_from_link(form.video_url.data.strip())
post.type = POST_TYPE_VIDEO
domain = domain_from_url(form.video_url.data)
domain.post_count += 1
post.domain = domain
if url_changed:
if post.image_id:
remove_old_file(post.image_id)
post.image_id = None
if form.video_url.data.endswith('.mp4') or form.video_url.data.endswith('.webm'):
file = File(source_url=form.video_url.data) # make_image_sizes() will take care of turning this into a still image
post.image = file
db.session.add(file)
else:
# check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag
tn_url = form.video_url.data
if tn_url[:32] == 'https://www.youtube.com/watch?v=':
tn_url = 'https://youtu.be/' + tn_url[32:43] # better chance of thumbnail from youtu.be than youtube.com
opengraph = opengraph_parse(tn_url)
if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''):
filename = opengraph.get('og:image') or opengraph.get('og:image:url')
if not filename.startswith('/'):
file = url_to_thumbnail_file(filename)
if file:
file.alt_text = shorten_string(opengraph.get('og:title'), 295)
post.image = file
db.session.add(file)
elif type == POST_TYPE_POLL:
post.body = form.title.data + '\n' + form.body.data if post.title not in form.body.data else form.body.data
post.body_html = markdown_to_html(post.body)
post.type = POST_TYPE_POLL
else:
raise Exception('invalid post type')
if post.id is None:
if current_user.reputation > 100:
post.up_votes = 1
post.score = 1
if current_user.reputation < -100:
post.score = -1
post.ranking = post.post_ranking(post.score, utcnow())
# Filter by phrase
blocked_phrases_list = blocked_phrases()
for blocked_phrase in blocked_phrases_list:
if blocked_phrase in post.title:
abort(401)
return
if post.body:
for blocked_phrase in blocked_phrases_list:
if blocked_phrase in post.body:
abort(401)
return
db.session.add(post)
else:
db.session.execute(text('DELETE FROM "post_tag" WHERE post_id = :post_id'), {'post_id': post.id})
post.tags = tags_from_string_old(form.tags.data)
db.session.commit()
# Save poll choices. NB this will delete all votes whenever a poll is edited. Partially because it's easier to code but also to stop malicious alterations to polls after people have already voted
if type == POST_TYPE_POLL:
db.session.execute(text('DELETE FROM "poll_choice_vote" WHERE post_id = :post_id'), {'post_id': post.id})
db.session.execute(text('DELETE FROM "poll_choice" WHERE post_id = :post_id'), {'post_id': post.id})
for i in range(1, 10):
choice_data = getattr(form, f"choice_{i}").data.strip()
if choice_data != '':
db.session.add(PollChoice(post_id=post.id, choice_text=choice_data, sort_order=i))
poll = Poll.query.filter_by(post_id=post.id).first()
if poll is None:
poll = Poll(post_id=post.id)
db.session.add(poll)
poll.mode = form.mode.data
if form.finish_in:
poll.end_poll = end_poll_date(form.finish_in.data)
poll.local_only = form.local_only.data
poll.latest_vote = None
db.session.commit()
# Notify author about replies
# Remove any subscription that currently exists
existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post.id,
NotificationSubscription.user_id == current_user.id,
NotificationSubscription.type == NOTIF_POST).first()
if existing_notification:
db.session.delete(existing_notification)
# Add subscription if necessary
if form.notify_author.data:
new_notification = NotificationSubscription(name=post.title, user_id=current_user.id, entity_id=post.id,
type=NOTIF_POST)
db.session.add(new_notification)
g.site.last_active = utcnow()
db.session.commit()
def end_poll_date(end_choice):
delta_mapping = {
'30m': timedelta(minutes=30),

View file

@ -1182,7 +1182,7 @@ class Post(db.Model):
find_licence_or_create, make_image_sizes, notify_about_post
from app.utils import allowlist_html, markdown_to_html, html_to_text, microblog_content_to_title, blocked_phrases, \
is_image_url, is_video_url, domain_from_url, opengraph_parse, shorten_string, remove_tracking_from_link, \
is_video_hosting_site, communities_banned_from, recently_upvoted_posts
is_video_hosting_site, communities_banned_from, recently_upvoted_posts, blocked_users
microblog = False
if 'name' not in request_json['object']: # Microblog posts
@ -1376,6 +1376,24 @@ class Post(db.Model):
db.session.rollback()
return Post.query.filter_by(ap_id=request_json['object']['id'].lower()).one()
# Mentions also need a post_id
if 'tag' in request_json['object'] and isinstance(request_json['object']['tag'], list):
for json_tag in request_json['object']['tag']:
if 'type' in json_tag and json_tag['type'] == 'Mention':
profile_id = json_tag['href'] if 'href' in json_tag else None
if profile_id and isinstance(profile_id, str) and profile_id.startswith('https://' + current_app.config['SERVER_NAME']):
profile_id = profile_id.lower()
recipient = User.query.filter_by(ap_profile_id=profile_id, ap_id=None).first()
if recipient:
blocked_senders = blocked_users(recipient.id)
if post.user_id not in blocked_senders:
notification = Notification(user_id=recipient.id, title=_(f"You have been mentioned in post {post.id}"),
url=f"https://{current_app.config['SERVER_NAME']}/post/{post.id}",
author_id=post.user_id)
recipient.unread_notifications += 1
db.session.add(notification)
db.session.commit()
# Polls need to be processed quite late because they need a post_id to refer to
if request_json['object']['type'] == 'Question':
post.type = constants.POST_TYPE_POLL
@ -1457,7 +1475,7 @@ class Post(db.Model):
ncp.cross_posts.append(self.id)
# this post: set the cross_posts field to the limited list of ids from the most recent other posts
if new_cross_posts:
if new_cross_posts.count() > 0:
self.cross_posts = [ncp.id for ncp in new_cross_posts]
db.session.commit()

View file

@ -10,8 +10,8 @@ from wtforms import SelectField, RadioField
from app import db, constants, cache, celery
from app.activitypub.signature import HttpSignature, post_request, default_context, post_request_in_background
from app.activitypub.util import notify_about_post_reply, inform_followers_of_post_update, update_post_from_activity
from app.community.util import save_post, send_to_remote_instance
from app.activitypub.util import notify_about_post_reply, update_post_from_activity
from app.community.util import send_to_remote_instance
from app.inoculation import inoculation
from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm, CrossPostForm
from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm, CreatePollForm, EditImageForm
@ -35,6 +35,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
languages_for_form, menu_topics, add_to_modlog, blocked_communities, piefed_markdown_to_lemmy_markdown, \
permission_required, blocked_users, get_request, is_local_image_url, is_video_url, can_upvote, can_downvote
from app.shared.reply import make_reply, edit_reply
from app.shared.post import edit_post
def show_post(post_id: int):
@ -359,7 +360,8 @@ def poll_vote(post_id):
poll_votes = PollChoice.query.join(PollChoiceVote, PollChoiceVote.choice_id == PollChoice.id).filter(PollChoiceVote.post_id == post.id, PollChoiceVote.user_id == current_user.id).all()
for pv in poll_votes:
if post.author.is_local():
inform_followers_of_post_update(post.id, 1)
from app.shared.tasks import task_selector
task_selector('edit_post', user_id=current_user.id, post_id=post.id)
else:
pollvote_json = {
'@context': default_context(),
@ -587,7 +589,7 @@ def post_edit(post_id: int):
del form.finish_in
else:
abort(404)
del form.communities
mods = post.community.moderators()
@ -607,26 +609,16 @@ def post_edit(post_id: int):
form.nsfl.data = True
form.nsfw.render_kw = {'disabled': True}
old_url = post.url
form.language_id.choices = languages_for_form()
if form.validate_on_submit():
save_post(form, post, post_type)
post.community.last_active = utcnow()
post.edited_at = utcnow()
if post.url != old_url:
post.calculate_cross_posts(url_changed=True)
db.session.commit()
flash(_('Your changes have been saved.'), 'success')
# federate edit
if not post.community.local_only:
federate_post_update(post)
federate_post_edit_to_user_followers(post)
try:
uploaded_file = request.files['image_file'] if post_type == POST_TYPE_IMAGE else None
edit_post(form, post, post_type, 1, uploaded_file=uploaded_file)
flash(_('Your changes have been saved.'), 'success')
except Exception as ex:
flash(_('Your edit was not accepted because %(reason)s', reason=str(ex)), 'error')
abort(401)
return redirect(url_for('activitypub.post_ap', post_id=post.id))
else:
@ -651,7 +643,7 @@ def post_edit(post_id: int):
)
with open(path, "rb")as file:
form.image_file.data = file.read()
elif post_type == POST_TYPE_VIDEO:
form.video_url.data = post.url
elif post_type == POST_TYPE_POLL:
@ -678,186 +670,6 @@ def post_edit(post_id: int):
abort(401)
def federate_post_update(post):
page_json = {
'type': 'Page',
'id': post.ap_id,
'attributedTo': current_user.public_url(),
'to': [
post.community.public_url(),
'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,
'nsfl': post.nsfl,
'stickied': post.sticky,
'published': ap_datetime(post.posted_at),
'updated': ap_datetime(post.edited_at),
'audience': post.community.public_url(),
'language': {
'identifier': post.language_code(),
'name': post.language_name()
},
'tag': post.tags_for_activitypub()
}
update_json = {
'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}",
'type': 'Update',
'actor': current_user.public_url(),
'audience': post.community.public_url(),
'to': [post.community.public_url(), 'https://www.w3.org/ns/activitystreams#Public'],
'published': ap_datetime(utcnow()),
'cc': [
current_user.followers_url()
],
'object': page_json,
}
if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO:
page_json['attachment'] = [{'href': post.url, 'type': 'Link'}]
elif post.image_id:
if post.image.file_path:
image_url = post.image.file_path.replace('app/static/',
f"https://{current_app.config['SERVER_NAME']}/static/")
elif post.image.thumbnail_path:
image_url = post.image.thumbnail_path.replace('app/static/',
f"https://{current_app.config['SERVER_NAME']}/static/")
else:
image_url = post.image.source_url
# NB image is a dict while attachment is a list of dicts (usually just one dict in the list)
page_json['image'] = {'type': 'Image', 'url': image_url}
if post.type == POST_TYPE_IMAGE:
page_json['attachment'] = [{'type': 'Image',
'url': post.image.source_url, # source_url is always a https link, no need for .replace() as done above
'name': post.image.alt_text}]
if post.type == POST_TYPE_POLL:
poll = Poll.query.filter_by(post_id=post.id).first()
page_json['type'] = 'Question'
page_json['endTime'] = ap_datetime(poll.end_poll)
page_json['votersCount'] = 0
choices = []
for choice in PollChoice.query.filter_by(post_id=post.id).all():
choices.append({
"type": "Note",
"name": choice.choice_text,
"replies": {
"type": "Collection",
"totalItems": 0
}
})
page_json['oneOf' if poll.mode == 'single' else 'anyOf'] = choices
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.public_url() + '#main-key')
if success is False or isinstance(success, str):
flash('Failed to 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,
"cc": [
post.community.ap_followers_url
],
'@context': default_context(),
'object': update_json
}
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):
send_to_remote_instance(instance.id, post.community.id, announce)
def federate_post_edit_to_user_followers(post):
followers = UserFollower.query.filter_by(local_user_id=post.user_id)
if not followers:
return
note = {
'type': 'Note',
'id': post.ap_id,
'inReplyTo': None,
'attributedTo': current_user.public_url(),
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
current_user.followers_url()
],
'content': '',
'mediaType': 'text/html',
'source': {'content': post.body if post.body else '', 'mediaType': 'text/markdown'},
'attachment': [],
'commentsEnabled': post.comments_enabled,
'sensitive': post.nsfw,
'nsfl': post.nsfl,
'stickied': post.sticky,
'published': ap_datetime(utcnow()),
'updated': ap_datetime(post.edited_at),
'language': {
'identifier': post.language_code(),
'name': post.language_name()
},
'tag': post.tags_for_activitypub()
}
update = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}",
"actor": current_user.public_url(),
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
current_user.followers_url()
],
"type": "Update",
"object": note,
'@context': default_context()
}
if post.type == POST_TYPE_ARTICLE:
note['content'] = '<p>' + post.title + '</p>'
elif post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO:
note['content'] = '<p><a href=' + post.url + '>' + post.title + '</a></p>'
elif post.type == POST_TYPE_IMAGE:
note['content'] = '<p>' + post.title + '</p>'
if post.image_id and post.image.source_url:
note['attachment'] = [{'type': 'Image', 'url': post.image.source_url, 'name': post.image.alt_text}]
elif post.type == POST_TYPE_POLL:
poll = Poll.query.filter_by(post_id=post.id).first()
note['type'] = 'Question'
note['endTime'] = ap_datetime(poll.end_poll)
note['votersCount'] = 0
choices = []
for choice in PollChoice.query.filter_by(post_id=post.id).all():
choices.append({
"type": "Note",
"name": choice.choice_text,
"replies": {
"type": "Collection",
"totalItems": 0
}
})
note['oneOf' if poll.mode == 'single' else 'anyOf'] = choices
if post.body_html:
note['content'] = note['content'] + '<p>' + post.body_html + '</p>'
instances = Instance.query.join(User, User.instance_id == Instance.id).join(UserFollower, UserFollower.remote_user_id == User.id)
instances = instances.filter(UserFollower.local_user_id == post.user_id)
for instance in instances:
if instance.inbox and not instance_banned(instance.domain):
post_request_in_background(instance.inbox, update, current_user.private_key, current_user.public_url() + '#main-key')
@bp.route('/post/<int:post_id>/delete', methods=['GET', 'POST'])
@login_required
def post_delete(post_id: int):

View file

@ -1,13 +1,24 @@
from app import db
from app import db, cache
from app.activitypub.util import make_image_sizes
from app.constants import *
from app.models import NotificationSubscription, Post, PostBookmark
from app.community.util import tags_from_string_old, end_poll_date
from app.models import File, Language, Notification, NotificationSubscription, Poll, PollChoice, Post, PostBookmark, PostVote, Report, Site, User, utcnow
from app.shared.tasks import task_selector
from app.utils import render_template, authorise_api_user, shorten_string
from app.utils import render_template, authorise_api_user, shorten_string, gibberish, ensure_directory_exists, \
piefed_markdown_to_lemmy_markdown, markdown_to_html, remove_tracking_from_link, domain_from_url, \
opengraph_parse, url_to_thumbnail_file, can_create_post, is_video_hosting_site, recently_upvoted_posts, \
is_image_url, is_video_hosting_site
from flask import abort, flash, redirect, request, url_for
from flask import abort, flash, redirect, request, url_for, current_app, g
from flask_babel import _
from flask_login import current_user
from pillow_heif import register_heif_opener
from PIL import Image, ImageOps
from sqlalchemy import text
import os
# would be in app/constants.py
SRC_WEB = 1
@ -127,3 +138,396 @@ def toggle_post_notification(post_id: int, src, auth=None):
return user_id
else:
return render_template('post/_post_notification_toggle.html', post=post)
def make_post(input, community, type, src, auth=None, uploaded_file=None):
if src == SRC_API:
user = authorise_api_user(auth, return_type='model')
#if not basic_rate_limit_check(user):
# raise Exception('rate_limited')
title = input['title']
url = input['url']
language_id = input['language_id']
else:
user = current_user
title = input.title.data.strip()
if type == POST_TYPE_LINK:
url = input.link_url.data.strip()
elif type == POST_TYPE_VIDEO:
url = input.video_url.data.strip()
else:
url = None
language_id = input.language_id.data
# taking values from one JSON to put in another JSON to put in a DB to put in another JSON feels bad
# instead, make_post shares code with edit_post
# ideally, a similar change could be made for incoming activitypub (create_post() and update_post_from_activity() could share code)
# once this happens, and post.new() just does the minimum before being passed off to an update function, post.new() can be used here again.
if not can_create_post(user, community):
raise Exception('You are not permitted to make posts in this community')
if url:
domain = domain_from_url(url)
if domain:
if domain.banned or domain.name.endswith('.pages.dev'):
raise Exception(domain.name + ' is blocked by admin')
if uploaded_file and uploaded_file.filename != '':
# check if this is an allowed type of file
allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic', '.mpo', '.avif', '.svg']
file_ext = os.path.splitext(uploaded_file.filename)[1]
if file_ext.lower() not in allowed_extensions:
raise Exception('filetype not allowed')
post = Post(user_id=user.id, community_id=community.id, instance_id=user.instance_id, posted_at=utcnow(),
ap_id=gibberish(), title=title, language_id=language_id)
db.session.add(post)
db.session.commit()
post.up_votes = 1
if user.reputation > 100:
post.up_votes += 1
effect = user.instance.vote_weight
post.score = post.up_votes * effect
post.ranking = post.post_ranking(post.score, post.posted_at)
cache.delete_memoized(recently_upvoted_posts, user.id)
community.post_count += 1
community.last_active = g.site.last_active = utcnow()
user.post_count += 1
post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}"
vote = PostVote(user_id=user.id, post_id=post.id, author_id=user.id, effect=1)
db.session.add(vote)
db.session.commit()
post = edit_post(input, post, type, src, user, auth, uploaded_file, from_scratch=True)
if src == SRC_API:
return user.id, post
else:
return post
# 'from_scratch == True' means that it's not really a user edit, we're just re-using code for make_post()
def edit_post(input, post, type, src, user=None, auth=None, uploaded_file=None, from_scratch=False):
if src == SRC_API:
if not user:
user = authorise_api_user(auth, return_type='model')
title = input['title']
body = input['body']
url = input['url']
nsfw = input['nsfw']
notify_author = input['notify_author']
language_id = input['language_id']
tags = []
else:
if not user:
user = current_user
title = input.title.data.strip()
body = input.body.data
if type == POST_TYPE_LINK:
url = input.link_url.data.strip()
elif type == POST_TYPE_VIDEO:
url = input.video_url.data.strip()
elif type == POST_TYPE_IMAGE and not from_scratch:
url = post.url
else:
url = None
nsfw = input.nsfw.data
notify_author = input.notify_author.data
language_id = input.language_id.data
tags = tags_from_string_old(input.tags.data)
post.indexable = user.indexable
post.sticky = False if src == SRC_API else input.sticky.data
post.nsfw = nsfw
post.nsfl = False if src == SRC_API else input.nsfl.data
post.notify_author = notify_author
post.language_id = language_id
user.language_id = language_id
post.title = title
post.body = piefed_markdown_to_lemmy_markdown(body)
post.body_html = markdown_to_html(post.body)
post.type = type
url_changed = False
if not from_scratch:
# Remove any subscription that currently exists
existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post.id,
NotificationSubscription.user_id == user.id,
NotificationSubscription.type == NOTIF_POST).first()
if existing_notification:
db.session.delete(existing_notification)
# Remove any poll votes that currently exists
# Partially because it's easier to code but also to stop malicious alterations to polls after people have already voted
db.session.execute(text('DELETE FROM "poll_choice_vote" WHERE post_id = :post_id'), {'post_id': post.id})
db.session.execute(text('DELETE FROM "poll_choice" WHERE post_id = :post_id'), {'post_id': post.id})
# Remove any old images, and set url_changed
if url != post.url or uploaded_file:
url_changed = True
if post.image_id:
remove_file = File.query.get(post.image_id)
if remove_file:
remove_file.delete_from_disk()
post.image_id = None
if post.url:
domain = domain_from_url(post.url)
if domain:
domain.post_count -= 1
# remove any old tags
db.session.execute(text('DELETE FROM "post_tag" WHERE post_id = :post_id'), {'post_id': post.id})
post.edited_at = utcnow()
db.session.commit()
if uploaded_file and uploaded_file.filename != '':
# check if this is an allowed type of file
allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic', '.mpo', '.avif', '.svg']
file_ext = os.path.splitext(uploaded_file.filename)[1]
if file_ext.lower() not in allowed_extensions:
raise Exception('filetype not allowed')
new_filename = gibberish(15)
# set up the storage directory
directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
# save the file
final_place = os.path.join(directory, new_filename + file_ext)
uploaded_file.seek(0)
uploaded_file.save(final_place)
if file_ext.lower() == '.heic':
register_heif_opener()
if file_ext.lower() == '.avif':
import pillow_avif
Image.MAX_IMAGE_PIXELS = 89478485
# limit full sized version to 2000px
if not final_place.endswith('.svg'):
img = Image.open(final_place)
if '.' + img.format.lower() in allowed_extensions:
img = ImageOps.exif_transpose(img)
img.thumbnail((2000, 2000))
img.save(final_place)
url = f"https://{current_app.config['SERVER_NAME']}/{final_place.replace('app/', '')}"
else:
raise Exception('filetype not allowed')
if url and (from_scratch or url_changed):
domain = domain_from_url(url)
if domain:
if domain.banned or domain.name.endswith('.pages.dev'):
raise Exception(domain.name + ' is blocked by admin')
post.domain = domain
domain.post_count += 1
already_notified = set() # often admins and mods are the same people - avoid notifying them twice
if domain.notify_mods:
for community_member in post.community.moderators():
if community_member.is_local():
notify = Notification(title='Suspicious content', url=post.ap_id, user_id=community_member.user_id, author_id=user.id)
db.session.add(notify)
already_notified.add(community_member.user_id)
if domain.notify_admins:
for admin in Site.admins():
if admin.id not in already_notified:
notify = Notification(title='Suspicious content', url=post.ap_id, user_id=admin.id, author_id=user.id)
db.session.add(notify)
if is_image_url(url):
file = File(source_url=url)
if uploaded_file and type == POST_TYPE_IMAGE:
# change this line when uploaded_file is supported in API
file.alt_text = input.image_alt_text.data if input.image_alt_text.data else title
db.session.add(file)
db.session.commit()
post.image_id = file.id
make_image_sizes(post.image_id, 170, 512, 'posts', post.community.low_quality)
post.url = url
post.type = POST_TYPE_IMAGE
else:
opengraph = opengraph_parse(url)
if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''):
filename = opengraph.get('og:image') or opengraph.get('og:image:url')
if not filename.startswith('/'):
file = url_to_thumbnail_file(filename)
if file:
file.alt_text = shorten_string(opengraph.get('og:title'), 295)
post.image = file
db.session.add(file)
post.url = remove_tracking_from_link(url)
if url.endswith('.mp4') or url.endswith('.webm') or is_video_hosting_site(url):
post.type = POST_TYPE_VIDEO
else:
post.type = POST_TYPE_LINK
post.calculate_cross_posts(url_changed=url_changed)
federate = True
if type == POST_TYPE_POLL:
post.type = POST_TYPE_POLL
for i in range(1, 10):
# change this line when polls are supported in API
choice_data = getattr(input, f"choice_{i}").data.strip()
if choice_data != '':
db.session.add(PollChoice(post_id=post.id, choice_text=choice_data, sort_order=i))
poll = Poll.query.filter_by(post_id=post.id).first()
if poll is None:
poll = Poll(post_id=post.id)
db.session.add(poll)
poll.mode = input.mode.data
if input.finish_in:
poll.end_poll = end_poll_date(input.finish_in.data)
poll.local_only = input.local_only.data
poll.latest_vote = None
db.session.commit()
if poll.local_only:
federate = False
# add tags
post.tags = tags
# Add subscription if necessary
if notify_author:
new_notification = NotificationSubscription(name=post.title, user_id=user.id, entity_id=post.id, type=NOTIF_POST)
db.session.add(new_notification)
db.session.commit()
if from_scratch:
if federate:
task_selector('make_post', user_id=user.id, post_id=post.id)
elif federate:
task_selector('edit_post', user_id=user.id, post_id=post.id)
if src == SRC_API:
if from_scratch:
return post
else:
return user.id, post
elif from_scratch:
return post
# just for deletes by owner (mod deletes are classed as 'remove')
def delete_post(post_id, src, auth):
if src == SRC_API:
user_id = authorise_api_user(auth)
else:
user_id = current_user.id
post = Post.query.filter_by(id=post_id, user_id=user_id, deleted=False).one()
if post.url:
post.calculate_cross_posts(delete_only=True)
post.deleted = True
post.deleted_by = user_id
post.author.post_count -= 1
post.community.post_count -= 1
db.session.commit()
if src == SRC_WEB:
flash(_('Post deleted.'))
task_selector('delete_post', user_id=user_id, post_id=post.id)
if src == SRC_API:
return user_id, post
else:
return
def restore_post(post_id, src, auth):
if src == SRC_API:
user_id = authorise_api_user(auth)
else:
user_id = current_user.id
post = Post.query.filter_by(id=post_id, user_id=user_id, deleted=True).one()
if post.url:
post.calculate_cross_posts()
post.deleted = False
post.deleted_by = None
post.author.post_count -= 1
post.community.post_count -= 1
db.session.commit()
if src == SRC_WEB:
flash(_('Post restored.'))
task_selector('restore_post', user_id=user_id, post_id=post.id)
if src == SRC_API:
return user_id, post
else:
return
def report_post(post_id, input, src, auth=None):
if src == SRC_API:
post = Post.query.filter_by(id=post_id).one()
user_id = authorise_api_user(auth)
reason = input['reason']
description = input['description']
report_remote = input['report_remote']
else:
post = Post.query.get_or_404(post_id)
user_id = current_user.id
reason = input.reasons_to_string(input.reasons.data)
description = input.description.data
report_remote = input.report_remote.data
if post.reports == -1: # When a mod decides to ignore future reports, post.reports is set to -1
if src == SRC_API:
raise Exception('already_reported')
else:
flash(_('Post has already been reported, thank you!'))
return
report = Report(reasons=reason, description=description, type=1, reporter_id=user_id, suspect_post_id=post.id, suspect_community_id=post.community_id,
suspect_user_id=post.user_id, in_community_id=post.community_id, source_instance_id=1)
db.session.add(report)
# Notify moderators
already_notified = set()
for mod in post.community.moderators():
moderator = User.query.get(mod.user_id)
if moderator and moderator.is_local():
notification = Notification(user_id=mod.user_id, title=_('A comment has been reported'),
url=f"https://{current_app.config['SERVER_NAME']}/comment/{post.id}",
author_id=user_id)
db.session.add(notification)
already_notified.add(mod.user_id)
post.reports += 1
# todo: only notify admins for certain types of report
for admin in Site.admins():
if admin.id not in already_notified:
notify = Notification(title='Suspicious content', url='/admin/reports', user_id=admin.id, author_id=user_id)
db.session.add(notify)
admin.unread_notifications += 1
db.session.commit()
# federate report to originating instance
if not post.community.is_local() and report_remote:
summary = reason
if description:
summary += ' - ' + description
task_selector('report_post', user_id=user_id, post_id=post_id, summary=summary)
if src == SRC_API:
return user_id, report
else:
return

View file

@ -233,12 +233,11 @@ def edit_reply(input, reply, post, src, auth=None):
# just for deletes by owner (mod deletes are classed as 'remove')
def delete_reply(reply_id, src, auth):
if src == SRC_API:
reply = PostReply.query.filter_by(id=reply_id, deleted=False).one()
user_id = authorise_api_user(auth, id_match=reply.user_id)
user_id = authorise_api_user(auth)
else:
reply = PostReply.query.get_or_404(reply_id)
user_id = current_user.id
reply = PostReply.query.filter_by(id=reply_id, user_id=user_id, deleted=False).one()
reply.deleted = True
reply.deleted_by = user_id
@ -259,14 +258,11 @@ def delete_reply(reply_id, src, auth):
def restore_reply(reply_id, src, auth):
if src == SRC_API:
reply = PostReply.query.filter_by(id=reply_id, deleted=True).one()
user_id = authorise_api_user(auth, id_match=reply.user_id)
if reply.user_id != reply.deleted_by:
raise Exception('incorrect_login')
user_id = authorise_api_user(auth)
else:
reply = PostReply.query.get_or_404(reply_id)
user_id = current_user.id
reply = PostReply.query.filter_by(id=reply_id, user_id=user_id, deleted=True).one()
reply.deleted = False
reply.deleted_by = None

View file

@ -1,8 +1,9 @@
from app.shared.tasks.follows import join_community, leave_community
from app.shared.tasks.likes import vote_for_post, vote_for_reply
from app.shared.tasks.notes import make_reply, edit_reply
from app.shared.tasks.deletes import delete_reply, restore_reply
from app.shared.tasks.flags import report_reply
from app.shared.tasks.deletes import delete_reply, restore_reply, delete_post, restore_post
from app.shared.tasks.flags import report_reply, report_post
from app.shared.tasks.pages import make_post, edit_post
from flask import current_app
@ -17,7 +18,12 @@ def task_selector(task_key, send_async=True, **kwargs):
'edit_reply': edit_reply,
'delete_reply': delete_reply,
'restore_reply': restore_reply,
'report_reply': report_reply
'report_reply': report_reply,
'make_post': make_post,
'edit_post': edit_post,
'delete_post': delete_post,
'restore_post': restore_post,
'report_post': report_post
}
if current_app.debug:

View file

@ -1,6 +1,6 @@
from app import celery
from app.activitypub.signature import default_context, post_request
from app.models import CommunityBan, PostReply, User
from app.models import CommunityBan, Instance, Post, PostReply, User, UserFollower
from app.utils import gibberish, instance_banned
from flask import current_app
@ -34,10 +34,31 @@ def restore_reply(send_async, user_id, reply_id):
delete_object(user_id, reply, is_restore=True)
def delete_object(user_id, object, is_restore=False):
@celery.task
def delete_post(send_async, user_id, post_id):
post = Post.query.filter_by(id=post_id).one()
delete_object(user_id, post, is_post=True)
@celery.task
def restore_post(send_async, user_id, post_id):
post = Post.query.filter_by(id=post_id).one()
delete_object(user_id, post, is_post=True, is_restore=True)
def delete_object(user_id, object, is_post=False, is_restore=False):
user = User.query.filter_by(id=user_id).one()
community = object.community
if community.local_only or not community.instance.online():
# local_only communities can also be used to send activity to User Followers (only applies to posts, not comments)
# return now though, if there aren't any
if not is_post and community.local_only:
return
followers = UserFollower.query.filter_by(local_user_id=user.id).all()
if not followers and community.local_only:
return
if not community.instance.online():
return
banned = CommunityBan.query.filter_by(user_id=user_id, community_id=community.id).first()
@ -75,6 +96,8 @@ def delete_object(user_id, object, is_restore=False):
'cc': cc
}
domains_sent_to = []
if community.is_local():
if is_restore:
del undo['@context']
@ -97,9 +120,24 @@ def delete_object(user_id, object, is_restore=False):
}
for instance in community.following_instances():
if instance.inbox and instance.online() and not user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
post_request(instance.inbox, announce, community.private_key, community.public_url() + '#main-key')
post_request(instance.inbox, announce, community.private_key, community.public_url() + '#main-key')
domains_sent_to.append(instance.domain)
else:
payload = undo if is_restore else delete
post_request(community.ap_inbox_url, payload, user.private_key, user.public_url() + '#main-key')
domains_sent_to.append(community.instance.domain)
if is_post and followers:
payload = undo if is_restore else delete
for follower in followers:
user_details = User.query.get(follower.remote_user_id)
if user_details:
payload['cc'].append(user_details.public_url())
instances = Instance.query.join(User, User.instance_id == Instance.id).join(UserFollower, UserFollower.remote_user_id == User.id)
instances = instances.filter(UserFollower.local_user_id == user.id).filter(Instance.gone_forever == False)
for instance in instances:
if instance.domain not in domains_sent_to:
post_request(instance.inbox, payload, user.private_key, user.public_url() + '#main-key')

View file

@ -1,6 +1,6 @@
from app import celery
from app.activitypub.signature import default_context, post_request
from app.models import CommunityBan, PostReply, User
from app.models import CommunityBan, Post, PostReply, User
from app.utils import gibberish, instance_banned
from flask import current_app
@ -27,6 +27,12 @@ def report_reply(send_async, user_id, reply_id, summary):
report_object(user_id, reply, summary)
@celery.task
def report_post(send_async, user_id, post_id, summary):
post = Post.query.filter_by(id=post_id).one()
report_object(user_id, post, summary)
def report_object(user_id, object, summary):
user = User.query.filter_by(id=user_id).one()
community = object.community

View file

@ -42,7 +42,6 @@ import re
'cc': []
'@context': (outer object only)
'audience': (not in Announce)
'tag': [] (not in Announce)
}
"""
@ -149,7 +148,7 @@ def send_reply(user_id, reply_id, parent_id, edit=False):
'distinguished': False,
}
if edit:
note['updated']: ap_datetime(utcnow())
note['updated'] = ap_datetime(utcnow())
activity = 'create' if not edit else 'update'
create_id = f"https://{current_app.config['SERVER_NAME']}/activities/{activity}/{gibberish(15)}"
@ -162,7 +161,7 @@ def send_reply(user_id, reply_id, parent_id, edit=False):
'to': to,
'cc': cc,
'@context': default_context(),
'tag': tag
'audience': community.public_url()
}
domains_sent_to = [current_app.config['SERVER_NAME']]

284
app/shared/tasks/pages.py Normal file
View file

@ -0,0 +1,284 @@
from app import celery, db
from app.activitypub.signature import default_context, post_request
from app.constants import POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, POST_TYPE_VIDEO, POST_TYPE_POLL, MICROBLOG_APPS
from app.models import CommunityBan, Instance, Notification, Poll, PollChoice, Post, User, UserFollower, utcnow
from app.user.utils import search_for_user
from app.utils import gibberish, instance_banned, ap_datetime
from flask import current_app
from flask_babel import _
import re
""" Post JSON format
{
'id':
'type':
'attributedTo':
'to': []
'cc': []
'tag': []
'audience':
'content':
'mediaType':
'source': {}
'inReplyTo': (only added for Mentions and user followers, as Note with inReplyTo: None)
'published':
'updated': (inner oject of Update only)
'language': {}
'name': (not included in Polls, which are sent out as microblogs)
'attachment': []
'commentsEnabled':
'sensitive':
'nsfl':
'stickied':
'image': (for posts with thumbnails only)
'endTime': (last 3 are for polls)
'votersCount':
'oneOf' / 'anyOf':
}
"""
""" Create / Update / Announce JSON format
{
'id':
'type':
'actor':
'object':
'to': []
'cc': []
'@context': (outer object only)
'audience': (not in Announce)
}
"""
@celery.task
def make_post(send_async, user_id, post_id):
send_post(user_id, post_id)
@celery.task
def edit_post(send_async, user_id, post_id):
send_post(user_id, post_id, edit=True)
def send_post(user_id, post_id, edit=False):
user = User.query.filter_by(id=user_id).one()
post = Post.query.filter_by(id=post_id).one()
community = post.community
# Find any users Mentioned in post body with @user@instance syntax
recipients = []
pattern = r"@([a-zA-Z0-9_.-]*)@([a-zA-Z0-9_.-]*)\b"
matches = re.finditer(pattern, post.body)
for match in matches:
recipient = None
if match.group(2) == current_app.config['SERVER_NAME']:
user_name = match.group(1)
if user_name != user.user_name:
try:
recipient = search_for_user(user_name)
except:
pass
else:
ap_id = f"{match.group(1)}@{match.group(2)}"
try:
recipient = search_for_user(ap_id)
except:
pass
if recipient:
add_recipient = True
for existing_recipient in recipients:
if ((not recipient.ap_id and recipient.user_name == existing_recipient.user_name) or
(recipient.ap_id and recipient.ap_id == existing_recipient.ap_id)):
add_recipient = False
break
if add_recipient:
recipients.append(recipient)
# Notify any local users that have been Mentioned
for recipient in recipients:
if recipient.is_local():
if edit:
existing_notification = Notification.query.filter(Notification.user_id == recipient.id, Notification.url == f"https://{current_app.config['SERVER_NAME']}/post/{post.id}").first()
else:
existing_notification = None
if not existing_notification:
notification = Notification(user_id=recipient.id, title=_(f"You have been mentioned in post {post.id}"),
url=f"https://{current_app.config['SERVER_NAME']}/post/{post.id}",
author_id=user.id)
recipient.unread_notifications += 1
db.session.add(notification)
db.session.commit()
if not community.instance.online():
return
# local_only communities can also be used to send activity to User Followers
# return now though, if there aren't any
followers = UserFollower.query.filter_by(local_user_id=post.user_id).all()
if not followers and community.local_only:
return
banned = CommunityBan.query.filter_by(user_id=user_id, community_id=community.id).first()
if banned:
return
if not community.is_local():
if user.has_blocked_instance(community.instance.id) or instance_banned(community.instance.domain):
return
type = 'Question' if post.type == POST_TYPE_POLL else 'Page'
to = [community.public_url(), "https://www.w3.org/ns/activitystreams#Public"]
cc = []
tag = post.tags_for_activitypub()
for recipient in recipients:
tag.append({'href': recipient.public_url(), 'name': recipient.mention_tag(), 'type': 'Mention'})
cc.append(recipient.public_url())
language = {'identifier': post.language_code(), 'name': post.language_name()}
source = {'content': post.body, 'mediaType': 'text/markdown'}
attachment = []
if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO:
attachment.append({'href': post.url, 'type': 'Link'})
elif post.type == POST_TYPE_IMAGE:
attachment.append({'type': 'Image', 'url': post.image.source_url, 'name': post.image.alt_text})
page = {
'id': post.public_url(),
'type': type,
'attributedTo': user.public_url(),
'to': to,
'cc': cc,
'tag': tag,
'audience': community.public_url(),
'content': post.body_html if post.type != POST_TYPE_POLL else '<p>' + post.title + '</p>' + post.body_html,
'mediaType': 'text/html',
'source': source,
'published': ap_datetime(post.posted_at),
'language': language,
'name': post.title,
'attachment': attachment,
'commentsEnabled': post.comments_enabled,
'sensitive': post.nsfw or post.nsfl,
'nsfl': post.nsfl,
'stickied': post.sticky
}
if post.type != POST_TYPE_POLL:
page['name'] = post.title
if edit:
page['updated'] = ap_datetime(utcnow())
if post.image_id:
image_url = ''
if post.image.source_url:
image_url = post.image.source_url
elif post.image.file_path:
image_url = post.image.file_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/")
elif post.image.thumbnail_path:
image_url = post.image.thumbnail_path.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/")
page['image'] = {'type': 'Image', 'url': image_url}
if post.type == POST_TYPE_POLL:
poll = Poll.query.filter_by(post_id=post.id).first()
page['endTime'] = ap_datetime(poll.end_poll)
page['votersCount'] = poll.total_votes() if edit else 0
choices = []
for choice in PollChoice.query.filter_by(post_id=post.id).all():
choices.append({'type': 'Note', 'name': choice.choice_text, 'replies': {'type': 'Collection', 'totalItems': choice.num_votes if edit else 0}})
page['oneOf' if poll.mode == 'single' else 'anyOf'] = choices
activity = 'create' if not edit else 'update'
create_id = f"https://{current_app.config['SERVER_NAME']}/activities/{activity}/{gibberish(15)}"
type = 'Create' if not edit else 'Update'
create = {
'id': create_id,
'type': type,
'actor': user.public_url(),
'object': page,
'to': to,
'cc': cc,
'@context': default_context(),
'audience': community.public_url()
}
domains_sent_to = [current_app.config['SERVER_NAME']]
# if the community is local, and remote instance is something like Lemmy, Announce the activity
# if the community is local, and remote instance is something like Mastodon, Announce creates (so the community Boosts it), but send updates directly and from the user
# Announce of Poll doesn't work for Mastodon, so don't add domain to domains_sent_to, so they receive it if they're also following the User or they get Mentioned
# if the community is remote, send activity directly
if not community.local_only:
if community.is_local():
del create['@context']
announce_id = f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}"
actor = community.public_url()
cc = [community.ap_followers_url]
group_announce = {
'id': announce_id,
'type': 'Announce',
'actor': community.public_url(),
'object': create,
'to': to,
'cc': cc,
'@context': default_context()
}
microblog_announce = {
'id': announce_id,
'type': 'Announce',
'actor': community.public_url(),
'object': post.ap_id,
'to': to,
'cc': cc,
'@context': default_context()
}
for instance in community.following_instances():
if instance.inbox and instance.online() and not user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
if instance.software in MICROBLOG_APPS:
if activity == 'create':
post_request(instance.inbox, microblog_announce, community.private_key, community.public_url() + '#main-key')
else:
post_request(instance.inbox, create, user.private_key, user.public_url() + '#main-key')
else:
post_request(instance.inbox, group_announce, community.private_key, community.public_url() + '#main-key')
if post.type < POST_TYPE_POLL:
domains_sent_to.append(instance.domain)
else:
post_request(community.ap_inbox_url, create, user.private_key, user.public_url() + '#main-key')
domains_sent_to.append(community.instance.domain)
# amend copy of the Create, for anyone Mentioned in post body or who is following the user, to a format more likely to be understood
if '@context' not in create:
create['@context'] = default_context()
if 'name' in page:
del page['name']
note = page
if note['type'] == 'Page':
note['type'] = 'Note'
if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO:
note['content'] = '<p><a href=' + post.url + '>' + post.title + '</a></p>'
elif post.type != POST_TYPE_POLL:
note['content'] = '<p>' + post.title + '</p>'
if post.body_html:
note['content'] = note['content'] + post.body_html
note['inReplyTo'] = None
create['object'] = note
if not community.local_only:
for recipient in recipients:
if recipient.instance.domain not in domains_sent_to:
post_request(recipient.instance.inbox, create, user.private_key, user.public_url() + '#main-key')
domains_sent_to.append(recipient.instance.domain)
if not followers:
return
# send the amended copy of the Create to anyone who is following the User, but hasn't already received something
for follower in followers:
user_details = User.query.get(follower.remote_user_id)
if user_details:
create['cc'].append(user_details.public_url())
instances = Instance.query.join(User, User.instance_id == Instance.id).join(UserFollower, UserFollower.remote_user_id == User.id)
instances = instances.filter(UserFollower.local_user_id == post.user_id).filter(Instance.gone_forever == False)
for instance in instances:
if instance.domain not in domains_sent_to:
post_request(instance.inbox, create, user.private_key, user.public_url() + '#main-key')

View file

@ -0,0 +1,42 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %}
{% set active_child = 'chats' %}
{% block app_content %}
<div class="row">
{% if active_ban %}
<h5> Active Ban</h5>
<table class="table">
<tr>
<th>Reason</th>
<th>From</th>
<th>Until</th>
</tr>
<tr>
<td>{{ active_ban.reason }}</td>
<td>{{ active_ban.created_at.strftime('%Y-%m-%d') }}</td>
<td>{{ active_ban.ban_until.strftime('%Y-%m-%d') if active_ban.ban_until else '' }}</td>
</table>
{% endif %}
{% if past_bans.count() > 0 %}
<h5> Ban History</h5>
<table class="table">
<tr>
<th>Reason</th>
<th>From</th>
<th>Type</th>
</tr>
{% for past_ban in past_bans %}
<tr>
<td>{{ past_ban.reason }}</td>
<td>{{ past_ban.created_at.strftime('%Y-%m-%d') }}</td>
<td>{{ past_ban.action }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
{% endblock %}

View file

@ -100,7 +100,7 @@
{% endif -%}
{% endif -%}
<div class="post_body"{% if post.language_id and post.language.code != 'en' %} lang="{{ post.language.code }}"{% endif %}>
{{ post.body_html|community_links|safe if post.body_html else '' }}
{{ post.body_html | community_links | person_links | safe if post.body_html else '' }}
{% if archive_link -%}
<p><a href="{{ archive_link }}" rel="nofollow ucg noindex" target="_blank">{{ _('Archive.ph link') }} <span class="fe fe-external"></span></a></p>
{% endif -%}

View file

@ -557,7 +557,14 @@ def blocked_users(user_id) -> List[int]:
def blocked_phrases() -> List[str]:
site = Site.query.get(1)
if site.blocked_phrases:
return [phrase for phrase in site.blocked_phrases.split('\n') if phrase != '']
blocked_phrases = []
for phrase in site.blocked_phrases.split('\n'):
if phrase != '':
if phrase.endswith('\r'):
blocked_phrases.append(phrase[:-1])
else:
blocked_phrases.append(phrase)
return blocked_phrases
else:
return []