user banning and purging

This commit is contained in:
rimu 2023-10-21 15:49:01 +13:00
parent 4b916fcf86
commit 56d09b264a
17 changed files with 285 additions and 69 deletions

View file

@ -138,6 +138,8 @@ def user_profile(actor):
return resp
else:
return show_profile(user)
else:
abort(404)
@bp.route('/c/<actor>', 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:

View file

@ -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:

View file

@ -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'))

View file

@ -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)

View file

@ -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)

View file

@ -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');

View file

@ -1,3 +1,10 @@
{% macro render_username(user) %}
{% if user.deleted %}
[deleted]
{% else %}
<a href="{{ url_for('activitypub.user_profile', actor=user.user_name) }}">{{ user.user_name }}</a>
{% endif %}
{% endmacro %}
<!doctype html>
<html lang="en">
<head>

View file

@ -17,7 +17,7 @@
{% endif %}
</small></p>
{% endif %}
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ post.author.user_name }}</small></p>
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }}</small></p>
</div>
<div class="col-4">
{% if post.url %}
@ -47,7 +47,7 @@
</small></p>
{% endif %}
<p class="small">submitted {{ moment(post.posted_at).fromNow() }} by
<a href="{{ url_for('activitypub.user_profile', actor=post.author.user_name) }}">{{ post.author.user_name }}</a>
{{ render_username(post.author) }}
</p>
{% endif %}
</div>

View file

@ -1,6 +1,6 @@
<div class="post_teaser">
<div class="row meta_row small">
<div class="col"><a href="{{ url_for('activitypub.user_profile', actor=post.author.user_name) }}">{{ post.author.user_name }}</a> · {{ moment(post.posted_at).fromNow() }}</div>
<div class="col">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</div>
</div>
<div class="row main_row">
<div class="col{% if post.image_id %}-8{% endif %}">

View file

@ -75,7 +75,7 @@
<h3>Moderators</h3>
<ol>
{% for mod in mods %}
<li><a href="/u/{{ mod.user_name }}">{{ mod.user_name }}</a></li>
<li>{{ render_username(mod) }}</li>
{% endfor %}
</ol>
{% endif %}

View file

@ -18,12 +18,16 @@
</div>
<div class="hide_button"><a href='#'>[-] hide</a></div>
<div class="comment_author">
{% if comment['comment'].author.avatar_id %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}">
<img src="{{ comment['comment'].author.avatar_image() }}" alt="Avatar" /></a>
{% endif %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}">
<strong>{{ comment['comment'].author.user_name}}</strong></a>
{% if comment['comment'].author.deleted %}
<strong>[deleted]</strong>
{% else %}
{% if comment['comment'].author.avatar_id %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}">
<img src="{{ comment['comment'].author.avatar_image() }}" alt="Avatar" /></a>
{% endif %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}">
<strong>{{ comment['comment'].author.user_name}}</strong></a>
{% endif %}
{% if comment['comment'].author.id == post.author.id%}<span title="Submitter of original post" aria-label="submitter">[S]</span>{% endif %}
<span class="text-muted small">{{ moment(comment['comment'].posted_at).fromNow(refresh=True) }}</span>
</div>

View file

@ -30,12 +30,16 @@
</div>
<div class="hide_button"><a href='#'>[-] hide</a></div>
<div class="comment_author">
{% if comment['comment'].author.avatar_id %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}">
<img src="{{ comment['comment'].author.avatar_image() }}" alt="Avatar" /></a>
{% if comment['comment'].author.deleted %}
<strong>[deleted]</strong>
{% else %}
{% if comment['comment'].author.avatar_id %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}">
<img src="{{ comment['comment'].author.avatar_image() }}" alt="Avatar" /></a>
{% endif %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}">
<strong>{{ comment['comment'].author.user_name }}</strong></a>
{% endif %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}">
<strong>{{ comment['comment'].author.user_name }}</strong></a>
{% if comment['comment'].author.id == post.author.id %}<span title="Submitter of original post" aria-label="submitter">[S]</span>{% endif %}
<span class="text-muted small">{{ moment(comment['comment'].posted_at).fromNow(refresh=True) }}</span>
</div>

View file

@ -57,7 +57,7 @@
</div>
<div class="col-4">
{% if current_user.id == user.id %}
{% if current_user.is_authenticated and current_user.id == user.id %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Manage') }}</h2>
@ -83,16 +83,37 @@
<ol>
{% for community in moderates %}
<li>
<a href="/c/{{ community.link() }}">
<img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />
{{ community.display_name() }}
</a>
<a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />{{ community.display_name() }}</a>
</li>
{% endfor %}
</ol>
</div>
</div>
{% 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 %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Crush') }}</h2>
</div>
<div class="card-body">
<div class="row">
{% if user_access('ban users', current_user.id) %}
<div class="col-4">
<a class="w-100 btn btn-primary confirm_first" href="/u/{{ user.user_name }}/ban">{{ _('Ban') }}</a>
</div>
{% endif %}
{% if user_access('manage users', current_user.id) %}
<div class="col-4">
<a class="w-100 btn btn-primary confirm_first" href="/u/{{ user.user_name }}/delete">{{ _('Delete') }}</a>
</div>
<div class="col-4">
<a class="w-100 btn btn-primary confirm_first" href="/u/{{ user.user_name }}/ban_purge">{{ _('Ban + Purge') }}</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -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/<actor>/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/<actor>/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/<actor>/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}')

View file

@ -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

View file

@ -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 ###

View file

@ -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