From 56d09b264a5347e17802c38f41557490b2bd4f9b Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Sat, 21 Oct 2023 15:49:01 +1300 Subject: [PATCH] user banning and purging --- app/activitypub/routes.py | 82 +++++++++--------- app/activitypub/util.py | 4 +- app/auth/forms.py | 2 +- app/cli.py | 4 +- app/models.py | 35 +++++++- app/static/js/scripts.js | 12 +++ app/templates/base.html | 7 ++ app/templates/community/_post_full.html | 4 +- app/templates/community/_post_teaser.html | 2 +- app/templates/community/community.html | 2 +- .../community/continue_discussion.html | 16 ++-- app/templates/community/post.html | 14 ++-- app/templates/user/show_profile.html | 31 +++++-- app/user/routes.py | 83 ++++++++++++++++++- app/utils.py | 19 ++++- .../c88bbba381b5_community_creator.py | 34 ++++++++ pyfedi.py | 3 +- 17 files changed, 285 insertions(+), 69 deletions(-) create mode 100644 migrations/versions/c88bbba381b5_community_creator.py diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 93419690..2c91ad96 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -138,6 +138,8 @@ def user_profile(actor): return resp else: return show_profile(user) + else: + abort(404) @bp.route('/c/', methods=['GET']) @@ -313,38 +315,39 @@ def shared_inbox(): user_ap_id = request_json['object']['actor'] liked_ap_id = request_json['object']['object'] user = find_actor_or_create(user_ap_id) - vote_weight = 1.0 - if user.ap_domain: - instance = Instance.query.filter_by(domain=user.ap_domain).fetch() - if instance: - vote_weight = instance.vote_weight - liked = find_liked_object(liked_ap_id) - # insert into voted table - if liked is not None and isinstance(liked, Post): - existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=liked.id).first() - if existing_vote: - existing_vote.effect = vote_effect * vote_weight + if user: + vote_weight = 1.0 + if user.ap_domain: + instance = Instance.query.filter_by(domain=user.ap_domain).fetch() + if instance: + vote_weight = instance.vote_weight + liked = find_liked_object(liked_ap_id) + # insert into voted table + if liked is not None and isinstance(liked, Post): + existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=liked.id).first() + if existing_vote: + existing_vote.effect = vote_effect * vote_weight + else: + vote = PostVote(user_id=user.id, author_id=liked.user_id, post_id=liked.id, + effect=vote_effect * vote_weight) + db.session.add(vote) + db.session.commit() + activity_log.result = 'success' + elif liked is not None and isinstance(liked, PostReply): + existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=liked.id).first() + if existing_vote: + existing_vote.effect = vote_effect * vote_weight + else: + vote = PostReplyVote(user_id=user.id, author_id=liked.user_id, post_reply_id=liked.id, + effect=vote_effect * vote_weight) + db.session.add(vote) + db.session.commit() + activity_log.result = 'success' else: - vote = PostVote(user_id=user.id, author_id=liked.user_id, post_id=liked.id, - effect=vote_effect * vote_weight) - db.session.add(vote) - db.session.commit() - activity_log.result = 'success' - elif liked is not None and isinstance(liked, PostReply): - existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=liked.id).first() - if existing_vote: - existing_vote.effect = vote_effect * vote_weight - else: - vote = PostReplyVote(user_id=user.id, author_id=liked.user_id, post_reply_id=liked.id, - effect=vote_effect * vote_weight) - db.session.add(vote) - db.session.commit() - activity_log.result = 'success' - else: - activity_log.exception_message = 'Could not detect type of like' - if activity_log.result == 'success': - ... # todo: recalculate 'hotness' of liked post/reply - # todo: if vote was on content in local community, federate the vote out to followers + activity_log.exception_message = 'Could not detect type of like' + if activity_log.result == 'success': + ... # todo: recalculate 'hotness' of liked post/reply + # todo: if vote was on content in local community, federate the vote out to followers # Follow: remote user wants to follow one of our communities elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community @@ -399,14 +402,15 @@ def shared_inbox(): user_ap_id = request_json['object']['actor'] user = find_actor_or_create(user_ap_id) community = find_actor_or_create(community_ap_id) - join_request = CommunityJoinRequest.query.filter_by(user_id=user.id, - community_id=community.id).first() - if join_request: - member = CommunityMember(user_id=user.id, community_id=community.id) - db.session.add(member) - community.subscriptions_count += 1 - db.session.commit() - activity_log.result = 'success' + if user and community: + join_request = CommunityJoinRequest.query.filter_by(user_id=user.id, + community_id=community.id).first() + if join_request: + member = CommunityMember(user_id=user.id, community_id=community.id) + db.session.add(member) + community.subscriptions_count += 1 + db.session.commit() + activity_log.result = 'success' else: activity_log.exception_message = 'Instance banned' else: diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 8701b2e2..76af6e8a 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -192,7 +192,7 @@ def find_actor_or_create(actor: str) -> Union[User, Community, None]: ap_profile_id=actor).first() # finds communities formatted like https://localhost/c/* if current_app.config['SERVER_NAME'] + '/u/' in actor: - user = User.query.filter_by(username=actor.split('/')[-1], ap_id=None).first() # finds local users + user = User.query.filter_by(username=actor.split('/')[-1], ap_id=None, banned=False).first() # finds local users if user is None: return None elif actor.startswith('https://'): @@ -201,6 +201,8 @@ def find_actor_or_create(actor: str) -> Union[User, Community, None]: return None user = User.query.filter_by( ap_profile_id=actor).first() # finds users formatted like https://kbin.social/u/tables + if user.banned: + return None if user is None: user = Community.query.filter_by(ap_profile_id=actor).first() if user is None: diff --git a/app/auth/forms.py b/app/auth/forms.py index 2d9ec1d2..00359aba 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -48,4 +48,4 @@ class ResetPasswordForm(FlaskForm): password2 = PasswordField( _l('Repeat password'), validators=[DataRequired(), EqualTo('password')]) - submit = SubmitField(_l('Request password reset')) + submit = SubmitField(_l('Set password')) diff --git a/app/cli.py b/app/cli.py index 6ce806c7..5902eee5 100644 --- a/app/cli.py +++ b/app/cli.py @@ -86,11 +86,13 @@ def register(app): staff_role = Role(name='Staff', weight=2) staff_role.permissions.append(RolePermission(permission='approve registrations')) - staff_role.permissions.append(RolePermission(permission='manage users')) + staff_role.permissions.append(RolePermission(permission='ban users')) db.session.add(staff_role) admin_role = Role(name='Admin', weight=3) + admin_role.permissions.append(RolePermission(permission='approve registrations')) admin_role.permissions.append(RolePermission(permission='change user roles')) + admin_role.permissions.append(RolePermission(permission='ban users')) admin_role.permissions.append(RolePermission(permission='manage users')) db.session.add(admin_role) diff --git a/app/models.py b/app/models.py index 5cecda86..a5677c9b 100644 --- a/app/models.py +++ b/app/models.py @@ -3,9 +3,9 @@ from hashlib import md5 from time import time from typing import List -from flask import current_app, escape +from flask import current_app, escape, url_for from flask_login import UserMixin -from sqlalchemy import or_ +from sqlalchemy import or_, text from werkzeug.security import generate_password_hash, check_password_hash from flask_babel import _, lazy_gettext as _l from sqlalchemy.orm import backref @@ -38,6 +38,7 @@ class Community(db.Model): id = db.Column(db.Integer, primary_key=True) icon_id = db.Column(db.Integer, db.ForeignKey('file.id')) image_id = db.Column(db.Integer, db.ForeignKey('file.id')) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) name = db.Column(db.String(256), index=True) title = db.Column(db.String(256)) description = db.Column(db.Text) @@ -185,6 +186,12 @@ class User(UserMixin, db.Model): except Exception: return False + def display_name(self): + if self.deleted is False: + return self.user_name + else: + return '[deleted]' + def avatar(self, size): digest = md5(self.email.lower().encode('utf-8')).hexdigest() return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format( @@ -266,6 +273,30 @@ class User(UserMixin, db.Model): return return User.query.get(id) + def purge_content(self): + db.session.query(ActivityLog).filter(ActivityLog.user_id == self.id).delete() + db.session.query(PostVote).filter(PostVote.user_id == self.id).delete() + db.session.query(PostReplyVote).filter(PostReplyVote.user_id == self.id).delete() + db.session.query(PostReply).filter(PostReply.user_id == self.id).delete() + db.session.query(FilterKeyword).filter(FilterKeyword.user_id == self.id).delete() + db.session.query(Filter).filter(Filter.user_id == self.id).delete() + db.session.query(DomainBlock).filter(DomainBlock.user_id == self.id).delete() + db.session.query(CommunityJoinRequest).filter(CommunityJoinRequest.user_id == self.id).delete() + db.session.query(CommunityMember).filter(CommunityMember.user_id == self.id).delete() + db.session.query(CommunityBlock).filter(CommunityBlock.user_id == self.id).delete() + db.session.query(CommunityBan).filter(CommunityBan.user_id == self.id).delete() + db.session.query(Community).filter(Community.user_id == self.id).delete() + db.session.query(Post).filter(Post.user_id == self.id).delete() + db.session.query(UserNote).filter(UserNote.user_id == self.id).delete() + db.session.query(UserNote).filter(UserNote.target_id == self.id).delete() + db.session.query(UserFollowRequest).filter(UserFollowRequest.follow_id == self.id).delete() + db.session.query(UserFollowRequest).filter(UserFollowRequest.user_id == self.id).delete() + db.session.query(UserBlock).filter(UserBlock.blocked_id == self.id).delete() + db.session.query(UserBlock).filter(UserBlock.blocker_id == self.id).delete() + db.session.execute(text('DELETE FROM user_role WHERE user_id = :user_id'), + {'user_id': self.id}) + + class ActivityLog(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/static/js/scripts.js b/app/static/js/scripts.js index 85325e94..88bef557 100644 --- a/app/static/js/scripts.js +++ b/app/static/js/scripts.js @@ -2,6 +2,7 @@ document.addEventListener("DOMContentLoaded", function () { setupCommunityNameInput(); setupShowMoreLinks(); + setupConfirmFirst(); }); @@ -11,6 +12,17 @@ window.addEventListener("load", function () { setupHideButtons(); }); +// every element with the 'confirm_first' class gets a popup confirmation dialog +function setupConfirmFirst() { + const show_first = document.querySelectorAll('.confirm_first'); + show_first.forEach(element => { + element.addEventListener("click", function(event) { + if (!confirm("Are you sure?")) { + event.preventDefault(); // As the user clicked "Cancel" in the dialog, prevent the default action. + } + }); + }) +} function setupShowMoreLinks() { const comments = document.querySelectorAll('.comment'); diff --git a/app/templates/base.html b/app/templates/base.html index 5b14e53b..39f3b938 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,3 +1,10 @@ +{% macro render_username(user) %} +{% if user.deleted %} + [deleted] +{% else %} + {{ user.user_name }} +{% endif %} +{% endmacro %} diff --git a/app/templates/community/_post_full.html b/app/templates/community/_post_full.html index 12c9daa2..117cfedc 100644 --- a/app/templates/community/_post_full.html +++ b/app/templates/community/_post_full.html @@ -17,7 +17,7 @@ {% endif %}

