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.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:

View file

@ -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

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.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/<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'])
@login_required
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.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()

View file

@ -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))

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.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/<int:post_id>/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()))

View file

@ -38,7 +38,13 @@
<td><span style="color: red;">{{ activity.result }}</span></td>
{% endif %}
<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>
{% endfor %}
</table>

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 %}
<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>
{% 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 %}
<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>