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 %} +
+ {% if user.matrix_user_id %} + {{ _('Send message') }} + {% endif %} + {% if current_user.id != user.id %} + {% if current_user.has_blocked_user(user.id) %} + {{ _('Unblock user') }} + {% else %} + {{ _('Block user') }} + {% endif %} + {{ _('Report user') }} + {% endif %} +
+ {% 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 ###