{% endif %} -

submitted {{ moment(post.posted_at).fromNow() }} by {{ post.author.user_name }}

+

submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }}

{% if post.url %} @@ -47,7 +47,7 @@

{% endif %}

submitted {{ moment(post.posted_at).fromNow() }} by - {{ post.author.user_name }} + {{ render_username(post.author) }}

{% endif %}
diff --git a/app/templates/community/_post_teaser.html b/app/templates/community/_post_teaser.html index aa13f2db..02b471b1 100644 --- a/app/templates/community/_post_teaser.html +++ b/app/templates/community/_post_teaser.html @@ -1,6 +1,6 @@
-
{{ post.author.user_name }} · {{ moment(post.posted_at).fromNow() }}
+
{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}
diff --git a/app/templates/community/community.html b/app/templates/community/community.html index 73a91880..7e79c7ba 100644 --- a/app/templates/community/community.html +++ b/app/templates/community/community.html @@ -75,7 +75,7 @@

Moderators

    {% for mod in mods %} -
  1. {{ mod.user_name }}
  2. +
  3. {{ render_username(mod) }}
  4. {% endfor %}
{% endif %} diff --git a/app/templates/community/continue_discussion.html b/app/templates/community/continue_discussion.html index d04d5c5c..bed41684 100644 --- a/app/templates/community/continue_discussion.html +++ b/app/templates/community/continue_discussion.html @@ -18,12 +18,16 @@
- {% if comment['comment'].author.avatar_id %} - - Avatar - {% endif %} - - {{ comment['comment'].author.user_name}} + {% if comment['comment'].author.deleted %} + [deleted] + {% else %} + {% if comment['comment'].author.avatar_id %} + + Avatar + {% endif %} + + {{ comment['comment'].author.user_name}} + {% endif %} {% if comment['comment'].author.id == post.author.id%}[S]{% endif %} {{ moment(comment['comment'].posted_at).fromNow(refresh=True) }}
diff --git a/app/templates/community/post.html b/app/templates/community/post.html index f465cfd6..b3f658e7 100644 --- a/app/templates/community/post.html +++ b/app/templates/community/post.html @@ -30,12 +30,16 @@
- {% if comment['comment'].author.avatar_id %} - - Avatar + {% if comment['comment'].author.deleted %} + [deleted] + {% else %} + {% if comment['comment'].author.avatar_id %} + + Avatar + {% endif %} + + {{ comment['comment'].author.user_name }} {% endif %} - - {{ comment['comment'].author.user_name }} {% if comment['comment'].author.id == post.author.id %}[S]{% endif %} {{ moment(comment['comment'].posted_at).fromNow(refresh=True) }}
diff --git a/app/templates/user/show_profile.html b/app/templates/user/show_profile.html index ee5cda92..6ca82907 100644 --- a/app/templates/user/show_profile.html +++ b/app/templates/user/show_profile.html @@ -57,7 +57,7 @@
- {% if current_user.id == user.id %} + {% if current_user.is_authenticated and current_user.id == user.id %}

