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)), '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(): def clear_stale_sessions():
from .db import db from .db import db
with db.transaction(): with db.transaction():
@@ -234,6 +239,7 @@ def create_app():
clear_stale_sessions() clear_stale_sessions()
clear_api_limits() clear_api_limits()
clear_stale_invites()
reparse_babycode() reparse_babycode()

View File

@@ -43,7 +43,8 @@ MIGRATIONS = [
add_signature_format, add_signature_format,
create_default_bookmark_collections, create_default_bookmark_collections,
add_display_name, 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(): def run_migrations():

View File

@@ -4,7 +4,7 @@ from flask import (
abort, flash, current_app abort, flash, current_app
) )
from functools import wraps 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.image import Image
from wand.color import Color from wand.color import Color
from wand.exceptions import WandException from wand.exceptions import WandException
@@ -14,9 +14,9 @@ from ..auth import (
login_required, revoke_session, get_active_user, login_required, revoke_session, get_active_user,
parse_display_name, revoke_all_sessions, csrf_verified 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 ..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 ..lib.babycode import babycode_to_html
from ..db import db from ..db import db
import math import math
@@ -169,15 +169,34 @@ def log_out():
@bp.get('/sign-up/') @bp.get('/sign-up/')
@redirect_if_logged_in() @redirect_if_logged_in()
def sign_up(): 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/') @bp.post('/sign-up/')
@redirect_if_logged_in() @redirect_if_logged_in()
def sign_up_post(): def sign_up_post():
generic_error_page = redirect(url_for('.sign_up', error='The username or password you entered is invalid.')) args_sans_error = dict(request.args)
invalid_username_error_page = redirect(url_for('.sign_up', error='This username cannot be used. Please pick another.')) args_sans_error.pop('error', '')
passwords_error_page = redirect(url_for('.sign_up', error='The passwords do not match.')) 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='') 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: if not username:
return generic_error_page return generic_error_page
if request.form.get('password') is None: if request.form.get('password') is None:
@@ -197,12 +216,18 @@ def sign_up_post():
password_hash = digest(request.form.get('password')) password_hash = digest(request.form.get('password'))
user = Users.create({ user_data = {
'username': username_pair[0], 'username': username_pair[0],
'password_hash': password_hash, 'password_hash': password_hash,
'permission': PermissionLevel.GUEST.value, 'permission': PermissionLevel.GUEST.value,
'created_at': int(time.time()), '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) BookmarkCollections.create_default(user.id)
@@ -217,6 +242,7 @@ def sign_up_post():
if session['remember']: if session['remember']:
session.permanent = True session.permanent = True
flash(f'Welcome to {current_app.config['SITE_NAME']}!', InfoboxKind.INFO)
return redirect(url_for('topics.all_topics')) return redirect(url_for('topics.all_topics'))
@bp.get('/<username>/') @bp.get('/<username>/')
@@ -286,9 +312,11 @@ def comments(username):
def settings(username): def settings(username):
user = get_active_user() user = get_active_user()
sort_by = session.get('sort_by', 'activity') sort_by = session.get('sort_by', 'activity')
invites = InviteKeys.findall({'created_by': user.id})
return render_template( return render_template(
'users/settings.html', user=user, 'users/settings.html', user=user,
sort_by=sort_by sort_by=sort_by,
invites=invites,
) )
@bp.post('/<username>/settings/set-avatar') @bp.post('/<username>/settings/set-avatar')
@@ -539,3 +567,40 @@ def delete_confirm_post(username):
user.delete() user.delete()
return redirect(url_for('topics.all_topics')) 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_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_badge_user ON badges(user_id)',
'CREATE INDEX IF NOT EXISTS idx_invite_key_user ON invite_keys(created_by, key)'
] ]
def create(): def create():

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
{%- from 'common/macros.html' import babycode_editor_component -%} {%- 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' -%} {%- extends 'base.html' -%}
{%- block title -%}settings{%- endblock -%} {%- block title -%}settings{%- endblock -%}
{%- block content -%} {%- block content -%}
@@ -76,6 +76,45 @@
<div>Loading badges&hellip;</div> <div>Loading badges&hellip;</div>
<div>If badges fail to load, make sure JS is enabled.</div> <div>If badges fail to load, make sure JS is enabled.</div>
</fieldset> </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 -%} {%- endif -%}
<fieldset class="plank"> <fieldset class="plank">
<legend>Disown & Delete account</legend> <legend>Disown & Delete account</legend>

View File

@@ -4,13 +4,22 @@
{%- block title -%}sign up{%- endblock -%} {%- block title -%}sign up{%- endblock -%}
{%- block content -%} {%- block content -%}
{%- set welcome -%} {%- 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 -%} {%- endset -%}
{{ subheader('Sign up', welcome)}} {{ subheader('Sign up', welcome)}}
{%- if request.args.get('error') -%} {%- if request.args.get('error') -%}
{{infobox(request.args.error, InfoboxKind.ERROR)}} {{infobox(request.args.error, InfoboxKind.ERROR)}}
{%- endif -%} {%- endif -%}
<form class="plank primary-bg full-width" method="POST"> <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> <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> <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> <label for="password">Create password</label>
@@ -18,6 +27,6 @@ Please read the rules etc. stub
<label for="password2">Confirm password</label> <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> <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> <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> </form>
{%- endblock -%} {%- endblock -%}

View File

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