From 4083c950c5e9cd87a8c20add7e599085751d07e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Wed, 3 Jun 2026 11:07:50 +0300 Subject: [PATCH] start work on invite keys --- app/__init__.py | 6 +++ app/migrations.py | 3 +- app/routes/users.py | 85 ++++++++++++++++++++++++++---- app/schema.py | 2 + app/templates/common/topnav.html | 2 + app/templates/users/bookmarks.html | 8 +-- app/templates/users/settings.html | 41 +++++++++++++- app/templates/users/sign_up.html | 13 ++++- data/static/css/style.css | 18 ++----- 9 files changed, 147 insertions(+), 31 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index b77526e..e1d0db5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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() diff --git a/app/migrations.py b/app/migrations.py index 870ea88..f770213 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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(): diff --git a/app/routes/users.py b/app/routes/users.py index 9316bd9..a964e50 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -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('//') @@ -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('//settings/set-avatar') @@ -539,3 +567,40 @@ def delete_confirm_post(username): user.delete() return redirect(url_for('topics.all_topics')) + +@bp.post('//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('//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')) diff --git a/app/schema.py b/app/schema.py index e7ee34d..9063ff8 100644 --- a/app/schema.py +++ b/app/schema.py @@ -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(): diff --git a/app/templates/common/topnav.html b/app/templates/common/topnav.html index 945fa18..0fd8962 100644 --- a/app/templates/common/topnav.html +++ b/app/templates/common/topnav.html @@ -21,7 +21,9 @@ + {%- if not config.DISABLE_SIGNUP -%} Sign up + {%- endif -%} {%- endif -%} diff --git a/app/templates/users/bookmarks.html b/app/templates/users/bookmarks.html index 5564e21..de4e5ad 100644 --- a/app/templates/users/bookmarks.html +++ b/app/templates/users/bookmarks.html @@ -20,12 +20,12 @@ {%- if thread_count > 0 -%}
Threads - +
- - - + + + diff --git a/app/templates/users/settings.html b/app/templates/users/settings.html index 486f3c4..b8833f7 100644 --- a/app/templates/users/settings.html +++ b/app/templates/users/settings.html @@ -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 @@
Loading badges…
If badges fail to load, make sure JS is enabled.
+{%- if user.can_invite() -%} +
+ Invite keys +

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.

+

Invite links are valid for 48 hours. Once an invite link is used to sign up, it can no longer be used.

+
+ {{ csrf_input() | safe }} + + + {%- if invites -%} +
TitleMemoManageTitleMemoManage
+ + + + + + + + + {%- for invite in invites -%} + + + + + + {%- endfor -%} + +
LinkExpiresRevoke
Copy this{{timestamp(invite.expires_at)}} +
+ {{ csrf_input() | safe }} + + +
+
+ {%- else -%} +

You do not have any invites pending activation.

+ {%- endif -%} + +{%- endif -%} {%- endif -%}
Disown & Delete account diff --git a/app/templates/users/sign_up.html b/app/templates/users/sign_up.html index ed2a80f..e6bd297 100644 --- a/app/templates/users/sign_up.html +++ b/app/templates/users/sign_up.html @@ -4,13 +4,22 @@ {%- block title -%}sign up{%- endblock -%} {%- block content -%} {%- set welcome -%} -Please read the rules etc. stub +

Please read the rules etc. stub

+{%- if not inviter -%} +

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 {{inviter.get_readable_name()}} to join {{config.SITE_NAME}}. Create an identity below. +{%- endif -%} +

{%- endset -%} {{ subheader('Sign up', welcome)}} {%- if request.args.get('error') -%} {{infobox(request.args.error, InfoboxKind.ERROR)}} {%- endif -%}
+ {%- if invite -%} + + {%- endif -%} @@ -18,6 +27,6 @@ Please read the rules etc. stub - +
{%- endblock -%} diff --git a/data/static/css/style.css b/data/static/css/style.css index 7138be0..b634137 100644 --- a/data/static/css/style.css +++ b/data/static/css/style.css @@ -731,21 +731,13 @@ details.inner { table { border-collapse: collapse; width: 100%; - - &.three-cols > thead > tr > th { - &:nth-child(1) { - width: 70%; - } - - &:nth-child(2) { - width: 25%; - } - - &:nth-child(3) { - width: 15%; - } + th { + width: var(--w, 50%); } + td.center { + text-align: center; + } } /* babycode tags */