{{ _('Manage') }}

@@ -83,16 +83,37 @@
    {% for community in moderates %}
  1. - - - {{ community.display_name() }} - + {{ community.display_name() }}
  2. {% endfor %}
{% endif %} + {% if current_user.is_authenticated and (user_access('ban users', current_user.id) or user_access('manage users', current_user.id)) and user.id != current_user.id %} +
+
+

{{ _('Crush') }}

+
+
+
+ {% if user_access('ban users', current_user.id) %} + + {% endif %} + {% if user_access('manage users', current_user.id) %} + + + {% endif %} +
+
+
+ {% endif %}
{% endblock %} diff --git a/app/user/routes.py b/app/user/routes.py index 320a6a8a..2fc33a90 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -6,14 +6,16 @@ from app import db from app.models import Post, Community, CommunityMember, User, PostReply from app.user import bp from app.user.forms import ProfileForm, SettingsForm -from app.utils import get_setting, render_template, markdown_to_html +from app.utils import get_setting, render_template, markdown_to_html, user_access from sqlalchemy import desc, or_ def show_profile(user): + if user.deleted or user.banned and current_user.is_anonymous(): + abort(404) posts = Post.query.filter_by(user_id=user.id).order_by(desc(Post.posted_at)).all() moderates = Community.query.filter_by(banned=False).join(CommunityMember).filter(or_(CommunityMember.is_moderator, CommunityMember.is_owner)) - if user.id != current_user.id: + if current_user.is_anonymous or user.id != current_user.id: moderates = moderates.filter(Community.private_mods == False) post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).all() canonical = user.ap_public_url if user.ap_public_url else None @@ -78,3 +80,80 @@ def change_settings(actor): form.manually_approves_followers.data = current_user.ap_manually_approves_followers return render_template('user/edit_settings.html', title=_('Edit profile'), form=form, user=current_user) + + +@bp.route('/u//ban', methods=['GET']) +def ban_profile(actor): + if user_access('ban users', current_user.id): + 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 ban yourself.', 'error') + else: + user.banned = True + db.session.commit() + + flash(f'{actor} has been banned.') + else: + abort(401) + + return redirect(f'/u/{actor}') + + +@bp.route('/u//delete', methods=['GET']) +def delete_profile(actor): + if user_access('manage users', current_user.id): + 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 delete yourself.', 'error') + else: + user.banned = True + user.deleted = True + db.session.commit() + + flash(f'{actor} has been deleted.') + else: + abort(401) + + return redirect(f'/u/{actor}') + + +@bp.route('/u//ban_purge', methods=['GET']) +def ban_purge_profile(actor): + if user_access('manage users', current_user.id): + 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 purge yourself.', 'error') + else: + user.banned = True + user.deleted = True + db.session.commit() + + user.purge_content() + db.session.delete(user) + db.session.commit() + + # todo: empty relevant caches + # todo: federate deletion + + flash(f'{actor} has been banned, deleted and all their content deleted.') + else: + abort(401) + + return redirect(f'/u/{actor}') diff --git a/app/utils.py b/app/utils.py index a7b6adf4..9f605b51 100644 --- a/app/utils.py +++ b/app/utils.py @@ -8,8 +8,11 @@ from bs4 import BeautifulSoup import requests import os from flask import current_app, json +from flask_login import current_user +from sqlalchemy import text + from app import db, cache -from app.models import Settings, Domain, Instance, BannedInstances +from app.models import Settings, Domain, Instance, BannedInstances, User # Flask's render_template function, with support for themes added @@ -141,7 +144,10 @@ def html_to_markdown_worker(element, indent_level=0): def markdown_to_html(markdown_text) -> str: - return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True)) + if markdown_text: + return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True)) + else: + return '' def domain_from_url(url: str) -> Domain: @@ -167,3 +173,12 @@ def digits(input: int) -> int: return 1 # Special case: 0 has 1 digit else: return math.floor(math.log10(abs(input))) + 1 + + +@cache.memoize(timeout=50) +def user_access(permission: str, user_id: int) -> bool: + has_access = db.session.execute(text('SELECT * FROM "role_permission" as rp ' + + 'INNER JOIN user_role ur on rp.role_id = ur.role_id ' + + 'WHERE ur.user_id = :user_id AND rp.permission = :permission'), + {'user_id': user_id, 'permission': permission}).first() + return has_access is not None \ No newline at end of file diff --git a/migrations/versions/c88bbba381b5_community_creator.py b/migrations/versions/c88bbba381b5_community_creator.py new file mode 100644 index 00000000..0ac67795 --- /dev/null +++ b/migrations/versions/c88bbba381b5_community_creator.py @@ -0,0 +1,34 @@ +"""community creator + +Revision ID: c88bbba381b5 +Revises: 882e33231c5b +Create Date: 2023-10-21 15:32:15.856895 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c88bbba381b5' +down_revision = '882e33231c5b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('community', schema=None) as batch_op: + batch_op.add_column(sa.Column('user_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(None, 'user', ['user_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('community', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('user_id') + + # ### end Alembic commands ### diff --git a/pyfedi.py b/pyfedi.py index dec9e59b..ca38129c 100644 --- a/pyfedi.py +++ b/pyfedi.py @@ -6,7 +6,7 @@ from app import create_app, db, cli import os, click from flask import session, g from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE -from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits +from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access app = create_app() cli.register(app) @@ -29,6 +29,7 @@ with app.app_context(): app.jinja_env.globals['len'] = len app.jinja_env.globals['digits'] = digits app.jinja_env.globals['str'] = str + app.jinja_env.globals['user_access'] = user_access app.jinja_env.filters['shorten'] = shorten_string app.jinja_env.filters['shorten_url'] = shorten_url