From b4dcdf98e7bfa5bf27752d1e7e2b901c08e38321 Mon Sep 17 00:00:00 2001
From: rimu <3310831+rimu@users.noreply.github.com>
Date: Mon, 1 Jan 2024 16:26:57 +1300
Subject: [PATCH] report and block profiles
---
GOVERNANCE.md | 4 +-
app/main/routes.py | 8 +-
app/models.py | 7 +-
app/post/routes.py | 18 +++-
app/static/structure.css | 4 +
app/static/structure.scss | 4 +
app/templates/admin/users.html | 2 +
app/templates/user/show_profile.html | 15 +++
app/templates/user/user_report.html | 17 ++++
app/user/forms.py | 28 ++++++
app/user/routes.py | 97 ++++++++++++++++++-
.../b18ea0b841fe_user_reports_count.py | 32 ++++++
12 files changed, 225 insertions(+), 11 deletions(-)
create mode 100644 app/templates/user/user_report.html
create mode 100644 migrations/versions/b18ea0b841fe_user_reports_count.py
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index bb98a371..11c9926f 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -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
diff --git a/app/main/routes.py b/app/main/routes.py
index 97052307..d46efc5f 100644
--- a/app/main/routes.py
+++ b/app/main/routes.py
@@ -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():
diff --git a/app/models.py b/app/models.py
index 7d90399c..800c4074 100644
--- a/app/models.py
+++ b/app/models.py
@@ -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:
diff --git a/app/post/routes.py b/app/post/routes.py
index e12d64b6..d7afd3ea 100644
--- a/app/post/routes.py
+++ b/app/post/routes.py
@@ -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
diff --git a/app/static/structure.css b/app/static/structure.css
index 017e0e5d..d4214bc1 100644
--- a/app/static/structure.css
+++ b/app/static/structure.css
@@ -704,4 +704,8 @@ fieldset legend {
font-weight: bold;
}
+.profile_action_buttons {
+ float: right;
+}
+
/*# sourceMappingURL=structure.css.map */
diff --git a/app/static/structure.scss b/app/static/structure.scss
index e5f4d634..02d5eb68 100644
--- a/app/static/structure.scss
+++ b/app/static/structure.scss
@@ -412,4 +412,8 @@ fieldset {
legend {
font-weight: bold;
}
+}
+
+.profile_action_buttons {
+ float: right;
}
\ No newline at end of file
diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html
index 13b89361..876937fe 100644
--- a/app/templates/admin/users.html
+++ b/app/templates/admin/users.html
@@ -22,6 +22,7 @@
Local/Remote |
Attitude |
Banned |
+ Reports |
Actions |
{% for user in users %}
@@ -31,6 +32,7 @@
{{ 'Local' if user.is_local() else 'Remote' }} |
{{ user.attitude * 100 }} |
{{ 'Banned'|safe if user.banned }} |
+ {{ user.reports if user.reports > 0 }} |
View local |
{% if not user.is_local() %}
View remote |
diff --git a/app/templates/user/show_profile.html b/app/templates/user/show_profile.html
index 8ea9ad3c..03a22fe5 100644
--- a/app/templates/user/show_profile.html
+++ b/app/templates/user/show_profile.html
@@ -35,6 +35,21 @@
{{ user.display_name() if user.is_local() else user.ap_id }}
{% endif %}
+ {% if current_user.is_authenticated %}
+
+ {% endif %}
{{ _('Joined') }}: {{ moment(user.created).fromNow(refresh=True) }}
{{ _('Attitude') }}: {{ (user.attitude * 100) | round | int }}%
{{ user.about_html|safe }}
diff --git a/app/templates/user/user_report.html b/app/templates/user/user_report.html
new file mode 100644
index 00000000..8ec62028
--- /dev/null
+++ b/app/templates/user/user_report.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% from 'bootstrap/form.html' import render_form %}
+
+{% block app_content %}
+
+
+
+
+ {{ _('Report "%(user_name)s"', user_name=user.display_name()) }}
+
+ {{ render_form(form) }}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/user/forms.py b/app/user/forms.py
index 632029b8..ca4c86b7 100644
--- a/app/user/forms.py
+++ b/app/user/forms.py
@@ -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)
diff --git a/app/user/routes.py b/app/user/routes.py
index b96f8fec..aa3a4385 100644
--- a/app/user/routes.py
+++ b/app/user/routes.py
@@ -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//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//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//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//delete', methods=['GET'])
@login_required
def delete_profile(actor):
diff --git a/migrations/versions/b18ea0b841fe_user_reports_count.py b/migrations/versions/b18ea0b841fe_user_reports_count.py
new file mode 100644
index 00000000..8f8f9937
--- /dev/null
+++ b/migrations/versions/b18ea0b841fe_user_reports_count.py
@@ -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 ###
|