diff --git a/app/activitypub/signature.py b/app/activitypub/signature.py index 1a8f173b..d78b3bcc 100644 --- a/app/activitypub/signature.py +++ b/app/activitypub/signature.py @@ -39,7 +39,7 @@ import arrow from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa -from flask import Request, current_app +from flask import Request, current_app, g from datetime import datetime from dateutil import parser from pyld import jsonld @@ -81,8 +81,9 @@ def post_request(uri: str, body: dict | None, private_key: str, key_id: str, con if '@context' not in body: # add a default json-ld context if necessary body['@context'] = default_context() type = body['type'] if 'type' in body else '' - log = ActivityPubLog(direction='out', activity_json=json.dumps(body), activity_type=type, - result='processing', activity_id=body['id'], exception_message='') + log = ActivityPubLog(direction='out', activity_type=type, result='processing', activity_id=body['id'], exception_message='') + if g.site.log_activitypub_json: + log.activity_json=json.dumps(body) db.session.add(log) db.session.commit() try: diff --git a/app/community/forms.py b/app/community/forms.py index 75507e03..1b721536 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -1,12 +1,14 @@ from flask import request, g from flask_login import current_user from flask_wtf import FlaskForm -from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField +from validators import Min +from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField, \ + DateField from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional from flask_babel import _, lazy_gettext as _l from app import db -from app.models import Community +from app.models import Community, utcnow from app.utils import domain_from_url, MultiCheckboxField from PIL import Image, ImageOps from io import BytesIO @@ -65,6 +67,14 @@ class SearchRemoteCommunity(FlaskForm): submit = SubmitField(_l('Search')) +class BanUserCommunityForm(FlaskForm): + reason = StringField(_l('Reason'), render_kw={'autofocus': True}, validators=[DataRequired()]) + ban_until = DateField(_l('Ban until')) + delete_posts = BooleanField(_l('Also delete all their posts')) + delete_post_replies = BooleanField(_l('Also delete all their comments')) + submit = SubmitField(_l('Ban')) + + class CreatePostForm(FlaskForm): communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) post_type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs diff --git a/app/community/routes.py b/app/community/routes.py index 984e01c2..f7f61d94 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -12,14 +12,15 @@ from app.activitypub.signature import RsaKeys, post_request from app.activitypub.util import default_context, notify_about_post, find_actor_or_create from app.chat.util import send_message from app.community.forms import SearchRemoteCommunity, CreatePostForm, ReportCommunityForm, \ - DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm + DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm, BanUserCommunityForm from app.community.util import search_for_community, community_url_exists, actor_to_community, \ - opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance + opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance, \ + delete_post_from_community, delete_post_reply_from_community from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR from app.inoculation import inoculation from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ - File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic, Conversation + File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic, Conversation, PostReply from app.community import bp from app.user.utils import search_for_user from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ @@ -772,6 +773,62 @@ def community_block_instance(community_id: int): return redirect(community.local_url()) +@bp.route('/community///ban_user_community', methods=['GET', 'POST']) +@login_required +def community_ban_user(community_id: int, user_id: int): + community = Community.query.get_or_404(community_id) + user = User.query.get_or_404(user_id) + existing = CommunityBan.query.filter_by(community_id=community.id, user_id=user.id).first() + + form = BanUserCommunityForm() + if form.validate_on_submit(): + if not existing: + new_ban = CommunityBan(community_id=community_id, user_id=user.id, banned_by=current_user.id, + reason=form.reason.data) + if form.ban_until.data is not None and form.ban_until.data < utcnow().date(): + new_ban.ban_until = form.ban_until.data + db.session.add(new_ban) + db.session.commit() + flash(_('%(name)s has been banned.', name=user.display_name())) + + if form.delete_posts.data: + posts = Post.query.filter(Post.user_id == user.id, Post.community_id == community.id).all() + for post in posts: + delete_post_from_community(post.id) + if posts: + flash(_('Posts by %(name)s have been deleted.', name=user.display_name())) + if form.delete_post_replies.data: + post_replies = PostReply.query.filter(PostReply.user_id == user.id, Post.community_id == community.id).all() + for post_reply in post_replies: + delete_post_reply_from_community(post_reply.id) + if post_replies: + flash(_('Comments by %(name)s have been deleted.', name=user.display_name())) + + # todo: federate ban to post author instance + + # notify banned person + if user.is_local(): + notify = Notification(title=shorten_string('You have been banned from ' + community.title), + url=f'/', user_id=user.id, + author_id=1) + db.session.add(notify) + user.unread_notifications += 1 + db.session.commit() + else: + ... + # todo: send chatmessage to remote user and federate it + + return redirect(community.local_url()) + else: + return render_template('community/community_ban_user.html', title=_('Ban from community'), form=form, community=community, + user=user, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + + + @bp.route('//notification', methods=['GET', 'POST']) @login_required def community_notification(community_id: int): diff --git a/app/community/util.py b/app/community/util.py index f86e5811..a67b93c9 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -10,12 +10,13 @@ from pillow_heif import register_heif_opener from app import db, cache, celery from app.activitypub.signature import post_request -from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model +from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model, default_context from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \ Instance, Notification, User from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \ - html_to_markdown, is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, remove_tracking_from_link + html_to_markdown, is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, \ + remove_tracking_from_link, ap_datetime, instance_banned from sqlalchemy import func import os @@ -283,6 +284,121 @@ def save_post(form, post: Post): g.site.last_active = utcnow() +def delete_post_from_community(post_id): + if current_app.debug: + delete_post_from_community_task(post_id) + else: + delete_post_from_community_task.delay(post_id) + + +@celery.task +def delete_post_from_community_task(post_id): + post = Post.query.get(post_id) + community = post.community + post.delete_dependencies() + post.flush_cache() + db.session.delete(post) + db.session.commit() + + if not community.local_only: + delete_json = { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/delete/{gibberish(15)}", + 'type': 'Delete', + 'actor': current_user.profile_id(), + 'audience': post.community.profile_id(), + 'to': [post.community.profile_id(), 'https://www.w3.org/ns/activitystreams#Public'], + 'published': ap_datetime(utcnow()), + 'cc': [ + current_user.followers_url() + ], + 'object': post.ap_id, + } + + 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, delete_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + 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': delete_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 delete_post_reply_from_community(post_reply_id): + if current_app.debug: + delete_post_reply_from_community_task(post_reply_id) + else: + delete_post_reply_from_community_task.delay(post_reply_id) + + +@celery.task +def delete_post_reply_from_community_task(post_reply_id): + post_reply = PostReply.query.get(post_reply_id) + post = post_reply.post + community = post.community + if post_reply.user_id == current_user.id or community.is_moderator(): + if post_reply.has_replies(): + post_reply.body = 'Deleted by author' if post_reply.author.id == current_user.id else 'Deleted by moderator' + post_reply.body_html = markdown_to_html(post_reply.body) + else: + post_reply.delete_dependencies() + db.session.delete(post_reply) + db.session.commit() + post.flush_cache() + # federate delete + if not post.community.local_only: + delete_json = { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/delete/{gibberish(15)}", + 'type': 'Delete', + 'actor': current_user.profile_id(), + 'audience': post.community.profile_id(), + 'to': [post.community.profile_id(), 'https://www.w3.org/ns/activitystreams#Public'], + 'published': ap_datetime(utcnow()), + 'cc': [ + current_user.followers_url() + ], + 'object': post_reply.ap_id, + } + + 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, delete_json, current_user.private_key, + current_user.ap_profile_id + '#main-key') + + 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': delete_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 remove_old_file(file_id): remove_file = File.query.get(file_id) remove_file.delete_from_disk() diff --git a/app/models.py b/app/models.py index 2f8e0d4c..73c2a238 100644 --- a/app/models.py +++ b/app/models.py @@ -374,7 +374,12 @@ class Community(db.Model): def user_is_banned(self, user): membership = CommunityMember.query.filter(CommunityMember.community_id == self.id, CommunityMember.user_id == user.id).first() - return membership.is_banned if membership else False + if membership.is_banned: + return True + banned = CommunityBan.query.filter(CommunityBan.community_id == self.id, CommunityBan.user_id == user.id).first() + if banned: + return True + return False def profile_id(self): @@ -980,7 +985,7 @@ class CommunityMember(db.Model): # people banned from communities class CommunityBan(db.Model): - user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) # person who is banned, not the banner community_id = db.Column(db.Integer, db.ForeignKey('community.id'), primary_key=True) banned_by = db.Column(db.Integer, db.ForeignKey('user.id')) reason = db.Column(db.String(50)) diff --git a/app/post/routes.py b/app/post/routes.py index 014e8a1c..a40a10cd 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -17,7 +17,8 @@ from app.community.forms import CreatePostForm from app.post.util import post_replies, get_comment_branch, post_reply_count from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE from app.models import Post, PostReply, \ - PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, Topic + PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \ + Topic from app.post import bp from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \ @@ -585,7 +586,8 @@ def add_reply(post_id: int, comment_id: int): @bp.route('/post//options', methods=['GET']) def post_options(post_id: int): post = Post.query.get_or_404(post_id) - return render_template('post/post_options.html', post=post, moderating_communities=moderating_communities(current_user.get_id()), + return render_template('post/post_options.html', post=post, + moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id())) diff --git a/app/templates/admin/activities.html b/app/templates/admin/activities.html index a8f86d4b..c0e09b30 100644 --- a/app/templates/admin/activities.html +++ b/app/templates/admin/activities.html @@ -38,7 +38,13 @@ {{ activity.result }} {% endif %} {{ activity.exception_message if activity.exception_message else '' }} - View + + {% if activity.activity_json is none %} + None + {% else %} + View + {% endif %} + {% endfor %} @@ -56,4 +62,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/community/community_ban_user.html b/app/templates/community/community_ban_user.html new file mode 100644 index 00000000..ccf90c65 --- /dev/null +++ b/app/templates/community/community_ban_user.html @@ -0,0 +1,21 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+ +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/post/post_options.html b/app/templates/post/post_options.html index 1981a931..34c8831f 100644 --- a/app/templates/post/post_options.html +++ b/app/templates/post/post_options.html @@ -26,6 +26,10 @@ {% if post.user_id != current_user.id %}
  • {{ _('Block post author @%(author_name)s', author_name=post.author.user_name) }}
  • + {% if post.community.is_moderator() or current_user.is_admin() %} +
  • + {{ _('Ban post author @%(author_name)s from
    %(community_name)s', author_name=post.author.user_name, community_name=post.community.title) }}
  • + {% endif %} {% if post.domain_id %}
  • {{ _('Block domain %(domain)s', domain=post.domain.name) }}