report and block profiles

This commit is contained in:
rimu 2024-01-01 16:26:57 +13:00
parent 013b6c326e
commit b4dcdf98e7
12 changed files with 225 additions and 11 deletions

View file

@ -1,8 +1,8 @@
# PieFed Governance
The PieFed project uses a governance model commonly described as Benevolent Dictator For Life
The PieFed project currently uses a governance model commonly described as Benevolent Dictator For Life
([BDFL](https://en.wikipedia.org/wiki/Benevolent_dictator_for_life)). This document outlines our implementation of
this model.
this model. A new governance model will be adopted when the project outgrows BDFL.
## Terms

View file

@ -11,7 +11,7 @@ from flask_babel import _, get_locale
from sqlalchemy import select, desc
from sqlalchemy_searchable import search
from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \
ap_datetime
ap_datetime, ip_address
from app.models import Community, CommunityMember, Post, Site, User
@ -99,8 +99,10 @@ def robots():
@bp.route('/test')
def test():
refresh_user_profile(12)
return 'done'
ip = request.headers.get('X-Forwarded-For') or request.remote_addr
if ',' in ip: # Remove all but first ip addresses
ip = ip[:ip.index(',')].strip()
return ip
def verification_warning():

View file

@ -268,6 +268,7 @@ class User(UserMixin, db.Model):
unread_notifications = db.Column(db.Integer, default=0)
ip_address = db.Column(db.String(50))
instance_id = db.Column(db.Integer, db.ForeignKey('instance.id'), index=True)
reports = db.Column(db.Integer, default=0) # how many times this user has been reported.
avatar = db.relationship('File', lazy='joined', foreign_keys=[avatar_id], single_parent=True, cascade="all, delete-orphan")
cover = db.relationship('File', lazy='joined', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan")
@ -456,10 +457,14 @@ class User(UserMixin, db.Model):
def created_recently(self):
return self.created and self.created > utcnow() - timedelta(days=7)
def has_blocked_instance(self, instance_id):
def has_blocked_instance(self, instance_id: int):
instance_block = InstanceBlock.query.filter_by(user_id=self.id, instance_id=instance_id).first()
return instance_block is not None
def has_blocked_user(self, user_id: int):
existing_block = UserBlock.query.filter_by(blocker_id=self.id, blocked_id=user_id).first()
return existing_block is not None
@staticmethod
def verify_reset_password_token(token):
try:

View file

@ -14,7 +14,7 @@ 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, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
from app.models import Post, PostReply, \
PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report
PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site
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, domain_from_url, validate_image, gibberish, ap_datetime, return_304, \
@ -547,13 +547,19 @@ def post_report(post_id: int):
db.session.add(report)
# Notify moderators
already_notified = set()
for mod in post.community.moderators():
notification = Notification(user_id=mod.user_id, title=_('A post has been reported'),
url=f"https://{current_app.config['SERVER_NAME']}/post/{post.id}",
author_id=current_user.id)
db.session.add(notification)
already_notified.add(mod.id)
post.reports += 1
# todo: Also notify admins for certain types of report
# 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=post.ap_id, user_id=admin.id, author_id=current_user.id)
db.session.add(notify)
db.session.commit()
# todo: federate report to originating instance
@ -636,13 +642,19 @@ def post_reply_report(post_id: int, comment_id: int):
db.session.add(report)
# Notify moderators
already_notified = set()
for mod in post.community.moderators():
notification = Notification(user_id=mod.user_id, title=_('A comment has been reported'),
url=f"https://{current_app.config['SERVER_NAME']}/comment/{post_reply.id}",
author_id=current_user.id)
db.session.add(notification)
already_notified.add(mod.id)
post_reply.reports += 1
# todo: Also notify admins for certain types of report
# 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=post.ap_id, user_id=admin.id, author_id=current_user.id)
db.session.add(notify)
db.session.commit()
# todo: federate report to originating instance

View file

@ -704,4 +704,8 @@ fieldset legend {
font-weight: bold;
}
.profile_action_buttons {
float: right;
}
/*# sourceMappingURL=structure.css.map */

View file

@ -412,4 +412,8 @@ fieldset {
legend {
font-weight: bold;
}
}
.profile_action_buttons {
float: right;
}

View file

@ -22,6 +22,7 @@
<th>Local/Remote</th>
<th>Attitude</th>
<th>Banned</th>
<th>Reports</th>
<th>Actions</th>
</tr>
{% for user in users %}
@ -31,6 +32,7 @@
<td>{{ 'Local' if user.is_local() else 'Remote' }}</td>
<td>{{ user.attitude * 100 }}</td>
<td>{{ '<span class="red">Banned</span>'|safe if user.banned }} </td>
<td>{{ user.reports if user.reports > 0 }} </td>
<td><a href="/u/{{ user.link() }}">View local</a> |
{% if not user.is_local() %}
<a href="{{ user.ap_profile_id }}">View remote</a> |

View file

@ -35,6 +35,21 @@
</nav>
<h1 class="mt-2">{{ user.display_name() if user.is_local() else user.ap_id }}</h1>
{% endif %}
{% if current_user.is_authenticated %}
<div class="profile_action_buttons">
{% if user.matrix_user_id %}
<a class="btn btn-primary" href="https://matrix.to/#/{{ user.matrix_user_id }}" rel="nofollow">{{ _('Send message') }}</a>
{% endif %}
{% if current_user.id != user.id %}
{% if current_user.has_blocked_user(user.id) %}
<a class="btn btn-primary" href="{{ url_for('user.unblock_profile', actor=user.link()) }}" rel="nofollow">{{ _('Unblock user') }}</a>
{% else %}
<a class="btn btn-primary confirm_first" href="{{ url_for('user.block_profile', actor=user.link()) }}" rel="nofollow">{{ _('Block user') }}</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for('user.report_profile', actor=user.link()) }}" rel="nofollow">{{ _('Report user') }}</a>
{% endif %}
</div>
{% endif %}
<p class="small">{{ _('Joined') }}: {{ moment(user.created).fromNow(refresh=True) }}<br />
{{ _('Attitude') }}: <span title="{{ _('Ratio of upvotes cast to downvotes cast. Higher is more positive.') }}">{{ (user.attitude * 100) | round | int }}%</span></p>
{{ user.about_html|safe }}

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% 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">{{ _('Report "%(user_name)s"', user_name=user.display_name()) }}</div>
<div class="card-body">
{{ render_form(form) }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -5,6 +5,8 @@ from wtforms import StringField, SubmitField, PasswordField, BooleanField, Email
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
from flask_babel import _, lazy_gettext as _l
from app.utils import MultiCheckboxField
class ProfileForm(FlaskForm):
title = StringField(_l('Display name'), validators=[Optional(), Length(max=255)])
@ -36,3 +38,29 @@ class SettingsForm(FlaskForm):
class DeleteAccountForm(FlaskForm):
submit = SubmitField(_l('Yes, delete my account'))
class ReportUserForm(FlaskForm):
reason_choices = [('1', _l('Breaks community rules')), ('7', _l('Spam')), ('2', _l('Harassment')),
('3', _l('Threatening violence')), ('4', _l('Hate / genocide')),
('15', _l('Misinformation / disinformation')),
('16', _l('Racism, sexism, transphobia')),
('6', _l('Sharing personal info - doxing')),
('5', _l('Minor abuse or sexualization')),
('8', _l('Non-consensual intimate media')),
('9', _l('Prohibited transaction')), ('10', _l('Impersonation')),
('11', _l('Copyright violation')), ('12', _l('Trademark violation')),
('13', _l('Self-harm or suicide')),
('14', _l('Other'))]
reasons = MultiCheckboxField(_l('Reason'), choices=reason_choices)
description = StringField(_l('More info'))
report_remote = BooleanField('Also send report to originating instance')
submit = SubmitField(_l('Report'))
def reasons_to_string(self, reason_data) -> str:
result = []
for reason_id in reason_data:
for choice in self.reason_choices:
if choice[0] == reason_id:
result.append(str(choice[1]))
return ', '.join(result)

View file

@ -10,9 +10,9 @@ from app.activitypub.signature import post_request
from app.activitypub.util import default_context
from app.community.util import save_icon_file, save_banner_file
from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification, utcnow, File, Site, \
Instance
Instance, Report, UserBlock
from app.user import bp
from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm
from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm, ReportUserForm
from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \
is_image_url
from sqlalchemy import desc, or_, text
@ -201,6 +201,99 @@ def unban_profile(actor):
return redirect(goto)
@bp.route('/u/<actor>/block', methods=['GET'])
@login_required
def block_profile(actor):
actor = actor.strip()
user = User.query.filter_by(user_name=actor, deleted=False).first()
if user is None:
user = User.query.filter_by(ap_id=actor, deleted=False).first()
if user is None:
abort(404)
if user.id == current_user.id:
flash(_('You cannot block yourself.'), 'error')
else:
existing_block = UserBlock.query.filter_by(blocker_id=current_user.id, blocked_id=user.id).first()
if not existing_block:
block = UserBlock(blocker_id=current_user.id, blocked_id=user.id)
db.session.add(block)
db.session.commit()
if not user.is_local():
...
# federate block
flash(f'{actor} has been blocked.')
goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}'
return redirect(goto)
@bp.route('/u/<actor>/unblock', methods=['GET'])
@login_required
def unblock_profile(actor):
actor = actor.strip()
user = User.query.filter_by(user_name=actor, deleted=False).first()
if user is None:
user = User.query.filter_by(ap_id=actor, deleted=False).first()
if user is None:
abort(404)
if user.id == current_user.id:
flash(_('You cannot unblock yourself.'), 'error')
else:
existing_block = UserBlock.query.filter_by(blocker_id=current_user.id, blocked_id=user.id).first()
if existing_block:
db.session.delete(existing_block)
db.session.commit()
if not user.is_local():
...
# federate unblock
flash(f'{actor} has been unblocked.')
goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}'
return redirect(goto)
@bp.route('/u/<actor>/report', methods=['GET', 'POST'])
@login_required
def report_profile(actor):
if '@' in actor:
user: User = User.query.filter_by(ap_id=actor, deleted=False, banned=False).first()
else:
user: User = User.query.filter_by(user_name=actor, deleted=False, ap_id=None).first()
form = ReportUserForm()
if user and not user.banned:
if form.validate_on_submit():
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data,
type=0, reporter_id=current_user.id, suspect_user_id=user.id)
db.session.add(report)
# Notify site admin
already_notified = set()
for admin in Site.admins():
if admin.id not in already_notified:
notify = Notification(title='Reported user', url=user.ap_id, user_id=admin.id, author_id=current_user.id)
db.session.add(notify)
user.reports += 1
db.session.commit()
# todo: federate report to originating instance
if not user.is_local() and form.report_remote.data:
...
flash(_('%(user_name)s has been reported, thank you!', user_name=actor))
goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}'
return redirect(goto)
elif request.method == 'GET':
form.report_remote.data = True
return render_template('user/user_report.html', title=_('Report user'), form=form, user=user)
@bp.route('/u/<actor>/delete', methods=['GET'])
@login_required
def delete_profile(actor):

View file

@ -0,0 +1,32 @@
"""user reports count
Revision ID: b18ea0b841fe
Revises: 0dadae40281d
Create Date: 2024-01-01 16:16:45.975293
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b18ea0b841fe'
down_revision = '0dadae40281d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('reports', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('reports')
# ### end Alembic commands ###