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 @@
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 %}
- - {{ mod.user_name }}
+ - {{ render_username(mod) }}
{% 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 @@
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 @@
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 %}
{% 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 %}
+
+
+
+
+ {% 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