mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 11:26:56 -08:00
admin users
This commit is contained in:
parent
83eaf6d883
commit
8c622c04c7
28 changed files with 422 additions and 93 deletions
|
@ -18,7 +18,7 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti
|
|||
user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \
|
||||
update_post_from_activity, undo_vote, undo_downvote
|
||||
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \
|
||||
domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text
|
||||
domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text, ip_address
|
||||
import werkzeug.exceptions
|
||||
|
||||
|
||||
|
@ -162,6 +162,7 @@ def user_profile(actor):
|
|||
"type": "Person",
|
||||
"id": f"https://{server}/u/{actor}",
|
||||
"preferredUsername": actor,
|
||||
"name": user.title if user.title else user.user_name,
|
||||
"inbox": f"https://{server}/u/{actor}/inbox",
|
||||
"outbox": f"https://{server}/u/{actor}/outbox",
|
||||
"discoverable": user.searchable,
|
||||
|
@ -309,9 +310,9 @@ def shared_inbox():
|
|||
# When a user is deleted, the only way to be fairly sure they get deleted everywhere is to tell the whole fediverse.
|
||||
if 'type' in request_json and request_json['type'] == 'Delete' and request_json['id'].endswith('#delete'):
|
||||
if current_app.debug:
|
||||
process_delete_request(request_json, activity_log.id)
|
||||
process_delete_request(request_json, activity_log.id, ip_address())
|
||||
else:
|
||||
process_delete_request.delay(request_json, activity_log.id)
|
||||
process_delete_request.delay(request_json, activity_log.id, ip_address())
|
||||
return ''
|
||||
else:
|
||||
activity_log.activity_id = ''
|
||||
|
@ -323,9 +324,9 @@ def shared_inbox():
|
|||
if actor is not None:
|
||||
if HttpSignature.verify_request(request, actor.public_key, skip_date=True):
|
||||
if current_app.debug:
|
||||
process_inbox_request(request_json, activity_log.id)
|
||||
process_inbox_request(request_json, activity_log.id, ip_address())
|
||||
else:
|
||||
process_inbox_request.delay(request_json, activity_log.id)
|
||||
process_inbox_request.delay(request_json, activity_log.id, ip_address())
|
||||
return ''
|
||||
else:
|
||||
activity_log.exception_message = 'Could not verify signature'
|
||||
|
@ -340,7 +341,7 @@ def shared_inbox():
|
|||
|
||||
|
||||
@celery.task
|
||||
def process_inbox_request(request_json, activitypublog_id):
|
||||
def process_inbox_request(request_json, activitypublog_id, ip_address):
|
||||
with current_app.app_context():
|
||||
activity_log = ActivityPubLog.query.get(activitypublog_id)
|
||||
site = Site.query.get(1) # can't use g.site because celery doesn't use Flask's g variable
|
||||
|
@ -667,6 +668,7 @@ def process_inbox_request(request_json, activitypublog_id):
|
|||
user.flush_cache()
|
||||
if user.instance_id:
|
||||
user.instance.last_seen = utcnow()
|
||||
user.instance.ip_address = ip_address
|
||||
# if 'community' in vars() and community is not None:
|
||||
# community.flush_cache()
|
||||
if 'post' in vars() and post is not None:
|
||||
|
@ -680,7 +682,7 @@ def process_inbox_request(request_json, activitypublog_id):
|
|||
|
||||
|
||||
@celery.task
|
||||
def process_delete_request(request_json, activitypublog_id):
|
||||
def process_delete_request(request_json, activitypublog_id, ip_address):
|
||||
with current_app.app_context():
|
||||
activity_log = ActivityPubLog.query.get(activitypublog_id)
|
||||
if 'type' in request_json and request_json['type'] == 'Delete':
|
||||
|
@ -688,37 +690,25 @@ def process_delete_request(request_json, activitypublog_id):
|
|||
user = User.query.filter_by(ap_profile_id=actor_to_delete).first()
|
||||
if user:
|
||||
# check that the user really has been deleted, to avoid spoofing attacks
|
||||
if not user.is_local() and user_removed_from_remote_server(actor_to_delete, user.instance.software == 'PieFed'):
|
||||
# Delete all their images to save moderators from having to see disgusting stuff.
|
||||
files = File.query.join(Post).filter(Post.user_id == user.id).all()
|
||||
for file in files:
|
||||
file.delete_from_disk()
|
||||
file.source_url = ''
|
||||
if user.avatar_id:
|
||||
user.avatar.delete_from_disk()
|
||||
user.avatar.source_url = ''
|
||||
if user.cover_id:
|
||||
user.cover.delete_from_disk()
|
||||
user.cover.source_url = ''
|
||||
user.banned = True
|
||||
user.deleted = True
|
||||
activity_log.result = 'success'
|
||||
|
||||
instances = Instance.query.all()
|
||||
site = Site.query.get(1)
|
||||
payload = {
|
||||
"@context": default_context(),
|
||||
"actor": user.ap_profile_id,
|
||||
"id": f"{user.ap_profile_id}#delete",
|
||||
"object": user.ap_profile_id,
|
||||
"to": [
|
||||
"https://www.w3.org/ns/activitystreams#Public"
|
||||
],
|
||||
"type": "Delete"
|
||||
}
|
||||
for instance in instances:
|
||||
if instance.inbox:
|
||||
post_request(instance.inbox, payload, site.private_key, f"https://{current_app.config['SERVER_NAME']}#main-key")
|
||||
if not user.is_local():
|
||||
if user_removed_from_remote_server(actor_to_delete, is_piefed=user.instance.software == 'PieFed'):
|
||||
# Delete all their images to save moderators from having to see disgusting stuff.
|
||||
files = File.query.join(Post).filter(Post.user_id == user.id).all()
|
||||
for file in files:
|
||||
file.delete_from_disk()
|
||||
file.source_url = ''
|
||||
if user.avatar_id:
|
||||
user.avatar.delete_from_disk()
|
||||
user.avatar.source_url = ''
|
||||
if user.cover_id:
|
||||
user.cover.delete_from_disk()
|
||||
user.cover.source_url = ''
|
||||
user.banned = True
|
||||
user.deleted = True
|
||||
activity_log.result = 'success'
|
||||
else:
|
||||
activity_log.result = 'ignored'
|
||||
activity_log.exception_message = 'User not actually deleted.'
|
||||
else:
|
||||
activity_log.result = 'ignored'
|
||||
activity_log.exception_message = 'Only remote users can be deleted remotely'
|
||||
|
|
|
@ -322,6 +322,7 @@ def refresh_user_profile_task(user_id):
|
|||
def actor_json_to_model(activity_json, address, server):
|
||||
if activity_json['type'] == 'Person':
|
||||
user = User(user_name=activity_json['preferredUsername'],
|
||||
title=activity_json['name'] if 'name' in activity_json else None,
|
||||
email=f"{address}@{server}",
|
||||
about_html=parse_summary(activity_json),
|
||||
matrix_user_id=activity_json['matrixUserId'] if 'matrixUserId' in activity_json else '',
|
||||
|
|
|
@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
|
|||
from flask_wtf.file import FileRequired, FileAllowed
|
||||
from wtforms import StringField, PasswordField, SubmitField, HiddenField, BooleanField, TextAreaField, SelectField, \
|
||||
FileField, IntegerField
|
||||
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length
|
||||
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
|
||||
from flask_babel import _, lazy_gettext as _l
|
||||
|
||||
|
||||
|
@ -76,4 +76,20 @@ class EditCommunityForm(FlaskForm):
|
|||
if '-' in self.url.data.strip():
|
||||
self.url.errors.append(_('- cannot be in Url. Use _ instead?'))
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
class EditUserForm(FlaskForm):
|
||||
about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)])
|
||||
matrix_user_id = StringField(_l('Matrix User ID'), validators=[Optional(), Length(max=255)])
|
||||
profile_file = FileField(_('Avatar image'))
|
||||
banner_file = FileField(_('Top banner image'))
|
||||
bot = BooleanField(_l('This profile is a bot'))
|
||||
newsletter = BooleanField(_l('Subscribe to email newsletter'))
|
||||
ignore_bots = BooleanField(_l('Hide posts by bots'))
|
||||
nsfw = BooleanField(_l('Show NSFW posts'))
|
||||
nsfl = BooleanField(_l('Show NSFL posts'))
|
||||
searchable = BooleanField(_l('Show profile in user list'))
|
||||
indexable = BooleanField(_l('Allow search engines to index this profile'))
|
||||
manually_approves_followers = BooleanField(_l('Manually approve followers'))
|
||||
submit = SubmitField(_l('Save'))
|
||||
|
|
|
@ -9,10 +9,12 @@ from sqlalchemy import text, desc
|
|||
from app import db, celery
|
||||
from app.activitypub.routes import process_inbox_request, process_delete_request
|
||||
from app.activitypub.signature import post_request
|
||||
from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm
|
||||
from app.activitypub.util import default_context
|
||||
from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm, EditUserForm
|
||||
from app.community.util import save_icon_file, save_banner_file
|
||||
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, User
|
||||
from app.utils import render_template, permission_required, set_setting, get_setting, gibberish
|
||||
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \
|
||||
User, Instance, File
|
||||
from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html
|
||||
from app.admin import bp
|
||||
|
||||
|
||||
|
@ -157,9 +159,9 @@ def activity_replay(activity_id):
|
|||
activity = ActivityPubLog.query.get_or_404(activity_id)
|
||||
request_json = json.loads(activity.activity_json)
|
||||
if 'type' in request_json and request_json['type'] == 'Delete' and request_json['id'].endswith('#delete'):
|
||||
process_delete_request(request_json, activity.id)
|
||||
process_delete_request(request_json, activity.id, None)
|
||||
else:
|
||||
process_inbox_request(request_json, activity.id)
|
||||
process_inbox_request(request_json, activity.id, None)
|
||||
return 'Ok'
|
||||
|
||||
|
||||
|
@ -259,27 +261,177 @@ def unsubscribe_everyone_then_delete_task(community_id):
|
|||
members = CommunityMember.query.filter_by(community_id=community_id).all()
|
||||
for member in members:
|
||||
user = User.query.get(member.user_id)
|
||||
undo_id = f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15)
|
||||
follow = {
|
||||
"actor": f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}",
|
||||
"to": [community.ap_profile_id],
|
||||
"object": community.ap_profile_id,
|
||||
"type": "Follow",
|
||||
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}"
|
||||
}
|
||||
undo = {
|
||||
'actor': user.profile_id(),
|
||||
'to': [community.ap_profile_id],
|
||||
'type': 'Undo',
|
||||
'id': undo_id,
|
||||
'object': follow
|
||||
}
|
||||
activity = ActivityPubLog(direction='out', activity_id=undo_id, activity_type='Undo', activity_json=json.dumps(undo), result='processing')
|
||||
db.session.add(activity)
|
||||
db.session.commit()
|
||||
post_request(community.ap_inbox_url, undo, user.private_key, user.profile_id() + '#main-key')
|
||||
activity.result = 'success'
|
||||
db.session.commit()
|
||||
unsubscribe_from_community(community, user)
|
||||
else:
|
||||
# todo: federate delete of local community out to all following instances
|
||||
...
|
||||
|
||||
sleep(5)
|
||||
community.delete_dependencies()
|
||||
db.session.delete(community) # todo: when a remote community is deleted it will be able to be re-created by using the 'Add remote' function. Not ideal. Consider soft-delete.
|
||||
|
||||
|
||||
@bp.route('/users', methods=['GET'])
|
||||
@login_required
|
||||
@permission_required('administer all users')
|
||||
def admin_users():
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
|
||||
users = User.query.filter_by(deleted=False).order_by(User.user_name).paginate(page=page, per_page=1000, error_out=False)
|
||||
|
||||
next_url = url_for('admin.admin_users', page=users.next_num) if users.has_next else None
|
||||
prev_url = url_for('admin.admin_users', page=users.prev_num) if users.has_prev and page != 1 else None
|
||||
|
||||
return render_template('admin/users.html', title=_('Users'), next_url=next_url, prev_url=prev_url, users=users)
|
||||
|
||||
|
||||
@bp.route('/user/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('administer all users')
|
||||
def admin_user_edit(user_id):
|
||||
form = EditUserForm()
|
||||
user = User.query.get_or_404(user_id)
|
||||
if form.validate_on_submit():
|
||||
user.about = form.about.data
|
||||
user.about_html = markdown_to_html(form.about.data)
|
||||
user.matrix_user_id = form.matrix_user_id.data
|
||||
user.bot = form.bot.data
|
||||
profile_file = request.files['profile_file']
|
||||
if profile_file and profile_file.filename != '':
|
||||
# remove old avatar
|
||||
file = File.query.get(user.avatar_id)
|
||||
file.delete_from_disk()
|
||||
user.avatar_id = None
|
||||
db.session.delete(file)
|
||||
|
||||
# add new avatar
|
||||
file = save_icon_file(profile_file, 'users')
|
||||
if file:
|
||||
user.avatar = file
|
||||
banner_file = request.files['banner_file']
|
||||
if banner_file and banner_file.filename != '':
|
||||
# remove old cover
|
||||
file = File.query.get(user.cover_id)
|
||||
file.delete_from_disk()
|
||||
user.cover_id = None
|
||||
db.session.delete(file)
|
||||
|
||||
# add new cover
|
||||
file = save_banner_file(banner_file, 'users')
|
||||
if file:
|
||||
user.cover = file
|
||||
user.newsletter = form.newsletter.data
|
||||
user.ignore_bots = form.ignore_bots.data
|
||||
user.show_nsfw = form.nsfw.data
|
||||
user.show_nsfl = form.nsfl.data
|
||||
user.searchable = form.searchable.data
|
||||
user.indexable = form.indexable.data
|
||||
user.ap_manually_approves_followers = form.manually_approves_followers.data
|
||||
db.session.commit()
|
||||
user.flush_cache()
|
||||
flash(_('Saved'))
|
||||
return redirect(url_for('admin.admin_users'))
|
||||
else:
|
||||
if not user.is_local():
|
||||
flash(_('This is a remote user - most settings here will be regularly overwritten with data from the original server.'), 'warning')
|
||||
form.about.data = user.about
|
||||
form.matrix_user_id.data = user.matrix_user_id
|
||||
form.newsletter.data = user.newsletter
|
||||
form.bot.data = user.bot
|
||||
form.ignore_bots.data = user.ignore_bots
|
||||
form.nsfw.data = user.show_nsfw
|
||||
form.nsfl.data = user.show_nsfl
|
||||
form.searchable.data = user.searchable
|
||||
form.indexable.data = user.indexable
|
||||
form.manually_approves_followers.data = user.ap_manually_approves_followers
|
||||
|
||||
return render_template('admin/edit_user.html', title=_('Edit user'), form=form, user=user)
|
||||
|
||||
|
||||
@bp.route('/user/<int:user_id>/delete', methods=['GET'])
|
||||
@login_required
|
||||
@permission_required('administer all users')
|
||||
def admin_user_delete(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
user.banned = True # Unsubscribing everyone could take a long time so until that is completed hide this user from the UI by banning it.
|
||||
user.last_active = utcnow()
|
||||
db.session.commit()
|
||||
|
||||
if user.is_local():
|
||||
unsubscribe_from_everything_then_delete(user.id)
|
||||
else:
|
||||
user.deleted = True
|
||||
user.delete_dependencies()
|
||||
db.session.commit()
|
||||
|
||||
flash(_('User deleted'))
|
||||
return redirect(url_for('admin.admin_users'))
|
||||
|
||||
|
||||
def unsubscribe_from_everything_then_delete(user_id):
|
||||
if current_app.debug:
|
||||
unsubscribe_from_everything_then_delete_task(user_id)
|
||||
else:
|
||||
unsubscribe_from_everything_then_delete_task.delay(user_id)
|
||||
|
||||
|
||||
@celery.task
|
||||
def unsubscribe_from_everything_then_delete_task(user_id):
|
||||
user = User.query.get(user_id)
|
||||
if user:
|
||||
|
||||
# unsubscribe
|
||||
communities = CommunityMember.query.filter_by(user_id=user_id).all()
|
||||
for membership in communities:
|
||||
community = Community.query.get(membership.community_id)
|
||||
unsubscribe_from_community(community, user)
|
||||
|
||||
# federate deletion of account
|
||||
if user.is_local():
|
||||
instances = Instance.query.all()
|
||||
site = Site.query.get(1)
|
||||
payload = {
|
||||
"@context": default_context(),
|
||||
"actor": user.ap_profile_id,
|
||||
"id": f"{user.ap_profile_id}#delete",
|
||||
"object": user.ap_profile_id,
|
||||
"to": [
|
||||
"https://www.w3.org/ns/activitystreams#Public"
|
||||
],
|
||||
"type": "Delete"
|
||||
}
|
||||
for instance in instances:
|
||||
if instance.inbox and instance.id != 1:
|
||||
post_request(instance.inbox, payload, site.private_key,
|
||||
f"https://{current_app.config['SERVER_NAME']}#main-key")
|
||||
|
||||
user.deleted = True
|
||||
user.delete_dependencies()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def unsubscribe_from_community(community, user):
|
||||
undo_id = f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15)
|
||||
follow = {
|
||||
"actor": f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}",
|
||||
"to": [community.ap_profile_id],
|
||||
"object": community.ap_profile_id,
|
||||
"type": "Follow",
|
||||
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}"
|
||||
}
|
||||
undo = {
|
||||
'actor': user.profile_id(),
|
||||
'to': [community.ap_profile_id],
|
||||
'type': 'Undo',
|
||||
'id': undo_id,
|
||||
'object': follow
|
||||
}
|
||||
activity = ActivityPubLog(direction='out', activity_id=undo_id, activity_type='Undo',
|
||||
activity_json=json.dumps(undo), result='processing')
|
||||
db.session.add(activity)
|
||||
db.session.commit()
|
||||
post_request(community.ap_inbox_url, undo, user.private_key, user.profile_id() + '#main-key')
|
||||
activity.result = 'success'
|
||||
db.session.commit()
|
||||
|
|
|
@ -91,7 +91,7 @@ def register():
|
|||
else:
|
||||
verification_token = random_token(16)
|
||||
form.user_name.data = form.user_name.data.strip()
|
||||
user = User(user_name=form.user_name.data, email=form.real_email.data,
|
||||
user = User(user_name=form.user_name.data, title=form.user_name.data, email=form.real_email.data,
|
||||
verification_token=verification_token, instance=1, ipaddress=ip_address(),
|
||||
banned=user_ip_banned() or user_cookie_banned())
|
||||
user.set_password(form.password.data)
|
||||
|
|
|
@ -235,6 +235,7 @@ class User(UserMixin, db.Model):
|
|||
query_class = FullTextSearchQuery
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_name = db.Column(db.String(255), index=True)
|
||||
title = db.Column(db.String(256))
|
||||
email = db.Column(db.String(255), index=True)
|
||||
password_hash = db.Column(db.String(128))
|
||||
verified = db.Column(db.Boolean, default=False)
|
||||
|
@ -305,7 +306,10 @@ class User(UserMixin, db.Model):
|
|||
|
||||
def display_name(self):
|
||||
if self.deleted is False:
|
||||
return self.user_name
|
||||
if self.title:
|
||||
return self.title
|
||||
else:
|
||||
return self.user_name
|
||||
else:
|
||||
return '[deleted]'
|
||||
|
||||
|
@ -469,10 +473,23 @@ class User(UserMixin, db.Model):
|
|||
cache.delete('/u/' + self.user_name + '_False')
|
||||
cache.delete('/u/' + self.user_name + '_True')
|
||||
|
||||
def delete_dependencies(self):
|
||||
if self.cover_id:
|
||||
file = File.query.get(self.cover_id)
|
||||
file.delete_from_disk()
|
||||
self.cover_id = None
|
||||
db.session.delete(file)
|
||||
if self.avatar_id:
|
||||
file = File.query.get(self.avatar_id)
|
||||
file.delete_from_disk()
|
||||
self.avatar_id = None
|
||||
db.session.delete(file)
|
||||
|
||||
def purge_content(self):
|
||||
files = File.query.join(Post).filter(Post.user_id == self.id).all()
|
||||
for file in files:
|
||||
file.delete_from_disk()
|
||||
self.delete_dependencies()
|
||||
db.session.query(Report).filter(Report.reporter_id == self.id).delete()
|
||||
db.session.query(Report).filter(Report.suspect_user_id == self.id).delete()
|
||||
db.session.query(ActivityLog).filter(ActivityLog.user_id == self.id).delete()
|
||||
|
|
|
@ -513,10 +513,6 @@ nav.navbar {
|
|||
color: #777;
|
||||
}
|
||||
|
||||
.comment_author a {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.low_score .hide_button a, .low_score .comment_author a {
|
||||
font-weight: normal;
|
||||
color: #777;
|
||||
|
|
|
@ -186,7 +186,7 @@ nav.navbar {
|
|||
|
||||
.comment_author {
|
||||
a {
|
||||
font-weight: bold;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<a href="{{ url_for('admin.admin_site') }}">{{ _('Site profile') }}</a> |
|
||||
<a href="{{ url_for('admin.admin_misc') }}">{{ _('Misc settings') }}</a> |
|
||||
<a href="{{ url_for('admin.admin_communities') }}">{{ _('Communities') }}</a> |
|
||||
<a href="{{ url_for('admin.admin_users') }}">{{ _('Users') }}</a> |
|
||||
<a href="{{ url_for('admin.admin_federation') }}">{{ _('Federation') }}</a> |
|
||||
<a href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a>
|
||||
</nav>
|
||||
|
|
46
app/templates/admin/edit_user.html
Normal file
46
app/templates/admin/edit_user.html
Normal file
|
@ -0,0 +1,46 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'bootstrap/form.html' import render_field %}
|
||||
|
||||
{% block app_content %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% include 'admin/_nav.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-login mx-auto">
|
||||
<h3>{{ _('Edit %(user_name)s (%(display_name)s)', user_name=user.user_name, display_name=user.display_name()) }}</h3>
|
||||
<form method="post" enctype="multipart/form-data" id="add_local_user_form">
|
||||
{{ form.csrf_token() }}
|
||||
{{ render_field(form.about) }}
|
||||
{{ render_field(form.matrix_user_id) }}
|
||||
{% if user.avatar_id %}
|
||||
<img class="user_icon_big rounded-circle" src="{{ user.avatar_image() }}" width="120" height="120" />
|
||||
{% endif %}
|
||||
{{ render_field(form.profile_file) }}
|
||||
<small class="field_hint">Provide a square image that looks good when small.</small>
|
||||
{% if user.cover_id %}
|
||||
<a href="{{ user.cover_image() }}"><img class="user_icon_big" src="{{ user.cover_image() }}" style="width: 300px; height: auto;" /></a>
|
||||
{% endif %}
|
||||
{{ render_field(form.banner_file) }}
|
||||
<small class="field_hint">Provide a wide image - letterbox orientation.</small>
|
||||
{{ render_field(form.bot) }}
|
||||
{{ render_field(form.newsletter) }}
|
||||
{{ render_field(form.nsfw) }}
|
||||
{{ render_field(form.nsfl) }}
|
||||
{{ render_field(form.searchable) }}
|
||||
{{ render_field(form.indexable) }}
|
||||
{{ render_field(form.manually_approves_followers) }}
|
||||
{{ render_field(form.submit) }}
|
||||
</form>
|
||||
<p class="mt-4">
|
||||
{% if not user.is_local() %}
|
||||
<a href="{{ user.profile_id() }}" class="btn btn-primary">View original profile</a>
|
||||
{% endif %}
|
||||
<a href="" class="btn btn-warning confirm_first">Ban</a>
|
||||
<a href="" class="btn btn-warning confirm_first">Ban + Purge</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
60
app/templates/admin/users.html
Normal file
60
app/templates/admin/users.html
Normal file
|
@ -0,0 +1,60 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'bootstrap/form.html' import render_form %}
|
||||
|
||||
{% block app_content %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% include 'admin/_nav.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<form method="get">
|
||||
<input type="search" name="search">
|
||||
<input type="radio" name="local_remote" value="local" id="local_remote_local"><label for="local_remote_local"> Local</label>
|
||||
<input type="radio" name="local_remote" value="remote" id="local_remote_remote"><label for="local_remote_remote"> Remote</label>
|
||||
<input type="submit" name="submit" value="Search" class="btn btn-primary">
|
||||
</form>
|
||||
<table class="table table-striped">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Local/Remote</th>
|
||||
<th>Attitude</th>
|
||||
<th>Banned</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td><img src="{{ user.avatar_thumbnail() }}" class="community_icon rounded-circle" loading="lazy" />
|
||||
{{ user.display_name() }}</td>
|
||||
<td>{{ 'Local' if user.is_local() else 'Remote' }}</td>
|
||||
<td>{{ user.attitude * 100 }}</td>
|
||||
<td>{{ '<span class="red">Banned</span>'|safe if user.banned }} </td>
|
||||
<td><a href="/u/{{ user.link() }}">View local</a> |
|
||||
{% if not user.is_local() %}
|
||||
<a href="{{ user.ap_profile_id }}">View remote</a> |
|
||||
{% else %}
|
||||
View remote |
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.admin_user_edit', user_id=user.id) }}">Edit</a> |
|
||||
<a href="{{ url_for('admin.admin_user_delete', user_id=user.id) }}" class="confirm_first">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<nav aria-label="Pagination" class="mt-4">
|
||||
{% if prev_url %}
|
||||
<a href="{{ prev_url }}" class="btn btn-primary">
|
||||
<span aria-hidden="true">←</span> {{ _('Previous page') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if next_url %}
|
||||
<a href="{{ next_url }}" class="btn btn-primary">
|
||||
{{ _('Next page') }} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -7,7 +7,7 @@
|
|||
<a href="/u/{{ user.link() }}" title="{{ user.ap_id if user.ap_id != none else user.user_name }}">
|
||||
<img src="{{ user.avatar_thumbnail() }}" alt="Avatar" /></a>
|
||||
{% endif %}
|
||||
<a href="/u/{{ user.link() }}" title="{{ user.ap_id if user.ap_id != none else user.user_name }}">{{ user.user_name }}</a>
|
||||
<a href="/u/{{ user.link() }}" title="{{ user.ap_id if user.ap_id != none else user.user_name }}">{{ user.display_name() }}</a>
|
||||
{% if user.created_recently() %}
|
||||
<span class="fe fe-new-account" title="New account"> </span>
|
||||
{% endif %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<p>Hi {{ user.user_name }},</p>
|
||||
<p>Hi {{ user.display_name() }},</p>
|
||||
<p>
|
||||
To reset your password
|
||||
<a href="{{ url_for('auth.reset_password', token=token, _external=True) }}">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
Hello {{ user.user_name }},
|
||||
Hello {{ user.display_name() }},
|
||||
|
||||
To reset your password click on the following link:
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<p>Hi {{ user.user_name }},</p>
|
||||
<p>Hi {{ user.display_name() }},</p>
|
||||
<p>
|
||||
To verify your email address
|
||||
<a href="{{ url_for('auth.verify_email', token=user.verification_token, _external=True) }}">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
Hello {{ user.user_name }},
|
||||
Hello {{ user.display_name() }},
|
||||
|
||||
To verify your email address:
|
||||
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
<h3>Moderators</h3>
|
||||
<ol>
|
||||
{% for mod in mods %}
|
||||
<li><a href="/u/{{ mod.user_name }}">{{ mod.user_name }}</a></li>
|
||||
<li><a href="/u/{{ mod.user_name }}">{{ mod.display_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
|
|
|
@ -89,7 +89,7 @@
|
|||
<h3>Moderators</h3>
|
||||
<ol>
|
||||
{% for mod in mods %}
|
||||
<li><a href="/u/{{ mod.user_name }}">{{ mod.user_name }}</a></li>
|
||||
<li><a href="/u/{{ mod.user_name }}">{{ mod.display_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
|
|
|
@ -71,7 +71,7 @@
|
|||
<img src="{{ comment['comment'].author.avatar_image() }}" alt="Avatar" /></a>
|
||||
{% endif %}
|
||||
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}">
|
||||
{{ comment['comment'].author.user_name }}</a>
|
||||
{{ comment['comment'].author.display_name() }}</a>
|
||||
{% endif %}
|
||||
{% if comment['comment'].author.created_recently() %}
|
||||
<span class="fe fe-new-account small" title="New account"> </span>
|
||||
|
@ -160,7 +160,7 @@
|
|||
<h3>Moderators</h3>
|
||||
<ul class="moderator_list">
|
||||
{% for mod in mods %}
|
||||
<li><a href="/u/{{ mod.user_name }}">{{ mod.user_name }}</a></li>
|
||||
<li><a href="/u/{{ mod.user_name }}">{{ mod.display_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
|
|
@ -31,9 +31,9 @@
|
|||
{{ _("Hide every post from author's instance: %(name)s", name=post.instance.domain) }}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('post.post_report', post_id=post.id) }}" class="no-underline"><span class="fe fe-report"></span>
|
||||
{{ _('Report to moderators') }}</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('post.post_report', post_id=post.id) }}" class="no-underline" rel="nofollow"><span class="fe fe-report"></span>
|
||||
{{ _('Report to moderators') }}</a></li>
|
||||
</ul>
|
||||
<p>{{ _('If you want to perform more than one of these (e.g. block and report), hold down Ctrl and click, then complete the operation in the new tabs that open.') }}</p>
|
||||
</div>
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
<h3>Moderators</h3>
|
||||
<ol>
|
||||
{% for mod in mods %}
|
||||
<li><a href="/u/{{ mod.user_name }}">{{ mod.user_name }}</a></li>
|
||||
<li><a href="/u/{{ mod.user_name }}">{{ mod.display_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/u/{{ user.user_name }}">{{ user.user_name }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/u/{{ user.user_name }}">{{ user.display_name() }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ _('Change settings') }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
|
|
@ -7,14 +7,14 @@
|
|||
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/u/{{ user.user_name }}">{{ user.user_name }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/u/{{ user.user_name }}">{{ user.display_name() }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ _('Edit profile') }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="mt-2">{{ _('Edit profile of %(name)s', name=user.user_name) }}</h1>
|
||||
<form method='post' enctype="multipart/form-data">
|
||||
{{ form.csrf_token() }}
|
||||
|
||||
{{ render_field(form.title) }}
|
||||
{{ render_field(form.email) }}
|
||||
{{ render_field(form.password_field) }}
|
||||
<hr />
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/u/{{ user.user_name }}">{{ user.user_name }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/u/{{ user.user_name }}">{{ user.display_name() }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ _('Change settings') }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
|
|
@ -10,19 +10,19 @@
|
|||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/users">{{ _('People') }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ user.user_name|shorten }}</li>
|
||||
<li class="breadcrumb-item active">{{ user.display_name()|shorten }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<img class="community_icon_big bump_up rounded-circle" src="{{ user.avatar_image() }}" />
|
||||
<h1 class="mt-2">{{ user.user_name if user.ap_id == none else user.ap_id }}</h1>
|
||||
<h1 class="mt-2">{{ user.display_name() if user.is_local() else user.ap_id }}</h1>
|
||||
{% elif user.avatar_image() != '' %}
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<img class="community_icon_big rounded-circle" src="{{ user.avatar_image() }}" />
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<h1 class="mt-3">{{ user.user_name if user.ap_id == none else user.ap_id }}</h1>
|
||||
<h1 class="mt-3">{{ user.display_name() if user.is_local() else user.ap_id }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
@ -33,7 +33,7 @@
|
|||
<li class="breadcrumb-item active">{{ user.user_name|shorten }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="mt-2">{{ user.user_name if user.ap_id == none else user.ap_id }}</h1>
|
||||
<h1 class="mt-2">{{ user.display_name() if user.is_local() else user.ap_id }}</h1>
|
||||
{% endif %}
|
||||
<p class="small">{{ _('Joined') }}: {{ moment(user.created).fromNow(refresh=True) }}<br />
|
||||
{{ _('Attitude') }}: <span title="{{ _('Ratio of upvotes cast to downvotes cast. Higher is more positive.') }}">{{ (user.attitude * 100) | round | int }}%</span></p>
|
||||
|
|
|
@ -7,6 +7,7 @@ from flask_babel import _, lazy_gettext as _l
|
|||
|
||||
|
||||
class ProfileForm(FlaskForm):
|
||||
title = StringField(_l('Display name'), validators=[Optional(), Length(max=255)])
|
||||
email = EmailField(_l('Email address'), validators=[Email(), DataRequired(), Length(min=5, max=255)])
|
||||
password_field = PasswordField(_l('Set new password'), validators=[Optional(), Length(min=1, max=50)],
|
||||
render_kw={"autocomplete": 'Off'})
|
||||
|
|
|
@ -68,6 +68,7 @@ def edit_profile(actor):
|
|||
abort(401)
|
||||
form = ProfileForm()
|
||||
if form.validate_on_submit() and not current_user.banned:
|
||||
current_user.title = form.title.data
|
||||
current_user.email = form.email.data
|
||||
if form.password_field.data.strip() != '':
|
||||
current_user.set_password(form.password_field.data)
|
||||
|
@ -77,11 +78,25 @@ def edit_profile(actor):
|
|||
current_user.bot = form.bot.data
|
||||
profile_file = request.files['profile_file']
|
||||
if profile_file and profile_file.filename != '':
|
||||
# remove old avatar
|
||||
file = File.query.get(current_user.avatar_id)
|
||||
file.delete_from_disk()
|
||||
current_user.avatar_id = None
|
||||
db.session.delete(file)
|
||||
|
||||
# add new avatar
|
||||
file = save_icon_file(profile_file, 'users')
|
||||
if file:
|
||||
current_user.avatar = file
|
||||
banner_file = request.files['banner_file']
|
||||
if banner_file and banner_file.filename != '':
|
||||
# remove old cover
|
||||
file = File.query.get(current_user.cover_id)
|
||||
file.delete_from_disk()
|
||||
current_user.cover_id = None
|
||||
db.session.delete(file)
|
||||
|
||||
# add new cover
|
||||
file = save_banner_file(banner_file, 'users')
|
||||
if file:
|
||||
current_user.cover = file
|
||||
|
@ -92,6 +107,7 @@ def edit_profile(actor):
|
|||
|
||||
return redirect(url_for('user.edit_profile', actor=actor))
|
||||
elif request.method == 'GET':
|
||||
form.title.data = current_user.title
|
||||
form.email.data = current_user.email
|
||||
form.about.data = current_user.about
|
||||
form.matrix_user_id.data = current_user.matrix_user_id
|
||||
|
@ -200,6 +216,7 @@ def delete_profile(actor):
|
|||
else:
|
||||
user.banned = True
|
||||
user.deleted = True
|
||||
user.delete_dependencies()
|
||||
db.session.commit()
|
||||
|
||||
flash(f'{actor} has been deleted.')
|
||||
|
|
32
migrations/versions/0dadae40281d_user_title.py
Normal file
32
migrations/versions/0dadae40281d_user_title.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
"""user title
|
||||
|
||||
Revision ID: 0dadae40281d
|
||||
Revises: c80716fd7b79
|
||||
Create Date: 2024-01-01 14:12:01.062643
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0dadae40281d'
|
||||
down_revision = 'c80716fd7b79'
|
||||
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('title', sa.String(length=256), 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('title')
|
||||
|
||||
# ### end Alembic commands ###
|
Loading…
Reference in a new issue