Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Martynas Sklizmantas 2024-03-15 09:52:50 +01:00
commit fcde6e9d26
9 changed files with 238 additions and 16 deletions

View file

@ -39,7 +39,7 @@ import arrow
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa 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 datetime import datetime
from dateutil import parser from dateutil import parser
from pyld import jsonld 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 if '@context' not in body: # add a default json-ld context if necessary
body['@context'] = default_context() body['@context'] = default_context()
type = body['type'] if 'type' in body else '' type = body['type'] if 'type' in body else ''
log = ActivityPubLog(direction='out', activity_json=json.dumps(body), activity_type=type, log = ActivityPubLog(direction='out', activity_type=type, result='processing', activity_id=body['id'], exception_message='')
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.add(log)
db.session.commit() db.session.commit()
try: try:

View file

@ -1,12 +1,14 @@
from flask import request, g from flask import request, g
from flask_login import current_user from flask_login import current_user
from flask_wtf import FlaskForm 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 wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
from flask_babel import _, lazy_gettext as _l from flask_babel import _, lazy_gettext as _l
from app import db from app import db
from app.models import Community from app.models import Community, utcnow
from app.utils import domain_from_url, MultiCheckboxField from app.utils import domain_from_url, MultiCheckboxField
from PIL import Image, ImageOps from PIL import Image, ImageOps
from io import BytesIO from io import BytesIO
@ -65,6 +67,14 @@ class SearchRemoteCommunity(FlaskForm):
submit = SubmitField(_l('Search')) 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): class CreatePostForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
post_type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs post_type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs

View file

@ -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.activitypub.util import default_context, notify_about_post, find_actor_or_create
from app.chat.util import send_message from app.chat.util import send_message
from app.community.forms import SearchRemoteCommunity, CreatePostForm, ReportCommunityForm, \ 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, \ 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, \ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \
SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR
from app.inoculation import inoculation from app.inoculation import inoculation
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ 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.community import bp
from app.user.utils import search_for_user from app.user.utils import search_for_user
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ 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()) return redirect(community.local_url())
@bp.route('/community/<int:community_id>/<int:user_id>/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('/<int:community_id>/notification', methods=['GET', 'POST']) @bp.route('/<int:community_id>/notification', methods=['GET', 'POST'])
@login_required @login_required
def community_notification(community_id: int): def community_notification(community_id: int):

View file

@ -10,12 +10,13 @@ from pillow_heif import register_heif_opener
from app import db, cache, celery from app import db, cache, celery
from app.activitypub.signature import post_request 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.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE
from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \ from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \
Instance, Notification, User Instance, Notification, User
from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \ 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 from sqlalchemy import func
import os import os
@ -283,6 +284,121 @@ def save_post(form, post: Post):
g.site.last_active = utcnow() 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): def remove_old_file(file_id):
remove_file = File.query.get(file_id) remove_file = File.query.get(file_id)
remove_file.delete_from_disk() remove_file.delete_from_disk()

View file

@ -374,7 +374,12 @@ class Community(db.Model):
def user_is_banned(self, user): def user_is_banned(self, user):
membership = CommunityMember.query.filter(CommunityMember.community_id == self.id, CommunityMember.user_id == user.id).first() 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): def profile_id(self):
@ -980,7 +985,7 @@ class CommunityMember(db.Model):
# people banned from communities # people banned from communities
class CommunityBan(db.Model): 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) community_id = db.Column(db.Integer, db.ForeignKey('community.id'), primary_key=True)
banned_by = db.Column(db.Integer, db.ForeignKey('user.id')) banned_by = db.Column(db.Integer, db.ForeignKey('user.id'))
reason = db.Column(db.String(50)) reason = db.Column(db.String(50))

View file

@ -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.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.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE
from app.models import Post, PostReply, \ 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.post import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ 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, \ 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/<int:post_id>/options', methods=['GET']) @bp.route('/post/<int:post_id>/options', methods=['GET'])
def post_options(post_id: int): def post_options(post_id: int):
post = Post.query.get_or_404(post_id) 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())) joined_communities=joined_communities(current_user.get_id()))

View file

@ -38,7 +38,13 @@
<td><span style="color: red;">{{ activity.result }}</span></td> <td><span style="color: red;">{{ activity.result }}</span></td>
{% endif %} {% endif %}
<td>{{ activity.exception_message if activity.exception_message else '' }}</td> <td>{{ activity.exception_message if activity.exception_message else '' }}</td>
<td><a href="{{ url_for('admin.activity_json', activity_id=activity.id) }}">View</a></td> <td>
{% if activity.activity_json is none %}
None
{% else %}
<a href="{{ url_for('admin.activity_json', activity_id=activity.id) }}">View</a>
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
@ -56,4 +62,4 @@
</nav> </nav>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -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 %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Ban "%(user_name)s" from %(community_name)s', user_name=user.display_name(), community_name=community.title) }}</div>
<div class="card-body">
{{ render_form(form) }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -26,6 +26,10 @@
{% if post.user_id != current_user.id %} {% if post.user_id != current_user.id %}
<li><a href="{{ url_for('post.post_block_user', post_id=post.id) }}" class="no-underline"><span class="fe fe-block"></span> <li><a href="{{ url_for('post.post_block_user', post_id=post.id) }}" class="no-underline"><span class="fe fe-block"></span>
{{ _('Block post author @%(author_name)s', author_name=post.author.user_name) }}</a></li> {{ _('Block post author @%(author_name)s', author_name=post.author.user_name) }}</a></li>
{% if post.community.is_moderator() or current_user.is_admin() %}
<li><a href="{{ url_for('community.community_ban_user', community_id=post.community.id, user_id=post.author.id) }}" class="no-underline"><span class="fe fe-block red"></span>
{{ _('Ban post author @%(author_name)s from<br>%(community_name)s', author_name=post.author.user_name, community_name=post.community.title) }}</a></li>
{% endif %}
{% if post.domain_id %} {% if post.domain_id %}
<li><a href="{{ url_for('post.post_block_domain', post_id=post.id) }}" class="no-underline"><span class="fe fe-block"></span> <li><a href="{{ url_for('post.post_block_domain', post_id=post.id) }}" class="no-underline"><span class="fe fe-block"></span>
{{ _('Block domain %(domain)s', domain=post.domain.name) }}</a></li> {{ _('Block domain %(domain)s', domain=post.domain.name) }}</a></li>