account linking improvements (#448)

currently account delinking is very messy and can sometimes just not work
we do codey stuff so it's not as bad
also we create a pretty page for mops to mop up borked account links

* alts: allow proper delinking

* fix prev commit

* url fix

* fix 500

* fixes

* :pepodrool:

* flag

* :pepodrool: redux

* sdsdsdsds

* correct endpoint

* fix html page

* alts: only adjust session history if flag is set

* fix 500

* allow relinking

* fsdsds

* :pepodrool: redux

* alts: don't fail if an alt isn't history

* use postToastSwitch + some API changes

* remove unnecessary variables

* d-none

* delink accounts mod action

* fa-link-slash

* alts: add form to create alt

* remove copied and pasted template

* rounded section

* UI improvement + fix

* \n

* fix status

* admin: remove duplicate route
admin: do a permissions check on 2 pages that need it
admin: set the manual flag for manually flagged alts

* variable change

* fix 500

* alts

* add shadowban icon to alt link tool

* shadowbanned tooltip

* add user info section

* fix 500, remove unnecessary form, and add alt votes button

* trans and also link to page

* margin

* sdsdsd

* stop the count

* fix prev commit

* with ctx

* plural

* alts

* don't show shadowbanned users to those who can't see them
this is... extremely rare and won't ever be seen in production however if perms were ever rearranged in the future, this keeps permissions correct

* shadowban check in alt list

* let shadow realm enthusiasts see shadowban alts

* sdsdsds

* test

* be graceful where needed

* sdsdsdsds

* alts: don't allow adding the same account
alts: clarify wording

* rename and reorder on admin panel

* EOL

* remove frankly unnecessary check

* try with a set

* test

* Revert "try with a set"

This reverts commit 72be353fba5ffa39b37590cc5d3bf584c94ee06e.

* Revert "Revert "try with a set""

This reverts commit 81e41890a192e8b46d0463477998e905fddf56ba.

* Revert "Revert "Revert "try with a set"""

This reverts commit be51592135a3c09848f993f0154bd2ac862ae505.

* clean up test
remotes/1693176582716663532/tmp_refs/heads/watchparty
justcool393 2022-11-14 09:32:13 -08:00 committed by GitHub
parent 65e555692a
commit c9ecb5d535
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 231 additions and 72 deletions

View File

@ -4084,6 +4084,13 @@ ul.comment-section {
.rounded {
border-radius: 0.35rem;
}
.rounded-section {
margin-bottom: 3rem;
border: .1px solid var(--gray-400);
border-radius: .35rem;
overflow: hidden;
}
.rounded-circle {
border-radius: 50%;
}
@ -5920,6 +5927,7 @@ g {
.fa-knife-kitchen:before{content:"\f6f5"}
.fa-lights-holiday:before{content:"\f7b2"}
.fa-link:before{content:"\f0c1"}
.fa-link-slash:before{content:"\f127"}
.fa-lock:before{content:"\f023"}
.fa-lock-alt:before{content:"\f30d"}
.fa-search:before{content:"\f002"}

View File

@ -9,6 +9,7 @@ class Alt(Base):
user2 = Column(Integer, ForeignKey("users.id"), primary_key=True)
is_manual = Column(Boolean, default=False)
created_utc = Column(Integer)
deleted = Column(Boolean, default=False)
def __init__(self, *args, **kwargs):
if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time())

View File

@ -226,6 +226,11 @@ ACTIONTYPES = {
"icon": 'fa-link',
"color": 'bg-success'
},
'delink_accounts': {
"str": 'delinked {self.target_link}',
"icon": 'fa-link-slash',
"color": 'bg-danger'
},
'make_admin': {
"str": 'made {self.target_link} an admin',
"icon": 'fa-user-crown',

View File

@ -739,7 +739,6 @@ class User(Base):
@property
@lazy
def alts(self):
subq = g.db.query(Alt).filter(
or_(
Alt.user1 == self.id,
@ -764,6 +763,8 @@ class User(Base):
for x in data:
user = x[0]
user._is_manual = x[1].is_manual
user._alt_deleted = x[1].deleted
user._alt_created_utc = x[1].created_utc
output.append(user)
return output

View File

@ -27,7 +27,7 @@ def get_id(username:str, graceful=False) -> Optional[int]:
return user[0]
def get_user(username:str, v:Optional[User]=None, graceful=False, include_blocks=False, include_shadowbanned=True) -> Optional[User]:
def get_user(username:Optional[str], v:Optional[User]=None, graceful=False, include_blocks=False, include_shadowbanned=True) -> Optional[User]:
if not username:
if graceful: return None
abort(404)

View File

@ -636,7 +636,6 @@ def users_list(v):
@app.get("/admin/alt_votes")
@admin_level_required(PERMS['VIEW_ALT_VOTES'])
def alt_votes_get(v):
u1 = request.values.get("u1")
u2 = request.values.get("u2")
@ -738,35 +737,78 @@ def alt_votes_get(v):
data=data
)
@app.post("/admin/link_accounts")
@app.get("/admin/alts/")
@app.get("/@<username>/alts/")
@limiter.limit(DEFAULT_RATELIMIT_SLOWER)
@admin_level_required(PERMS['USER_LINK'])
def admin_link_accounts(v):
u1 = get_account(request.values.get("u1")).id
u2 = get_account(request.values.get("u2")).id
def admin_view_alts(v, username=None):
u = get_user(username or request.values.get('username'), graceful=True)
return render_template('admin/alts.html', v=v, u=u, alts=u.alts_unique if u else None)
new_alt = Alt(
user1=u1,
user2=u2,
is_manual=True
)
@app.post('/@<username>/alts/')
@limiter.limit(DEFAULT_RATELIMIT_SLOWER)
@admin_level_required(PERMS['USER_LINK'])
def admin_add_alt(v, username):
user1 = get_user(username)
user2 = get_user(request.values.get('other_username'))
if user1.id == user2.id: abort(400, "Can't add the same account as alts of each other")
g.db.add(new_alt)
deleted = request.values.get('deleted', False, bool) or False
ids = [user1.id, user2.id]
a = g.db.query(Alt).filter(Alt.user1.in_(ids), Alt.user2.in_(ids)).one_or_none()
if a: abort(409, f"@{user1.username} and @{user2.username} are already known {'linked' if not a.deleted else 'delinked'} alts")
a = Alt(
user1=user1.id,
user2=user2.id,
manual=True,
deleted=deleted
)
g.db.add(a)
g.db.flush()
check_for_alts(g.db.get(User, u1), include_current_session=False)
check_for_alts(g.db.get(User, u2), include_current_session=False)
check_for_alts(user1, include_current_session=False)
check_for_alts(user2, include_current_session=False)
word = 'Delinked' if deleted else 'Linked'
ma_word = 'delink' if deleted else 'link'
note = f'from {user2.id}' if deleted else f'with {user2.id}'
ma = ModAction(
kind=f"{ma_word}_accounts",
user_id=v.id,
target_user_id=user1.id,
_note=note
)
g.db.add(ma)
return {"message": f"{word} @{user1.username} and @{user2.username} successfully!"}
@app.route('/@<username>/alts/<int:other>/deleted', methods=["PUT", "DELETE"])
@limiter.limit(DEFAULT_RATELIMIT_SLOWER)
@admin_level_required(PERMS['USER_LINK'])
def admin_delink_relink_alt(v, username, other):
is_delinking = request.method == 'PUT' # we're adding the 'deleted' state if a PUT request
user1 = get_user(username)
user2 = get_account(other)
ids = [user1.id, user2.id]
a = g.db.query(Alt).filter(Alt.user1.in_(ids), Alt.user2.in_(ids)).one_or_none()
if not a: abort(404)
a.deleted = is_delinking
g.db.add(a)
g.db.flush()
check_for_alts(user1, include_current_session=False)
check_for_alts(user2, include_current_session=False)
word = 'Delinked' if is_delinking else 'Relinked'
ma_word = 'delink' if is_delinking else 'link'
note = f'from {user2.id}' if is_delinking else f'with {user2.id} (relinked)'
ma = ModAction(
kind="link_accounts",
kind=f"{ma_word}_accounts",
user_id=v.id,
target_user_id=u1,
_note=f'with {u2}'
target_user_id=user1.id,
_note=note
)
g.db.add(ma)
return redirect(f"/admin/alt_votes?u1={get_account(u1).username}&u2={get_account(u2).username}")
return {"message": f"{word} @{user1.username} and @{user2.username} successfully!"}
@app.get("/admin/removed/posts")

View File

@ -48,20 +48,24 @@ def check_for_alts(current:User, include_current_session=True):
add_alt(past_id, current_id)
other_alts = g.db.query(Alt).filter(Alt.user1.in_(li), Alt.user2.in_(li)).all()
for a in other_alts:
if a.user1 != past_id:
add_alt(a.user1, past_id)
if a.user1 != current_id:
add_alt(a.user1, current_id)
if a.user2 != past_id:
add_alt(a.user2, past_id)
if a.user2 != current_id:
add_alt(a.user2, current_id)
if a.deleted:
if include_current_session:
try: session["history"].remove(a.user1)
except: pass
try: session["history"].remove(a.user2)
except: pass
continue # don't propagate deleted alt links
if a.user1 != past_id: add_alt(a.user1, past_id)
if a.user1 != current_id: add_alt(a.user1, current_id)
if a.user2 != past_id: add_alt(a.user2, past_id)
if a.user2 != current_id: add_alt(a.user2, current_id)
past_accs.add(current_id)
if include_current_session:
session["history"] = list(past_accs)
g.db.flush()
for u in current.alts_unique:
if u._alt_deleted: continue
if u.shadowbanned:
current.shadowbanned = u.shadowbanned
if not current.is_banned: current.ban_reason = u.ban_reason
@ -369,11 +373,10 @@ def sign_up_post(v):
send_verification_email(new_user)
check_for_alts(new_user)
send_notification(new_user.id, WELCOME_MSG)
session["lo_user"] = new_user.id
check_for_alts(new_user)
send_notification(new_user.id, WELCOME_MSG)
if SIGNUP_FOLLOW_ID:
signup_autofollow = get_account(SIGNUP_FOLLOW_ID)

View File

@ -145,7 +145,7 @@ def log(v):
actions = actions.filter(ModAction.kind.notin_([
"shadowban","unshadowban",
"mod_mute_user","mod_unmute_user",
"link_accounts",
"link_accounts","delink_accounts",
]))
if admin_id:

View File

@ -69,7 +69,12 @@
<h4>Safety</h4>
<ul>
<li><a href="/admin/banned_domains">Banned Domains</a></li>
<li><a href="/admin/alt_votes">Multi Vote Analysis</a></li>
{% if v.admin_level >= PERMS['USER_LINK'] %}
<li><a href="/admin/alts/">View and Link Alts</a></li>
{% endif %}
{% if v.admin_level >= PERMS['VIEW_ALT_VOTES'] %}
<li><a href="/admin/alt_votes">Multi Vote Analysis</a></li>
{% endif %}
</ul>
{% if FEATURES['BADGES'] or FEATURES['AWARDS'] -%}

View File

@ -1,15 +1,9 @@
{% extends "default.html" %}
{% block title %}
<title>{{SITE_NAME}}</title>
{% endblock %}
{% block pagetitle %}Alt Vote Analysis{% endblock %}
{% block content %}
<h5 class="mt-3">Vote Info</h5>
<form action="/admin/alt_votes" method="get" class="mb-6">
<form action="" method="get" class="mb-6">
<label for="link-input">Usernames</label>
<input autocomplete="off" id="link-input" type="text" class="form-control mb-2" name="u1" value="{{u1.username if u1 else ''}}" placeholder="User 1">
<input autocomplete="off" id="link-input" type="text" class="form-control mb-2" name="u2" value="{{u2.username if u2 else ''}}" placeholder="User 2">
@ -17,12 +11,7 @@
</form>
{% if u1 and u2 %}
<h2>Analysis</h2>
<div class="overflow-x-auto"><table class="table table-striped mb-5">
<thead class="bg-primary text-white">
<tr>
@ -32,7 +21,6 @@
<th>@{{u2.username}} only (% unique)</th>
</tr>
</thead>
<tr>
<td><b>Post Upvotes</b></td>
<td>{{data['u1_only_post_ups']}} ({{data['u1_post_ups_unique']}}%)</td>
@ -58,27 +46,18 @@
<td>{{data['u2_only_comment_downs']}} ({{data['u2_comment_downs_unique']}}%)</td>
</tr>
</table>
{% if v.admin_level >= PERMS['USER_LINK'] %}
<h2>Link Accounts</h2>
<h2>Link Accounts</h2>
{% if u2 in u1.alts %}
<p>Accounts are <a href="/@{{u1.username}}/alts">known alts</a> of each other.</p>
{% else %}
{% if u2 in u1.alts %}
<p>Accounts are known alts of eachother.</p>
{% else %}
<p>Two accounts controlled by different people should have most uniqueness percentages at or above 70-80%</p>
<p>A sockpuppet account will have its uniqueness percentages significantly lower.</p>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('linkbtn').classList.toggle('d-none');">Link Accounts</button>
<form action="/admin/link_accounts" method="post">
<input type="hidden" name="formkey" value="{{v.formkey}}">
<input type="hidden" name="u1" value="{{u1.id}}">
<input type="hidden" name="u2" value="{{u2.id}}">
<input type="submit" onclick="disable(this)" id="linkbtn" class="btn btn-primary d-none" value="Confirm Link: {{u1.username}} and {{u2.username}}">
</form>
<p>Two accounts controlled by different people should have most uniqueness percentages at or above 70-80%</p>
<p>A sockpuppet account will have its uniqueness percentages significantly lower.</p>
<button class="btn btn-danger" data-click="postToastReload(this,'/@{{u1.username}}/alts/?other_username={{u2.username}}')" onclick="areyousure(this)">Link {{u1.username}} and {{u2.username}}</button>
{% endif %}
{% endif %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,106 @@
{% extends "settings2.html" %}
{% block content %}
{% if u %}
<h5 class="mt-3">Alts for <a href="/@{{u.username}}">@{{u.username}}</a></h5>
{% else %}
<h5 class="mt-3">Alts</h5>
{% endif %}
{%- import 'util/helpers.html' as help -%}
<section class="username-input-section mb-3">
<form action="/admin/alts" method="get">
<label for="link-input">Username</label>
<input autocomplete="off" id="link-input" type="text" class="form-control mb-2" name="username" value="{{u.username if u else ''}}" placeholder="Username">
<input type="submit" onclick="disable(this)" value="Submit" class="btn btn-primary">
</form>
</section>
{% if u %}
{% set count=alts|length %}
<section class="userinfo-section">
<p><a href="/@{{u.username}}">@{{u.username}}</a> created their account {{u.created_utc|timestamp}} and has {{count}} known alt{{help.plural(count)}}.<br>
They are {% if not u.is_suspended_permanently %}not {% endif %}permanently banned{% if v.admin_level >= PERMS['USER_SHADOWBAN'] %} and they are {% if not u.shadowbanned %}not {% endif %}shadowbanned{% endif %}.</p>
</section>
<div class="overflow-x-auto">
<table class="table table-striped mb-5">
<thead class="bg-primary text-white">
<tr>
<th>#</th>
<th>Name</th>
<th>Account Created</th>
<th>Alt Link Created</th>
<th>Manual</th>
<th>Delinked</th>
<th>Actions</th>
</tr>
</thead>
{% for user in alts %}
<tr>
<td>{{loop.index}}</td>
<td>{% include "admin/shadowbanned_tooltip.html" %}{% include "user_in_table.html" %}</td>
<td>{{user.created_utc|timestamp}}</td>
<td>{{user._alt_created_utc|timestamp}}</td>
<td>{{user._is_manual}}</td>
<td>{{user._alt_deleted}}</td>
<td>
<button type="button" id="delink-alt-{{u.id}}-{{user.id}}" class="btn btn-danger {% if user._alt_deleted %}d-none{% endif %}" onclick="postToastSwitch(this,'/@{{u.username}}/alts/{{user.id}}/deleted', this.id, 'relink-alt-{{u.id}}-{{user.id}}', 'd-none', null, 'PUT')">Delink</button>
<button type="button" id="relink-alt-{{u.id}}-{{user.id}}" class="btn btn-danger {% if not user._alt_deleted %}d-none{% endif %}" onclick="postToastSwitch(this,'/@{{u.username}}/alts/{{user.id}}/deleted', this.id, 'delink-alt-{{u.id}}-{{user.id}}', 'd-none', null, 'DELETE')">Relink</button>
<a class="btn btn-secondary" href="/@{{user.username}}/alts">Alts</a>
{% if v.admin_level >= PERMS['VIEW_ALT_VOTES'] %}
<a class="btn btn-secondary" href="/admin/alt_votes/?u1={{u.username}}&u2={{user.username}}">Alt Votes</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
<section id="add-alt" class="rounded rounded-section p-3">
<h5>Add Alt</h5>
<p>This tool allows you to add an alt either linked or delinked.<br>
Adding linked will link the two alts together manually, while adding delinked will attempt to delink alts whereever possible.<br>
You're on your own for reversing any propagation though.
</p>
<form onsubmit="return false;">
<label for="link-input-other">Other Username</label>
<input autocomplete="off" id="link-input-other" type="text" class="form-control mb-2" name="other_username" placeholder="Other Username">
<button id="add-alt-form-link" class="btn btn-danger mr-3" data-click="submitAddAlt(this)" onclick="areyousure(this)">Add Alt Linked</button>
<button id="add-alt-form-delink" class="btn btn-danger" data-click="submitAddAlt(this)" onclick="areyousure(this)">Add Alt Delinked</button>
</form>
</section>
{% endif %}
<script type="application/javascript">
function submitAddAlt(element) {
if (!element || !element.form) return;
const isLinking = element.id == 'add-alt-form-link';
const otherElement = isLinking ? document.getElementById('add-alt-form-delink') : document.getElementById('add-alt-form-link');
if (!otherElement) return;
element.disabled = true;
otherElement.disabled = true;
element.classList.add('disabled');
otherElement.classList.add('disabled');
const form = new FormData();
if (!isLinking) form.append('deleted', 'true');
form.append('other_username', document.getElementById('link-input-other').value);
const xhr = createXhrWithFormKey('/@{{u.username}}/alts/', 'POST', form);
xhr[0].onload = function() {
let data;
try {
data = JSON.parse(xhr[0].response);
}
catch(e) {
console.log(e);
}
if (xhr[0].status >= 200 && xhr[0].status < 300) {
showToast(true, getMessageFromJsonData(true, data));
window.location.reload();
} else {
showToast(false, getMessageFromJsonData(false, data));
element.disabled = false;
otherElement.disabled = false;
element.classList.remove('disabled');
otherElement.classList.remove('disabled');
}
}
xhr[0].send(xhr[1]);
}
</script>
{% endblock %}

View File

@ -0,0 +1 @@
{% if v and v.admin_level >= PERMS['USER_SHADOWBAN'] and user.shadowbanned %}<i class="fas fa-user-times text-admin" data-bs-toggle="tooltip" data-bs-placement="bottom" title='Shadowbanned by @{{user.shadowbanned}}{% if user.ban_reason %} for "{{user.ban_reason}}"{% endif %}'></i>{% endif %}

View File

@ -230,9 +230,13 @@
<p id="profile--info--private">User has private mode enabled</p>
{% endif %}
{% if v and (v.admin_level >= PERMS['VIEW_ALTS'] or v.alt) %}
<span id="profile--alts">Alts:</span>
{% if v.admin_level >= PERMS['USER_LINK'] %}
<span id="profile--alts"><a href="/@{{u.username}}/alts">Alts</a>:</span>
{% else %}
<span id="profile--alts">Alts:</span>
{% endif %}
<ul id="profile--alts-list">
{% for account in u.alts_unique %}
{% for account in u.alts_unique if not account._alt_deleted and (v.can_see_shadowbanned or not account.shadowbanned) %}
<li><a href="{{account.url}}">@{{account.username}}</a>{% if account._is_manual %} [m]{% endif %}</li>
{% endfor %}
</ul>
@ -469,11 +473,15 @@
<p id="profile-mobile--info--private">User has private mode enabled</p>
{% endif %}
{% if v and (v.admin_level >= PERMS['VIEW_ALTS'] or v.alt) %}
<span id="profile-mobile--alts">Alts:</span>
{% if v.admin_level >= PERMS['USER_LINK'] %}
<span id="profile-mobile--alts"><a href="/@{{u.username}}/alts">Alts</a>:</span>
{% else %}
<span id="profile-mobile--alts">Alts:</span>
{% endif %}:
<ul id="profile-mobile--alts-list">
{% for account in u.alts_unique %}
<li><a href="{{account.url}}">@{{account.username}}</a>{% if account._is_manual %} [m]{% endif %}</li>
{% endfor %}
{% for account in u.alts_unique if not account._alt_deleted and (v.can_see_shadowbanned or not account.shadowbanned) %}
<li><a href="{{account.url}}">@{{account.username}}</a>{% if account._is_manual %} [m]{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
</div>