start work on invite keys

This commit is contained in:
2026-06-03 11:07:50 +03:00
parent 5853c8b7a8
commit 4083c950c5
9 changed files with 147 additions and 31 deletions

View File

@@ -139,6 +139,11 @@ def bind_default_badges(path):
'uploaded_at': int(os.path.getmtime(real_path)),
})
def clear_stale_invites():
from .db import db
from .util import time_now
db.execute('DELETE FROM "invite_keys" WHERE expires_at < ?', time_now())
def clear_stale_sessions():
from .db import db
with db.transaction():
@@ -234,6 +239,7 @@ def create_app():
clear_stale_sessions()
clear_api_limits()
clear_stale_invites()
reparse_babycode()

View File

@@ -43,7 +43,8 @@ MIGRATIONS = [
add_signature_format,
create_default_bookmark_collections,
add_display_name,
'ALTER TABLE "post_history" ADD COLUMN "content_rss" STRING DEFAULT NULL'
'ALTER TABLE "post_history" ADD COLUMN "content_rss" STRING DEFAULT NULL',
'ALTER TABLE "invite_keys" ADD COLUMN "expires_at" INTEGER NOT NULL DEFAULT 0',
]
def run_migrations():

View File

@@ -4,7 +4,7 @@ from flask import (
abort, flash, current_app
)
from functools import wraps
from secrets import compare_digest as compare_timesafe
from secrets import compare_digest as compare_timesafe, token_urlsafe
from wand.image import Image
from wand.color import Color
from wand.exceptions import WandException
@@ -14,9 +14,9 @@ from ..auth import (
login_required, revoke_session, get_active_user,
parse_display_name, revoke_all_sessions, csrf_verified
)
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions, BookmarkCollections
from ..models import Users, Posts, Reactions, Threads, Avatars, PostHistory, Mentions, BookmarkCollections, InviteKeys
from ..constants import PermissionLevel, InfoboxKind
from ..util import get_form_checkbox
from ..util import get_form_checkbox, time_now
from ..lib.babycode import babycode_to_html
from ..db import db
import math
@@ -169,15 +169,34 @@ def log_out():
@bp.get('/sign-up/')
@redirect_if_logged_in()
def sign_up():
return render_template('users/sign_up.html')
key = request.args.get('key', '')
if not key and current_app.config['DISABLE_SIGNUP']:
return redirect(url_for('topics.all_topics'))
elif key and current_app.config['DISABLE_SIGNUP']:
invite = InviteKeys.find({'key': key})
if not invite:
return redirect(url_for('topics.all_topics'))
inviter = Users.find({'id': invite.created_by})
return render_template('users/sign_up.html', invite=invite, inviter=inviter)
@bp.post('/sign-up/')
@redirect_if_logged_in()
def sign_up_post():
generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.'))
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.'))
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.'))
args_sans_error = dict(request.args)
args_sans_error.pop('error', '')
generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.', **args_sans_error))
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.', **args_sans_error))
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.', **args_sans_error))
username = request.form.get('username', default='')
if current_app.config['DISABLE_SIGNUP']:
key = request.form.get('key', '')
if not key:
return generic_error_page
invite = InviteKeys.find({'key': key})
if not invite:
return generic_error_page
if invite.expires_at < time_now():
return generic_error_page
if not username:
return generic_error_page
if request.form.get('password') is None:
@@ -197,12 +216,18 @@ def sign_up_post():
password_hash = digest(request.form.get('password'))
user = Users.create({
user_data = {
'username': username_pair[0],
'password_hash': password_hash,
'permission': PermissionLevel.GUEST.value,
'created_at': int(time.time()),
})
}
if invite:
user_data['invited_by'] = invite.created_by
user_data['permission'] = PermissionLevel.USER.value
invite.delete()
user = Users.create(user_data)
BookmarkCollections.create_default(user.id)
@@ -217,6 +242,7 @@ def sign_up_post():
if session['remember']:
session.permanent = True
flash(f'Welcome to {current_app.config['SITE_NAME']}!', InfoboxKind.INFO)
return redirect(url_for('topics.all_topics'))
@bp.get('/<username>/')
@@ -286,9 +312,11 @@ def comments(username):
def settings(username):
user = get_active_user()
sort_by = session.get('sort_by', 'activity')
invites = InviteKeys.findall({'created_by': user.id})
return render_template(
'users/settings.html', user=user,
sort_by=sort_by
sort_by=sort_by,
invites=invites,
)
@bp.post('/<username>/settings/set-avatar')
@@ -539,3 +567,40 @@ def delete_confirm_post(username):
user.delete()
return redirect(url_for('topics.all_topics'))
@bp.post('/<username>/invite-keys/create/')
@login_required
@redirect_to_own
@csrf_verified
def create_invite_key(username):
user = get_active_user()
if not user.can_invite():
abort(404)
key = token_urlsafe(16)
expires_at = time_now() + 48 * 60 * 60
invite = InviteKeys.create({
'created_by': user.id,
'expires_at': expires_at,
'key': key,
})
return redirect(url_for('.settings', username=username, _anchor='invite'))
@bp.post('/<username>/invite-keys/revoke/')
@login_required
@redirect_to_own
@csrf_verified
def revoke_invite_key(username):
user = get_active_user()
if not user.can_invite():
abort(404)
key = request.form.get('key', '')
invite = InviteKeys.find({'created_by': user.id, 'key': key})
if not invite:
abort(404)
invite.delete()
return redirect(url_for('.settings', username=username, _anchor='invite'))

View File

