add invite system
This commit is contained in:
13
app/db.py
13
app/db.py
@ -201,6 +201,19 @@ class Model:
|
|||||||
return instance
|
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
|
@classmethod
|
||||||
def create(cls, values):
|
def create(cls, values):
|
||||||
if not values:
|
if not values:
|
||||||
|
@ -9,6 +9,7 @@ def migrate_old_avatars():
|
|||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
migrate_old_avatars,
|
migrate_old_avatars,
|
||||||
'DELETE FROM sessions', # delete old lua porom sessions
|
'DELETE FROM sessions', # delete old lua porom sessions
|
||||||
|
'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system
|
||||||
]
|
]
|
||||||
|
|
||||||
def run_migrations():
|
def run_migrations():
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from .db import Model, db
|
from .db import Model, db
|
||||||
from .constants import PermissionLevel
|
from .constants import PermissionLevel
|
||||||
|
from flask import current_app
|
||||||
import time
|
import time
|
||||||
|
|
||||||
class Users(Model):
|
class Users(Model):
|
||||||
@ -48,7 +49,8 @@ class Users(Model):
|
|||||||
COUNT(DISTINCT posts.id) AS post_count,
|
COUNT(DISTINCT posts.id) AS post_count,
|
||||||
COUNT(DISTINCT threads.id) AS thread_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.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
|
FROM users
|
||||||
LEFT JOIN posts ON posts.user_id = users.id
|
LEFT JOIN posts ON posts.user_id = users.id
|
||||||
LEFT JOIN threads ON threads.user_id = users.id
|
LEFT JOIN threads ON threads.user_id = users.id
|
||||||
@ -57,6 +59,7 @@ class Users(Model):
|
|||||||
FROM threads
|
FROM threads
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
) latest ON latest.user_id = users.id
|
) latest ON latest.user_id = users.id
|
||||||
|
LEFT JOIN users AS inviter ON inviter.id = users.invited_by
|
||||||
WHERE users.id = ?"""
|
WHERE users.id = ?"""
|
||||||
return db.fetch_one(q, self.id)
|
return db.fetch_one(q, self.id)
|
||||||
|
|
||||||
@ -83,6 +86,18 @@ class Users(Model):
|
|||||||
|
|
||||||
return True
|
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):
|
class Topics(Model):
|
||||||
table = "topics"
|
table = "topics"
|
||||||
@ -285,3 +300,7 @@ class Reactions(Model):
|
|||||||
|
|
||||||
class PasswordResetLinks(Model):
|
class PasswordResetLinks(Model):
|
||||||
table = "password_reset_links"
|
table = "password_reset_links"
|
||||||
|
|
||||||
|
|
||||||
|
class InviteKeys(Model):
|
||||||
|
table = 'invite_keys'
|
||||||
|
@ -4,7 +4,7 @@ from flask import (
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from ..db import db
|
from ..db import db
|
||||||
from ..lib.babycode import babycode_to_html
|
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 ..constants import InfoboxKind, PermissionLevel
|
||||||
from ..auth import digest, verify
|
from ..auth import digest, verify
|
||||||
from wand.image import Image
|
from wand.image import Image
|
||||||
@ -195,32 +195,53 @@ def log_in_post():
|
|||||||
@bp.get("/sign_up")
|
@bp.get("/sign_up")
|
||||||
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
|
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
|
||||||
def sign_up():
|
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")
|
return render_template("users/sign_up.html")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sign_up")
|
@bp.post("/sign_up")
|
||||||
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
|
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
|
||||||
def sign_up_post():
|
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']
|
username = request.form['username']
|
||||||
password = request.form['password']
|
password = request.form['password']
|
||||||
password_confirm = request.form['password-confirm']
|
password_confirm = request.form['password-confirm']
|
||||||
|
|
||||||
if not validate_username(username):
|
if not validate_username(username):
|
||||||
flash("Invalid username.", InfoboxKind.ERROR)
|
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
|
user_exists = Users.count({"username": username}) > 0
|
||||||
if user_exists:
|
if user_exists:
|
||||||
flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR)
|
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):
|
if not validate_password(password):
|
||||||
flash("Invalid password.", InfoboxKind.ERROR)
|
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:
|
if password != password_confirm:
|
||||||
flash("Passwords do not match.", InfoboxKind.ERROR)
|
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)
|
hashed = digest(password)
|
||||||
|
|
||||||
@ -230,11 +251,19 @@ def sign_up_post():
|
|||||||
"permission": PermissionLevel.GUEST.value,
|
"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_obj = create_session(new_user.id)
|
||||||
|
|
||||||
session['pyrom_session_key'] = session_obj.key
|
session['pyrom_session_key'] = session_obj.key
|
||||||
flash("Signed up successfully!", InfoboxKind.INFO)
|
flash("Signed up successfully!", InfoboxKind.INFO)
|
||||||
return redirect(url_for("users.sign_up"))
|
return redirect(url_for("topics.all_topics"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<username>")
|
@bp.get("/<username>")
|
||||||
@ -573,3 +602,69 @@ def reset_link_login_form(key):
|
|||||||
flash("Logged in!", InfoboxKind.INFO)
|
flash("Logged in!", InfoboxKind.INFO)
|
||||||
|
|
||||||
return redirect(url_for('.page', username=target_user.username))
|
return redirect(url_for('.page', username=target_user.username))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/<username>/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('/<username>/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('/<username>/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))
|
||||||
|
@ -90,6 +90,12 @@ SCHEMA = [
|
|||||||
"key" TEXT NOT NULL UNIQUE
|
"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
|
# INDEXES
|
||||||
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
|
"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)",
|
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)",
|
||||||
|
@ -4,7 +4,11 @@
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{% if not is_logged_in() %}
|
{% if not is_logged_in() %}
|
||||||
|
{% if not config.DISABLE_SIGNUP %}
|
||||||
Welcome, guest. Please <a href="{{url_for('users.sign_up')}}">sign up</a> or <a href="{{url_for('users.log_in')}}">log in</a>
|
Welcome, guest. Please <a href="{{url_for('users.sign_up')}}">sign up</a> or <a href="{{url_for('users.log_in')}}">log in</a>
|
||||||
|
{% else %}
|
||||||
|
Welcome, guest. Please <a href="{{url_for('users.log_in')}}">log in</a>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% with user = get_active_user() %}
|
{% with user = get_active_user() %}
|
||||||
Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.username}}</a>
|
Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.username}}</a>
|
||||||
@ -12,6 +16,10 @@
|
|||||||
<a href="{{ url_for("users.settings", username = user.username) }}">Settings</a>
|
<a href="{{ url_for("users.settings", username = user.username) }}">Settings</a>
|
||||||
•
|
•
|
||||||
<a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a>
|
<a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a>
|
||||||
|
{% if config.DISABLE_SIGNUP and user.can_invite() %}
|
||||||
|
•
|
||||||
|
<a href="{{ url_for('users.invite_links', username=user.username )}}">Invite to {{ config.SITE_NAME }}</a>
|
||||||
|
{% endif %}
|
||||||
{% if user.is_mod() %}
|
{% if user.is_mod() %}
|
||||||
•
|
•
|
||||||
<a href="{{ url_for("mod.user_list") }}">User list</a>
|
<a href="{{ url_for("mod.user_list") }}">User list</a>
|
||||||
|
36
app/templates/users/invite_links.html
Normal file
36
app/templates/users/invite_links.html
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{% from 'common/macros.html' import accordion %}
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}invites{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="darkbg inbox-container">
|
||||||
|
<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. Once an invite link is used to sign up, it can no longer be used.</p>
|
||||||
|
{% call(section) accordion(disabled=invites | length == 0) %}
|
||||||
|
{% if section == 'header' %}
|
||||||
|
Your invites
|
||||||
|
{% else %}
|
||||||
|
{% if invites %}
|
||||||
|
<table class="colorful-table">
|
||||||
|
<thead>
|
||||||
|
<th class='small'>Link</th>
|
||||||
|
<th class='small'>Revoke</th>
|
||||||
|
</thead>
|
||||||
|
{% for invite in invites %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{url_for('users.sign_up', key=invite.key)}}">Link</a></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('users.revoke_invite_link', username=active_user.username) }}">
|
||||||
|
<input type=hidden value="{{ invite.key }}" name="key">
|
||||||
|
<input type=submit class=warn value="Revoke">
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endcall %}
|
||||||
|
<form method="post" action="{{ url_for('users.create_invite_link', username=active_user.username) }}">
|
||||||
|
<input type=submit value="Create new invite">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -3,7 +3,13 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="darkbg login-container">
|
<div class="darkbg login-container">
|
||||||
<h1>Sign up</h1>
|
<h1>Sign up</h1>
|
||||||
|
{% if inviter %}
|
||||||
|
<p>You have been invited by <a href="{{ url_for('users.page', username=inviter.username) }}">{{ inviter.username }}</a> to join {{ config.SITE_NAME }}. Create an identity below.</p>
|
||||||
|
{% endif %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
{% if key %}
|
||||||
|
<input type="hidden" value={{key}} name="key">
|
||||||
|
{% endif %}
|
||||||
<label for="username">Username</label><br>
|
<label for="username">Username</label><br>
|
||||||
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_-]{3,20}" title="3-20 characters. Only upper and lowercase letters, digits, hyphens, and underscores" required autocomplete="username"><br>
|
<input type="text" id="username" name="username" pattern="[a-zA-Z0-9_-]{3,20}" title="3-20 characters. Only upper and lowercase letters, digits, hyphens, and underscores" required autocomplete="username"><br>
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
@ -12,6 +18,8 @@
|
|||||||
<input type="password" id="password-confirm" name="password-confirm" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
<input type="password" id="password-confirm" name="password-confirm" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
||||||
<input type="submit" value="Sign up">
|
<input type="submit" value="Sign up">
|
||||||
</form>
|
</form>
|
||||||
|
{% if not inviter %}
|
||||||
<span>After you sign up, a moderator will need to confirm your account before you will be allowed to post.</span>
|
<span>After you sign up, a moderator will need to confirm your account before you will be allowed to post.</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -64,6 +64,9 @@
|
|||||||
{% if stats.latest_thread_title %}
|
{% if stats.latest_thread_title %}
|
||||||
<li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a>
|
<li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if stats.inviter_username %}
|
||||||
|
<li>Invited by <a href="{{ url_for('users.page', username=stats.inviter_username) }}">{{ stats.inviter_username }}</a></li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
Latest posts:
|
Latest posts:
|
||||||
|
@ -1 +1,8 @@
|
|||||||
SITE_NAME = "Porom"
|
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.
|
||||||
|
Reference in New Issue
Block a user