delete account, with federation

This commit is contained in:
rimu 2023-12-29 17:32:35 +13:00
parent 7966a91334
commit 207efc2329
16 changed files with 271 additions and 41 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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