mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-02-02 16:21:32 -08:00
delete account, with federation
This commit is contained in:
parent
7966a91334
commit
207efc2329
16 changed files with 271 additions and 41 deletions
|
@ -2,7 +2,7 @@ from app import db, constants, cache, celery
|
|||
from app.activitypub import bp
|
||||
from flask import request, Response, current_app, abort, jsonify, json, g
|
||||
|
||||
from app.activitypub.signature import HttpSignature
|
||||
from app.activitypub.signature import HttpSignature, post_request
|
||||
from app.community.routes import show_community
|
||||
from app.post.routes import continue_discussion, show_post
|
||||
from app.user.routes import show_profile
|
||||
|
@ -12,7 +12,8 @@ from app.models import User, Community, CommunityJoinRequest, CommunityMember, C
|
|||
from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \
|
||||
post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \
|
||||
lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \
|
||||
upvote_post, activity_already_ingested, make_image_sizes, delete_post_or_comment, community_members
|
||||
upvote_post, activity_already_ingested, make_image_sizes, delete_post_or_comment, community_members, \
|
||||
user_removed_from_remote_server
|
||||
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
|
||||
import werkzeug.exceptions
|
||||
|
@ -136,17 +137,24 @@ def lemmy_federated_instances():
|
|||
})
|
||||
|
||||
|
||||
@bp.route('/u/<actor>', methods=['GET'])
|
||||
@bp.route('/u/<actor>', methods=['GET', 'HEAD'])
|
||||
def user_profile(actor):
|
||||
""" Requests to this endpoint can be for a JSON representation of the user, or a HTML rendering of their profile.
|
||||
The two types of requests are differentiated by the header """
|
||||
actor = actor.strip()
|
||||
if '@' in actor:
|
||||
user = User.query.filter_by(ap_id=actor, deleted=False, banned=False).first()
|
||||
user: User = User.query.filter_by(ap_id=actor, deleted=False, banned=False).first()
|
||||
else:
|
||||
user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
|
||||
user: User = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
|
||||
|
||||
if user is not None:
|
||||
if request.method == 'HEAD':
|
||||
if is_activitypub_request():
|
||||
resp = jsonify('')
|
||||
resp.content_type = 'application/activity+json'
|
||||
return resp
|
||||
else:
|
||||
return ''
|
||||
if is_activitypub_request():
|
||||
server = current_app.config['SERVER_NAME']
|
||||
actor_data = { "@context": default_context(),
|
||||
|
@ -155,6 +163,9 @@ def user_profile(actor):
|
|||
"preferredUsername": actor,
|
||||
"inbox": f"https://{server}/u/{actor}/inbox",
|
||||
"outbox": f"https://{server}/u/{actor}/outbox",
|
||||
"discoverable": user.searchable,
|
||||
"indexable": user.indexable,
|
||||
"manuallyApprovesFollowers": user.ap_manually_approves_followers,
|
||||
"publicKey": {
|
||||
"id": f"https://{server}/u/{actor}#main-key",
|
||||
"owner": f"https://{server}/u/{actor}",
|
||||
|
@ -291,17 +302,16 @@ def shared_inbox():
|
|||
activity_log.activity_id = request_json['id']
|
||||
activity_log.activity_json = json.dumps(request_json)
|
||||
activity_log.result = 'processing'
|
||||
db.session.add(activity_log)
|
||||
db.session.commit()
|
||||
|
||||
# Mastodon spams the whole fediverse whenever any of their users are deleted. Ignore them, for now. The Activity includes the Actor signature so it should be possible to verify the POST and do the delete if valid, without a call to find_actor_or_create() and all the network activity that involves. One day.
|
||||
# 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'):
|
||||
activity_log.result = 'ignored'
|
||||
activity_log.activity_type = 'Delete'
|
||||
db.session.add(activity_log)
|
||||
db.session.commit()
|
||||
if current_app.debug:
|
||||
process_delete_request(request_json, activity_log.id)
|
||||
else:
|
||||
process_delete_request.delay(request_json, activity_log.id)
|
||||
return ''
|
||||
else:
|
||||
db.session.add(activity_log)
|
||||
db.session.commit()
|
||||
else:
|
||||
activity_log.activity_id = ''
|
||||
activity_log.activity_json = json.dumps(request_json)
|
||||
|
@ -926,6 +936,55 @@ def process_inbox_request(request_json, activitypublog_id):
|
|||
db.session.commit()
|
||||
|
||||
|
||||
@celery.task
|
||||
def process_delete_request(request_json, activitypublog_id):
|
||||
with current_app.app_context():
|
||||
activity_log = ActivityPubLog.query.get(activitypublog_id)
|
||||
if 'type' in request_json and request_json['type'] == 'Delete':
|
||||
actor_to_delete = request_json['object']
|
||||
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")
|
||||
else:
|
||||
activity_log.result = 'ignored'
|
||||
activity_log.exception_message = 'Only remote users can be deleted remotely'
|
||||
else:
|
||||
activity_log.result = 'ignored'
|
||||
activity_log.exception_message = 'Does not exist here'
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@bp.route('/c/<actor>/outbox', methods=['GET'])
|
||||
def community_outbox(actor):
|
||||
actor = actor.strip()
|
||||
|
|
|
@ -54,8 +54,8 @@ def http_date(epoch_seconds=None):
|
|||
if epoch_seconds is None:
|
||||
epoch_seconds = arrow.utcnow().timestamp()
|
||||
return formatdate(epoch_seconds, usegmt=True) # takahe uses formatdate so let's try that
|
||||
# formatted_date = arrow.get(epoch_seconds).format('ddd, DD MMM YYYY HH:mm:ss ZZ', 'en_US') # mastodon does not like this
|
||||
# return formatted_date
|
||||
#formatted_date = arrow.get(epoch_seconds).format('ddd, DD MMM YYYY HH:mm:ss ZZ', 'en_US') # mastodon does not like this
|
||||
#return formatted_date
|
||||
|
||||
|
||||
def format_ld_date(value: datetime) -> str:
|
||||
|
|
|
@ -21,7 +21,7 @@ from PIL import Image, ImageOps
|
|||
from io import BytesIO
|
||||
|
||||
from app.utils import get_request, allowlist_html, html_to_markdown, get_setting, ap_datetime, markdown_to_html, \
|
||||
is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text
|
||||
is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text, head_request
|
||||
|
||||
|
||||
def public_key():
|
||||
|
@ -257,11 +257,31 @@ def extract_domain_and_actor(url_string: str):
|
|||
return server_domain, actor
|
||||
|
||||
|
||||
def user_removed_from_remote_server(actor_url, is_piefed=False):
|
||||
result = False
|
||||
response = None
|
||||
try:
|
||||
if is_piefed:
|
||||
response = head_request(actor_url, headers={'Accept': 'application/activity+json'})
|
||||
else:
|
||||
response = get_request(actor_url, headers={'Accept': 'application/activity+json'})
|
||||
if response.status_code == 404 or response.status_code == 410:
|
||||
result = True
|
||||
else:
|
||||
result = False
|
||||
except:
|
||||
result = True
|
||||
finally:
|
||||
if response:
|
||||
response.close()
|
||||
return result
|
||||
|
||||
|
||||
def refresh_user_profile(user_id):
|
||||
if current_app.debug:
|
||||
refresh_user_profile_task(user_id)
|
||||
else:
|
||||
refresh_user_profile_task.apply_async(args=(user_id), countdown=randint(1, 10))
|
||||
refresh_user_profile_task.apply_async(args=(user_id,), countdown=randint(1, 10))
|
||||
|
||||
|
||||
@celery.task
|
||||
|
@ -305,6 +325,8 @@ def actor_json_to_model(activity_json, address, server):
|
|||
email=f"{address}@{server}",
|
||||
about_html=parse_summary(activity_json),
|
||||
matrix_user_id=activity_json['matrixUserId'] if 'matrixUserId' in activity_json else '',
|
||||
indexable=activity_json['indexable'] if 'indexable' in activity_json else False,
|
||||
searchable=activity_json['discoverable'] if 'discoverable' in activity_json else True,
|
||||
created=activity_json['published'] if 'published' in activity_json else utcnow(),
|
||||
ap_id=f"{address}@{server}",
|
||||
ap_public_url=activity_json['id'],
|
||||
|
@ -312,6 +334,7 @@ def actor_json_to_model(activity_json, address, server):
|
|||
ap_inbox_url=activity_json['endpoints']['sharedInbox'],
|
||||
ap_followers_url=activity_json['followers'] if 'followers' in activity_json else None,
|
||||
ap_preferred_username=activity_json['preferredUsername'],
|
||||
ap_manually_approves_followers=activity_json['manuallyApprovesFollowers'] if 'manuallyApprovesFollowers' in activity_json else False,
|
||||
ap_fetched_at=utcnow(),
|
||||
ap_domain=server,
|
||||
public_key=activity_json['publicKey']['publicKeyPem'],
|
||||
|
@ -607,7 +630,7 @@ def refresh_instance_profile(instance_id: int):
|
|||
if current_app.debug:
|
||||
refresh_instance_profile_task(instance_id)
|
||||
else:
|
||||
refresh_instance_profile_task.apply_async(args=(instance_id), countdown=randint(1, 10))
|
||||
refresh_instance_profile_task.apply_async(args=(instance_id,), countdown=randint(1, 10))
|
||||
|
||||
|
||||
@celery.task
|
||||
|
@ -633,6 +656,8 @@ def refresh_instance_profile_task(instance_id: int):
|
|||
instance.inbox = instance_json['inbox']
|
||||
instance.outbox = instance_json['outbox']
|
||||
instance.software = software
|
||||
if instance.inbox.endswith('/site_inbox'): # Lemmy provides a /site_inbox but it always returns 400 when trying to POST to it. wtf.
|
||||
instance.inbox = instance.inbox.replace('/site_inbox', '/inbox')
|
||||
else: # it's pretty much always /inbox so just assume that it is for whatever this instance is running (mostly likely Mastodon)
|
||||
instance.inbox = f"https://{instance.domain}/inbox"
|
||||
instance.updated_at = utcnow()
|
||||
|
|
|
@ -6,7 +6,7 @@ from flask_babel import _
|
|||
from sqlalchemy import text, desc
|
||||
|
||||
from app import db
|
||||
from app.activitypub.routes import process_inbox_request
|
||||
from app.activitypub.routes import process_inbox_request, process_delete_request
|
||||
from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm
|
||||
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site
|
||||
from app.utils import render_template, permission_required, set_setting, get_setting
|
||||
|
@ -131,10 +131,8 @@ def admin_activities():
|
|||
|
||||
activities = ActivityPubLog.query.order_by(desc(ActivityPubLog.created_at)).paginate(page=page, per_page=1000, error_out=False)
|
||||
|
||||
next_url = url_for('admin.admin_activities',
|
||||
page=activities.next_num) if activities.has_next else None
|
||||
prev_url = url_for('admin.admin_activities',
|
||||
page=activities.prev_num) if activities.has_prev and page != 1 else None
|
||||
next_url = url_for('admin.admin_activities', page=activities.next_num) if activities.has_next else None
|
||||
prev_url = url_for('admin.admin_activities', page=activities.prev_num) if activities.has_prev and page != 1 else None
|
||||
|
||||
return render_template('admin/activities.html', title=_('ActivityPub Log'), next_url=next_url, prev_url=prev_url,
|
||||
activities=activities)
|
||||
|
@ -154,5 +152,9 @@ def activity_json(activity_id):
|
|||
@permission_required('change instance settings')
|
||||
def activity_replay(activity_id):
|
||||
activity = ActivityPubLog.query.get_or_404(activity_id)
|
||||
process_inbox_request(json.loads(activity.activity_json), 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)
|
||||
else:
|
||||
process_inbox_request(request_json, activity.id)
|
||||
return 'Ok'
|
||||
|
|
|
@ -32,7 +32,10 @@ class RegistrationForm(FlaskForm):
|
|||
def validate_user_name(self, user_name):
|
||||
user = User.query.filter_by(user_name=user_name.data).first()
|
||||
if user is not None:
|
||||
raise ValidationError(_('An account with this user name already exists.'))
|
||||
if user.deleted:
|
||||
raise ValidationError(_('This username was used in the past and cannot be reused.'))
|
||||
else:
|
||||
raise ValidationError(_('An account with this user name already exists.'))
|
||||
community = Community.query.filter_by(name=user_name.data).first()
|
||||
if community is not None:
|
||||
raise ValidationError(_('A community with this name exists so it cannot be used for a user.'))
|
||||
|
|
|
@ -24,10 +24,13 @@ def login():
|
|||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(user_name=form.user_name.data, ap_id=None).first()
|
||||
if user is None:
|
||||
flash(_('No account exists with that user name address'), 'error')
|
||||
flash(_('No account exists with that user name.'), 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
if user.deleted:
|
||||
flash(_('No account exists with that user name.'), 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
if user.banned:
|
||||
flash(_('You have been banned.', 'error'))
|
||||
flash(_('You have been banned.'), 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
if not user.check_password(form.password.data):
|
||||
if user.password_hash is None:
|
||||
|
|
|
@ -204,7 +204,7 @@ class Community(db.Model):
|
|||
# returns a list of tuples (instance.id, instance.inbox)
|
||||
def following_instances(self):
|
||||
sql = 'select distinct i.id, i.inbox from "instance" as i inner join "user" as u on u.instance_id = i.id inner join "community_member" as cm on cm.user_id = u.id '
|
||||
sql += 'where cm.community_id = :community_id and cm.is_banned = false'
|
||||
sql += 'where cm.community_id = :community_id and cm.is_banned = false and i.id <> 1'
|
||||
return db.session.execute(text(sql), {'community_id': self.id})
|
||||
|
||||
def delete_dependencies(self):
|
||||
|
@ -743,6 +743,10 @@ class Instance(db.Model):
|
|||
post_replies = db.relationship('PostReply', backref='instance', lazy='dynamic')
|
||||
communities = db.relationship('Community', backref='instance', lazy='dynamic')
|
||||
|
||||
def alive(self):
|
||||
# todo: determine aliveness based on number of failed connection attempts, etc
|
||||
return True
|
||||
|
||||
|
||||
class InstanceBlock(db.Model):
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
||||
|
|
|
@ -126,8 +126,7 @@ def show_post(post_id: int):
|
|||
if instance[1] and not current_user.has_blocked_instance(instance[0]):
|
||||
send_to_remote_instance(instance[1], post.community.id, announce)
|
||||
|
||||
return redirect(url_for('activitypub.post_ap',
|
||||
post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form
|
||||
return redirect(url_for('activitypub.post_ap', post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form
|
||||
else:
|
||||
replies = post_replies(post.id, 'top')
|
||||
form.notify_author.data = True
|
||||
|
|
|
@ -399,8 +399,12 @@ fieldset legend {
|
|||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
border-radius: 5px;
|
||||
border-radius: 5px 5px 0 0;
|
||||
/* top-left | top-right | bottom-right | bottom-left */
|
||||
height: 176px;
|
||||
margin-left: -12px;
|
||||
margin-right: -12px;
|
||||
margin-top: -9px;
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.community_header {
|
||||
|
@ -409,7 +413,7 @@ fieldset legend {
|
|||
}
|
||||
@media (min-width: 992px) {
|
||||
.community_header #breadcrumb_nav {
|
||||
padding-left: 20px;
|
||||
padding-left: 13px;
|
||||
padding-top: 13px;
|
||||
}
|
||||
}
|
||||
|
@ -417,9 +421,13 @@ fieldset legend {
|
|||
background-color: rgba(0, 0, 0, 0.2);
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.community_header #breadcrumb_nav .breadcrumb {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
.community_header #breadcrumb_nav .breadcrumb .breadcrumb-item {
|
||||
color: white;
|
||||
display: inline-block;
|
||||
|
|
|
@ -76,8 +76,11 @@ nav, etc which are used site-wide */
|
|||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
border-radius: 5px;
|
||||
border-radius: 5px 5px 0 0; /* top-left | top-right | bottom-right | bottom-left */
|
||||
height: 176px;
|
||||
margin-left: -12px;
|
||||
margin-right: -12px;
|
||||
margin-top: -9px;
|
||||
|
||||
@include breakpoint(tablet) {
|
||||
height: 240px;
|
||||
|
@ -85,14 +88,16 @@ nav, etc which are used site-wide */
|
|||
|
||||
#breadcrumb_nav {
|
||||
@include breakpoint(tablet) {
|
||||
padding-left: 20px;
|
||||
padding-left: 13px;
|
||||
padding-top: 13px;
|
||||
}
|
||||
.breadcrumb {
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
@include breakpoint(tablet) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
margin-bottom: 0;
|
||||
|
||||
.breadcrumb-item {
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
User-Agent: *
|
||||
Disallow: /community/add_local
|
||||
Disallow: /community/add_remote
|
||||
Disallow: /auth/register
|
||||
Disallow: /auth/reset_password_request
|
||||
Disallow: /auth/login
|
||||
Disallow: /admin
|
||||
Disallow: /inbox
|
||||
Disallow: /search/
|
||||
Disallow: /modlog
|
24
app/templates/user/delete_account.html
Normal file
24
app/templates/user/delete_account.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'bootstrap/form.html' import render_form %}
|
||||
|
||||
{% block app_content %}
|
||||
<div class="row">
|
||||
<div class="col main_pane">
|
||||
<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 active">{{ _('Change settings') }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="mt-2">{{ _('Delete %(username)s', username=user.user_name) }}</h1>
|
||||
<form method='post'>
|
||||
<p class="btn-warning">{{ _('You are about to permanently delete the account with the username "<strong>%(username)s</strong>." This means your profile will disappear, pictures will be deleted. Text-based posts will stay but look like they are from someone named "deleted."', username=user.user_name) }}</p>
|
||||
<p>{{ _('Once you hit delete, nobody can use "%(username)s" as a username again. We are doing this so nobody pretends to be you.', username=user.user_name) }}</p>
|
||||
<p>{{ _("We will tell other websites (fediverse instances) that your account is gone. But it's up to them to decide what to do with any copies they have of your stuff. Some websites work differently than ours.") }}</p>
|
||||
<p>{{ _("Remember, once you do this, there's no going back. Are you sure you want to continue?") }}</p>
|
||||
{{ render_form(form) }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -29,6 +29,9 @@
|
|||
{{ render_field(form.bot) }}
|
||||
{{ render_field(form.submit) }}
|
||||
</form>
|
||||
<p class="mt-4 pt-4">
|
||||
<a class="btn btn-warning" href="{{ url_for('user.delete_account') }}">{{ _('Delete account') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -27,7 +27,11 @@ class SettingsForm(FlaskForm):
|
|||
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 fediverse searches'))
|
||||
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 settings'))
|
||||
submit = SubmitField(_l('Save settings'))
|
||||
|
||||
|
||||
class DeleteAccountForm(FlaskForm):
|
||||
submit = SubmitField(_l('Yes, delete my account'))
|
||||
|
|
|
@ -4,11 +4,14 @@ from flask import redirect, url_for, flash, request, make_response, session, Mar
|
|||
from flask_login import login_user, logout_user, current_user, login_required
|
||||
from flask_babel import _
|
||||
|
||||
from app import db, cache
|
||||
from app import db, cache, celery
|
||||
from app.activitypub.signature import post_request
|
||||
from app.activitypub.util import default_context
|
||||
from app.community.util import save_icon_file, save_banner_file
|
||||
from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification, utcnow
|
||||
from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification, utcnow, File, Site, \
|
||||
Instance
|
||||
from app.user import bp
|
||||
from app.user.forms import ProfileForm, SettingsForm
|
||||
from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm
|
||||
from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \
|
||||
is_image_url
|
||||
from sqlalchemy import desc, or_, text
|
||||
|
@ -206,6 +209,62 @@ def delete_profile(actor):
|
|||
return redirect(goto)
|
||||
|
||||
|
||||
@bp.route('/delete_account', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def delete_account():
|
||||
form = DeleteAccountForm()
|
||||
if form.validate_on_submit():
|
||||
files = File.query.join(Post).filter(Post.user_id == current_user.id).all()
|
||||
for file in files:
|
||||
file.delete_from_disk()
|
||||
file.source_url = ''
|
||||
if current_user.avatar_id:
|
||||
current_user.avatar.delete_from_disk()
|
||||
current_user.avatar.source_url = ''
|
||||
if current_user.cover_id:
|
||||
current_user.cover.delete_from_disk()
|
||||
current_user.cover.source_url = ''
|
||||
current_user.banned = True
|
||||
current_user.deleted = True
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if current_app.debug:
|
||||
send_deletion_requests(current_user.id)
|
||||
else:
|
||||
send_deletion_requests.delay(current_user.id)
|
||||
|
||||
logout_user()
|
||||
flash(_('Your account has been deleted.'), 'success')
|
||||
return redirect(url_for('main.index'))
|
||||
elif request.method == 'GET':
|
||||
...
|
||||
|
||||
return render_template('user/delete_account.html', title=_('Delete my account'), form=form, user=current_user)
|
||||
|
||||
|
||||
@celery.task
|
||||
def send_deletion_requests(user_id):
|
||||
user = User.query.get(user_id)
|
||||
if user:
|
||||
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.alive() and instance.id != 1: # instance id 1 is always the current instance
|
||||
post_request(instance.inbox, payload, site.private_key,
|
||||
f"https://{current_app.config['SERVER_NAME']}#main-key")
|
||||
|
||||
|
||||
@bp.route('/u/<actor>/ban_purge', methods=['GET'])
|
||||
@login_required
|
||||
def ban_purge_profile(actor):
|
||||
|
|
22
app/utils.py
22
app/utils.py
|
@ -84,6 +84,28 @@ def get_request(uri, params=None, headers=None) -> requests.Response:
|
|||
return response
|
||||
|
||||
|
||||
# do a HEAD request to a uri, return the result
|
||||
def head_request(uri, params=None, headers=None) -> requests.Response:
|
||||
if headers is None:
|
||||
headers = {'User-Agent': 'PieFed/1.0'}
|
||||
else:
|
||||
headers.update({'User-Agent': 'PieFed/1.0'})
|
||||
try:
|
||||
response = requests.head(uri, params=params, headers=headers, timeout=5, allow_redirects=True)
|
||||
except requests.exceptions.SSLError as invalid_cert:
|
||||
# Not our problem if the other end doesn't have proper SSL
|
||||
current_app.logger.info(f"{uri} {invalid_cert}")
|
||||
raise requests.exceptions.SSLError from invalid_cert
|
||||
except ValueError as ex:
|
||||
# Convert to a more generic error we handle
|
||||
raise requests.exceptions.RequestException(f"InvalidCodepoint: {str(ex)}") from None
|
||||
except requests.exceptions.ReadTimeout as read_timeout:
|
||||
current_app.logger.info(f"{uri} {read_timeout}")
|
||||
raise requests.exceptions.ReadTimeout from read_timeout
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# saves an arbitrary object into a persistent key-value store. cached.
|
||||
@cache.memoize(timeout=50)
|
||||
def get_setting(name: str, default=None):
|
||||
|
|
Loading…
Add table
Reference in a new issue