mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 11:26:56 -08:00
report and block profiles
This commit is contained in:
parent
013b6c326e
commit
b4dcdf98e7
12 changed files with 225 additions and 11 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -704,4 +704,8 @@ fieldset legend {
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
.profile_action_buttons {
|
||||
float: right;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=structure.css.map */
|
||||
|
|
|
@ -412,4 +412,8 @@ fieldset {
|
|||
legend {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.profile_action_buttons {
|
||||
float: right;
|
||||
}
|
|
@ -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> |
|
||||
|
|
|
@ -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 }}
|
||||
|
|
17
app/templates/user/user_report.html
Normal file
17
app/templates/user/user_report.html
Normal 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 %}
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
32
migrations/versions/b18ea0b841fe_user_reports_count.py
Normal file
32
migrations/versions/b18ea0b841fe_user_reports_count.py
Normal 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 ###
|
Loading…
Reference in a new issue