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 return resp
else: else:
return show_profile(user) return show_profile(user)
else:
abort(404)
@bp.route('/c/<actor>', methods=['GET']) @bp.route('/c/<actor>', methods=['GET'])
@ -313,6 +315,7 @@ def shared_inbox():
user_ap_id = request_json['object']['actor'] user_ap_id = request_json['object']['actor']
liked_ap_id = request_json['object']['object'] liked_ap_id = request_json['object']['object']
user = find_actor_or_create(user_ap_id) user = find_actor_or_create(user_ap_id)
if user:
vote_weight = 1.0 vote_weight = 1.0
if user.ap_domain: if user.ap_domain:
instance = Instance.query.filter_by(domain=user.ap_domain).fetch() instance = Instance.query.filter_by(domain=user.ap_domain).fetch()
@ -399,6 +402,7 @@ def shared_inbox():
user_ap_id = request_json['object']['actor'] user_ap_id = request_json['object']['actor']
user = find_actor_or_create(user_ap_id) user = find_actor_or_create(user_ap_id)
community = find_actor_or_create(community_ap_id) community = find_actor_or_create(community_ap_id)
if user and community:
join_request = CommunityJoinRequest.query.filter_by(user_id=user.id, join_request = CommunityJoinRequest.query.filter_by(user_id=user.id,
community_id=community.id).first() community_id=community.id).first()
if join_request: if join_request:

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/* ap_profile_id=actor).first() # finds communities formatted like https://localhost/c/*
if current_app.config['SERVER_NAME'] + '/u/' in actor: 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: if user is None:
return None return None
elif actor.startswith('https://'): elif actor.startswith('https://'):
@ -201,6 +201,8 @@ def find_actor_or_create(actor: str) -> Union[User, Community, None]:
return None return None
user = User.query.filter_by( user = User.query.filter_by(
ap_profile_id=actor).first() # finds users formatted like https://kbin.social/u/tables ap_profile_id=actor).first() # finds users formatted like https://kbin.social/u/tables
if user.banned:
return None
if user is None: if user is None:
user = Community.query.filter_by(ap_profile_id=actor).first() user = Community.query.filter_by(ap_profile_id=actor).first()
if user is None: if user is None:

View file

@ -48,4 +48,4 @@ class ResetPasswordForm(FlaskForm):
password2 = PasswordField( password2 = PasswordField(
_l('Repeat password'), validators=[DataRequired(), _l('Repeat password'), validators=[DataRequired(),
EqualTo('password')]) 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 = Role(name='Staff', weight=2)
staff_role.permissions.append(RolePermission(permission='approve registrations')) 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) db.session.add(staff_role)
admin_role = Role(name='Admin', weight=3) 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='change user roles'))
admin_role.permissions.append(RolePermission(permission='ban users'))
admin_role.permissions.append(RolePermission(permission='manage users')) admin_role.permissions.append(RolePermission(permission='manage users'))
db.session.add(admin_role) db.session.add(admin_role)

View file

@ -3,9 +3,9 @@ from hashlib import md5
from time import time from time import time
from typing import List from typing import List
from flask import current_app, escape from flask import current_app, escape, url_for
from flask_login import UserMixin 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 werkzeug.security import generate_password_hash, check_password_hash
from flask_babel import _, lazy_gettext as _l from flask_babel import _, lazy_gettext as _l
from sqlalchemy.orm import backref from sqlalchemy.orm import backref
@ -38,6 +38,7 @@ class Community(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
icon_id = db.Column(db.Integer, db.ForeignKey('file.id')) icon_id = db.Column(db.Integer, db.ForeignKey('file.id'))
image_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) name = db.Column(db.String(256), index=True)
title = db.Column(db.String(256)) title = db.Column(db.String(256))
description = db.Column(db.Text) description = db.Column(db.Text)
@ -185,6 +186,12 @@ class User(UserMixin, db.Model):
except Exception: except Exception:
return False return False
def display_name(self):
if self.deleted is False:
return self.user_name
else:
return '[deleted]'
def avatar(self, size): def avatar(self, size):
digest = md5(self.email.lower().encode('utf-8')).hexdigest() digest = md5(self.email.lower().encode('utf-8')).hexdigest()
return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format( return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format(
@ -266,6 +273,30 @@ class User(UserMixin, db.Model):
return return
return User.query.get(id) 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): class ActivityLog(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View file

@ -2,6 +2,7 @@
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
setupCommunityNameInput(); setupCommunityNameInput();
setupShowMoreLinks(); setupShowMoreLinks();
setupConfirmFirst();
}); });
@ -11,6 +12,17 @@ window.addEventListener("load", function () {
setupHideButtons(); 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() { function setupShowMoreLinks() {
const comments = document.querySelectorAll('.comment'); 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> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>

View file

@ -17,7 +17,7 @@
{% endif %} {% endif %}
</small></p> </small></p>
{% endif %} {% 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>
<div class="col-4"> <div class="col-4">
{% if post.url %} {% if post.url %}
@ -47,7 +47,7 @@
</small></p> </small></p>
{% endif %} {% endif %}
<p class="small">submitted {{ moment(post.posted_at).fromNow() }} by <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> </p>
{% endif %} {% endif %}
</div> </div>

View file

@ -1,6 +1,6 @@
<div class="post_teaser"> <div class="post_teaser">
<div class="row meta_row small"> <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>
<div class="row main_row"> <div class="row main_row">
<div class="col{% if post.image_id %}-8{% endif %}"> <div class="col{% if post.image_id %}-8{% endif %}">

View file

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

View file

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

View file

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

View file

@ -57,7 +57,7 @@
</div> </div>
<div class="col-4"> <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 mt-3">
<div class="card-header"> <div class="card-header">
<h2>{{ _('Manage') }}</h2> <h2>{{ _('Manage') }}</h2>
@ -83,16 +83,37 @@
<ol> <ol>
{% for community in moderates %} {% for community in moderates %}
<li> <li>
<a href="/c/{{ community.link() }}"> <a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />{{ community.display_name() }}</a>
<img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />
{{ community.display_name() }}
</a>
</li> </li>
{% endfor %} {% endfor %}
</ol> </ol>
</div> </div>
</div> </div>
{% endif %} {% 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>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -6,14 +6,16 @@ from app import db
from app.models import Post, Community, CommunityMember, User, PostReply from app.models import Post, Community, CommunityMember, User, PostReply
from app.user import bp from app.user import bp
from app.user.forms import ProfileForm, SettingsForm 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_ from sqlalchemy import desc, or_
def show_profile(user): 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() 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)) 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) moderates = moderates.filter(Community.private_mods == False)
post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).all() 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 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 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) 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 requests
import os import os
from flask import current_app, json from flask import current_app, json
from flask_login import current_user
from sqlalchemy import text
from app import db, cache 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 # 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: def markdown_to_html(markdown_text) -> str:
if markdown_text:
return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True)) return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True))
else:
return ''
def domain_from_url(url: str) -> Domain: def domain_from_url(url: str) -> Domain:
@ -167,3 +173,12 @@ def digits(input: int) -> int:
return 1 # Special case: 0 has 1 digit return 1 # Special case: 0 has 1 digit
else: else:
return math.floor(math.log10(abs(input))) + 1 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 import os, click
from flask import session, g from flask import session, g
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE 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() app = create_app()
cli.register(app) cli.register(app)
@ -29,6 +29,7 @@ with app.app_context():
app.jinja_env.globals['len'] = len app.jinja_env.globals['len'] = len
app.jinja_env.globals['digits'] = digits app.jinja_env.globals['digits'] = digits
app.jinja_env.globals['str'] = str 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'] = shorten_string
app.jinja_env.filters['shorten_url'] = shorten_url app.jinja_env.filters['shorten_url'] = shorten_url