diff --git a/app/db.py b/app/db.py index d377ac9..2f5447a 100644 --- a/app/db.py +++ b/app/db.py @@ -201,6 +201,19 @@ class Model: return instance + @classmethod + def findall(cls, condition): + rows = db.QueryBuilder(cls.table)\ + .where(condition)\ + .all() + res = [] + for row in rows: + instance = cls(cls.table) + instance._data = dict(row) + res.append(instance) + return res + + @classmethod def create(cls, values): if not values: diff --git a/app/migrations.py b/app/migrations.py index 352f0c0..5c3ebae 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -9,6 +9,7 @@ def migrate_old_avatars(): MIGRATIONS = [ migrate_old_avatars, 'DELETE FROM sessions', # delete old lua porom sessions + 'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system ] def run_migrations(): diff --git a/app/models.py b/app/models.py index a96f0c7..1d7297d 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ from .db import Model, db from .constants import PermissionLevel +from flask import current_app import time class Users(Model): @@ -48,7 +49,8 @@ class Users(Model): COUNT(DISTINCT posts.id) AS post_count, COUNT(DISTINCT threads.id) AS thread_count, MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title, - MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug + MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug, + inviter.username AS inviter_username FROM users LEFT JOIN posts ON posts.user_id = users.id LEFT JOIN threads ON threads.user_id = users.id @@ -57,6 +59,7 @@ class Users(Model): FROM threads GROUP BY user_id ) latest ON latest.user_id = users.id + LEFT JOIN users AS inviter ON inviter.id = users.invited_by WHERE users.id = ?""" return db.fetch_one(q, self.id) @@ -83,6 +86,18 @@ class Users(Model): return True + def can_invite(self): + if not current_app.config['DISABLE_SIGNUP']: + return True + + if current_app.config['MODS_CAN_INVITE'] and self.is_mod(): + return True + + if current_app.config['USERS_CAN_INVITE'] and not self.is_guest(): + return True + + return False + class Topics(Model): table = "topics" @@ -285,3 +300,7 @@ class Reactions(Model): class PasswordResetLinks(Model): table = "password_reset_links" + + +class InviteKeys(Model): + table = 'invite_keys' diff --git a/app/routes/users.py b/app/routes/users.py index 7011580..eba265c 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -4,7 +4,7 @@ from flask import ( from functools import wraps from ..db import db from ..lib.babycode import babycode_to_html -from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks +from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys from ..constants import InfoboxKind, PermissionLevel from ..auth import digest, verify from wand.image import Image @@ -195,32 +195,53 @@ def log_in_post(): @bp.get("/sign_up") @redirect_if_logged_in(".page", username = lambda: get_active_user().username) def sign_up(): + if current_app.config['DISABLE_SIGNUP']: + key = request.args.get('key', default=None) + if key is None: + return redirect(url_for('topics.all_topics')) + + 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", inviter=inviter, key=key) + return render_template("users/sign_up.html") @bp.post("/sign_up") @redirect_if_logged_in(".page", username = lambda: get_active_user().username) def sign_up_post(): + key = request.form.get('key', default=None) + + if current_app.config['DISABLE_SIGNUP']: + if not key: + return redirect(url_for("topics.all_topics")) + invite_key = InviteKeys.find({'key': key}) + if not invite_key: + return redirect(url_for("topics.all_topics")) + username = request.form['username'] password = request.form['password'] password_confirm = request.form['password-confirm'] if not validate_username(username): flash("Invalid username.", InfoboxKind.ERROR) - return redirect(url_for("users.sign_up")) + return redirect(url_for("users.sign_up", key=key)) user_exists = Users.count({"username": username}) > 0 if user_exists: flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR) - return redirect(url_for("users.sign_up")) + return redirect(url_for("users.sign_up", key=key)) if not validate_password(password): flash("Invalid password.", InfoboxKind.ERROR) - return redirect(url_for("users.sign_up")) + return redirect(url_for("users.sign_up", key=key)) if password != password_confirm: flash("Passwords do not match.", InfoboxKind.ERROR) - return redirect(url_for("users.sign_up")) + return redirect(url_for("users.sign_up", key=key)) hashed = digest(password) @@ -230,11 +251,19 @@ def sign_up_post(): "permission": PermissionLevel.GUEST.value, }) + if current_app.config['DISABLE_SIGNUP']: + invite_key = InviteKeys.find({'key': key}) + new_user.update({ + 'invited_by': invite_key.created_by, + 'permission': PermissionLevel.USER.value, + }) + invite_key.delete() + session_obj = create_session(new_user.id) session['pyrom_session_key'] = session_obj.key flash("Signed up successfully!", InfoboxKind.INFO) - return redirect(url_for("users.sign_up")) + return redirect(url_for("topics.all_topics")) @bp.get("/") @@ -573,3 +602,69 @@ def reset_link_login_form(key): flash("Logged in!", InfoboxKind.INFO) return redirect(url_for('.page', username=target_user.username)) + + +@bp.get('//invite-links/') +@login_required +def invite_links(username): + target_user = Users.find({ + 'username': username + }) + if not target_user or not target_user.can_invite(): + return redirect(url_for('.page', username=username)) + + if target_user.username != get_active_user().username: + return redirect(url_for('.invite_links', username=target_user.username)) + + invites = InviteKeys.findall({ + 'created_by': target_user.id + }) + + return render_template('users/invite_links.html', invites=invites) + + +@bp.post('//invite-links/create') +@login_required +def create_invite_link(username): + target_user = Users.find({ + 'username': username + }) + if not target_user or not target_user.can_invite(): + return redirect(url_for('.page', username=username)) + + if target_user.username != get_active_user().username: + return redirect(url_for('.invite_links', username=target_user.username)) + + invite = InviteKeys.create({ + 'created_by': target_user.id, + 'key': secrets.token_urlsafe(20), + }) + + return redirect(url_for('.invite_links', username=target_user.username)) + + +@bp.post('//invite-links/revoke') +@login_required +def revoke_invite_link(username): + target_user = Users.find({ + 'username': username + }) + if not target_user or not target_user.can_invite(): + return redirect(url_for('.page', username=username)) + + if target_user.username != get_active_user().username: + return redirect(url_for('.invite_links', username=target_user.username)) + + invite = InviteKeys.find({ + 'key': request.form.get('key'), + }) + + if not invite: + return redirect(url_for('.invite_links', username=target_user.username)) + + if invite.created_by != target_user.id: + return redirect(url_for('.invite_links', username=target_user.username)) + + invite.delete() + + return redirect(url_for('.invite_links', username=target_user.username)) diff --git a/app/schema.py b/app/schema.py index c40cbdf..7351947 100644 --- a/app/schema.py +++ b/app/schema.py @@ -90,6 +90,12 @@ SCHEMA = [ "key" TEXT NOT NULL UNIQUE )""", + """CREATE TABLE IF NOT EXISTS "invite_keys" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "created_by" REFERENCES users(id) ON DELETE CASCADE, + "key" TEXT NOT NULL UNIQUE + )""", + # INDEXES "CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)", "CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)", diff --git a/app/templates/common/topnav.html b/app/templates/common/topnav.html index 95ac9c4..2a85d08 100644 --- a/app/templates/common/topnav.html +++ b/app/templates/common/topnav.html @@ -4,7 +4,11 @@ {% if not is_logged_in() %} + {% if not config.DISABLE_SIGNUP %} Welcome, guest. Please sign up or log in + {% else %} + Welcome, guest. Please log in + {% endif %} {% else %} {% with user = get_active_user() %} Welcome, {{user.username}} @@ -12,6 +16,10 @@ SettingsInbox + {% if config.DISABLE_SIGNUP and user.can_invite() %} + • + Invite to {{ config.SITE_NAME }} + {% endif %} {% if user.is_mod() %} • User list diff --git a/app/templates/users/invite_links.html b/app/templates/users/invite_links.html new file mode 100644 index 0000000..cff1638 --- /dev/null +++ b/app/templates/users/invite_links.html @@ -0,0 +1,36 @@ +{% from 'common/macros.html' import accordion %} +{% extends 'base.html' %} +{% block title %}invites{% endblock %} +{% block content %} +
+

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. Once an invite link is used to sign up, it can no longer be used.

+ {% call(section) accordion(disabled=invites | length == 0) %} + {% if section == 'header' %} + Your invites + {% else %} + {% if invites %} + + + + + + {% for invite in invites %} + + + + + {% endfor %} +
LinkRevoke
Link +
+ + +
+
+ {% endif %} + {% endif %} + {% endcall %} +
+ +
+
+{% endblock %} diff --git a/app/templates/users/sign_up.html b/app/templates/users/sign_up.html index 96f1a2a..2429566 100644 --- a/app/templates/users/sign_up.html +++ b/app/templates/users/sign_up.html @@ -3,7 +3,13 @@ {% block content %} {% endblock %} diff --git a/app/templates/users/user.html b/app/templates/users/user.html index 641603c..dc68d41 100644 --- a/app/templates/users/user.html +++ b/app/templates/users/user.html @@ -64,6 +64,9 @@ {% if stats.latest_thread_title %}
  • Latest started thread: {{ stats.latest_thread_title }} {% endif %} + {% if stats.inviter_username %} +
  • Invited by {{ stats.inviter_username }}
  • + {% endif %} {% endwith %} Latest posts: diff --git a/config/pyrom_config.toml b/config/pyrom_config.toml index 8658e52..97a5f3e 100644 --- a/config/pyrom_config.toml +++ b/config/pyrom_config.toml @@ -1 +1,8 @@ SITE_NAME = "Porom" +DISABLE_SIGNUP = false # if true, no one can sign up. + +# if neither of the following two options is true, +# no one can sign up. this may be useful later when/if LDAP is implemented. + +MODS_CAN_INVITE = true # if true, allows moderators to create invite links. useless unless DISABLE_SIGNUP to be true. +USERS_CAN_INVITE = false # if true, allows users to create invite links. useless unless DISABLE_SIGNUP to be true.