@@ -187,6 +187,8 @@ SCHEMA = [
'CREATE INDEX IF NOT EXISTS idx_badge_upload_user ON badge_uploads(user_id)',
'CREATE INDEX IF NOT EXISTS idx_badge_user ON badges(user_id)',
'CREATE INDEX IF NOT EXISTS idx_invite_key_user ON invite_keys(created_by, key)'
]
def create():

View File

@@ -21,7 +21,9 @@
<input type="password" placeholder="Password" name="password" autocomplete="current-password" required>
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
<input type="submit" value="Log in">
{%- if not config.DISABLE_SIGNUP -%}
<a href="{{url_for('users.sign_up')}}" class="linkbutton alt">Sign up</a>
{%- endif -%}
</form>
{%- endif -%}
</nav>

View File

@@ -20,12 +20,12 @@
{%- if thread_count > 0 -%}
<details class="inner" data-id="{{collection.id}}" data-r="restoreThreadDetails setThreadDetails" data-s="setThreadDetails">
<summary class="plank no-shadow even">Threads</summary>
<table class="three-cols">
<table>
<thead>
<tr>
<th class="plank even no-shadow contrast-bg">Title</th>
<th class="plank even no-shadow contrast-bg">Memo</th>
<th class="plank even no-shadow contrast-bg">Manage</th>
<th class="plank even no-shadow contrast-bg" style="--w:65%">Title</th>
<th class="plank even no-shadow contrast-bg" style="--w:25%">Memo</th>
<th class="plank even no-shadow contrast-bg" style="--w:10%">Manage</th>
</tr>
</thead>
<tbody>

View File

@@ -1,5 +1,5 @@
{%- from 'common/macros.html' import babycode_editor_component -%}
{%- from 'common/macros.html' import subheader, avatar -%}
{%- from 'common/macros.html' import subheader, avatar, timestamp -%}
{%- extends 'base.html' -%}
{%- block title -%}settings{%- endblock -%}
{%- block content -%}
@@ -76,6 +76,45 @@
<div>Loading badges&hellip;</div>
<div>If badges fail to load, make sure JS is enabled.</div>
</fieldset>
{%- if user.can_invite() -%}
<fieldset class="plank" id="invite">
<legend>Invite keys</legend>
<p>To manage growth, {{ config.SITE_NAME }} disallows direct sign ups. Instead, users already with an account may invite people they know. You can create invite links here.</p>
<p>Invite links are valid for 48 hours. Once an invite link is used to sign up, it can no longer be used.</p>
<form method="POST" action="{{url_for('users.create_invite_key', username=user.username)}}">
{{ csrf_input() | safe }}
<input type="submit" value="Create new invite">
</form>
{%- if invites -%}
<table>
<thead>
<tr>
<th class="plank even no-shadow contrast-bg" style="--w: 50%;">Link</th>
<th class="plank even no-shadow contrast-bg" style="--w: 30%;">Expires</th>
<th class="plank even no-shadow contrast-bg" style="--w: 20%;">Revoke</th>
</tr>
</thead>
<tbody>
{%- for invite in invites -%}
<tr>
<td class="plank even no-shadow minimal"><a href="{{url_for('users.sign_up', key=invite.key)}}">Copy this</a></td>
<td class="plank even no-shadow minimal">{{timestamp(invite.expires_at)}}</td>
<td class="plank even no-shadow minimal center">
<form method="POST" action="{{url_for('users.revoke_invite_key', username=user.username)}}">
{{ csrf_input() | safe }}
<input type="hidden" name="key" value="{{invite.key}}">
<input type="submit" class="warn" value="Revoke">
</form>
</td>
</tr>
{%- endfor -%}
</tbody>
</table>
{%- else -%}
<p>You do not have any invites pending activation.</p>
{%- endif -%}
</fieldset>
{%- endif -%}
{%- endif -%}
<fieldset class="plank">
<legend>Disown & Delete account</legend>

View File

@@ -4,13 +4,22 @@
{%- block title -%}sign up{%- endblock -%}
{%- block content -%}
{%- set welcome -%}
Please read the rules etc. stub
<p>Please read the rules etc. stub</p>
{%- if not inviter -%}
<p>After you sign up, a moderator will need to confirm your account before you will be allowed to post.
{%- else -%}
You have been invited by <a href="{{url_for('users.user_page', username=inviter.username)}}">{{inviter.get_readable_name()}}</a> to join {{config.SITE_NAME}}. Create an identity below.
{%- endif -%}
</p>
{%- endset -%}
{{ subheader('Sign up', welcome)}}
{%- if request.args.get('error') -%}
{{infobox(request.args.error, InfoboxKind.ERROR)}}
{%- endif -%}
<form class="plank primary-bg full-width" method="POST">
{%- if invite -%}
<input type="hidden" name="key" value="{{invite.key}}">
{%- endif -%}
<label for="username">Username</label>
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_\-]{3,24}" title="3-24 characters. Only upper and lowercase letters, digits, hyphens, and underscores" autocomplete="username" required>
<label for="password">Create password</label>
@@ -18,6 +27,6 @@ Please read the rules etc. stub
<label for="password2">Confirm password</label>
<input type="password" id="password2" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with at least: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" autocomplete="new-password" required>
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
<input type="submit" value="Sign up">
<input type="submit" value="Sign up" class="alt">
</form>
{%- endblock -%}

View File

@@ -731,21 +731,13 @@ details.inner {
table {
border-collapse: collapse;
width: 100%;
&.three-cols > thead > tr > th {
&:nth-child(1) {
width: 70%;
th {
width: var(--w, 50%);
}
&:nth-child(2) {
width: 25%;
td.center {
text-align: center;
}
&:nth-child(3) {
width: 15%;
}
}
}
/* babycode tags */