admin users

This commit is contained in:
rimu 2024-01-01 14:49:15 +13:00
parent 83eaf6d883
commit 8c622c04c7
28 changed files with 422 additions and 93 deletions

View file

@ -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,7 +690,8 @@ 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'):
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:
@ -703,22 +706,9 @@ def process_delete_request(request_json, activitypublog_id):
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")
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'

View file

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

View file

@ -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
@ -77,3 +77,19 @@ class EditCommunityForm(FlaskForm):
self.url.errors.append(_('- cannot be in Url. Use _ instead?'))
return False
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'))

View file

@ -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,6 +261,158 @@ 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)
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}",
@ -274,12 +428,10 @@ def unsubscribe_everyone_then_delete_task(community_id):
'id': undo_id,
'object': follow
}
activity = ActivityPubLog(direction='out', activity_id=undo_id, activity_type='Undo', activity_json=json.dumps(undo), result='processing')
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()
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.

View file

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

View file

@ -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,6 +306,9 @@ class User(UserMixin, db.Model):
def display_name(self):
if self.deleted is False:
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()

View file

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

View file

@ -186,7 +186,7 @@ nav.navbar {
.comment_author {
a {
font-weight: bold;
}
}

View file

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

View 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 %}

View 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">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary">
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}
</nav>
</div>
</div>
{% endblock %}

View file

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

View file

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

View file

@ -1,4 +1,4 @@
Hello {{ user.user_name }},
Hello {{ user.display_name() }},
To reset your password click on the following link:

View file

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

View file

@ -1,4 +1,4 @@
Hello {{ user.user_name }},
Hello {{ user.display_name() }},
To verify your email address:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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