Compare commits

...

4 Commits

Author SHA1 Message Date
a0c86f33b4 finish that a tag in topics view 2025-08-11 17:46:22 +03:00
712782bc1c add invite system 2025-08-11 17:26:15 +03:00
1c80777fe4 add config file 2025-08-10 19:31:00 +03:00
4c2877403d add a way for mods to create a password reset link for users 2025-08-10 19:00:47 +03:00
17 changed files with 344 additions and 13 deletions

View File

@ -3,6 +3,7 @@ from dotenv import load_dotenv
from .models import Avatars, Users from .models import Avatars, Users
from .auth import digest from .auth import digest
from .routes.users import is_logged_in, get_active_user from .routes.users import is_logged_in, get_active_user
from .routes.threads import get_post_url
from .constants import ( from .constants import (
PermissionLevel, permission_level_string, PermissionLevel, permission_level_string,
InfoboxKind, InfoboxIcons, InfoboxHTMLClass, InfoboxKind, InfoboxIcons, InfoboxHTMLClass,
@ -13,6 +14,7 @@ from datetime import datetime
import os import os
import time import time
import secrets import secrets
import tomllib
def create_default_avatar(): def create_default_avatar():
if Avatars.count() == 0: if Avatars.count() == 0:
@ -48,6 +50,7 @@ def create_deleted_user():
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False)
if os.getenv("PYROM_PROD") is None: if os.getenv("PYROM_PROD") is None:
app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static") app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static")
@ -114,6 +117,12 @@ def create_app():
def inject_auth(): def inject_auth():
return {"is_logged_in": is_logged_in, "get_active_user": get_active_user, "active_user": get_active_user()} return {"is_logged_in": is_logged_in, "get_active_user": get_active_user, "active_user": get_active_user()}
@app.context_processor
def inject_funcs():
return {
'get_post_url': get_post_url,
}
@app.template_filter("ts_datetime") @app.template_filter("ts_datetime")
def ts_datetime(ts, format): def ts_datetime(ts, format):
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format) return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)

View File

@ -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:

View File

@ -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():

View File

@ -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"
@ -281,3 +296,11 @@ class Reactions(Model):
""" """
return db.query(q, post_id, reaction_text) return db.query(q, post_id, reaction_text)
class PasswordResetLinks(Model):
table = "password_reset_links"
class InviteKeys(Model):
table = 'invite_keys'

View File

@ -1,9 +1,12 @@
from flask import ( from flask import (
Blueprint, render_template, request, redirect, url_for Blueprint, render_template, request, redirect, url_for
) )
from .users import login_required, mod_only, get_active_user from .users import login_required, mod_only, get_active_user, admin_only
from ..models import Users from ..models import Users, PasswordResetLinks
from ..db import db, DB from ..db import db, DB
import secrets
import time
bp = Blueprint("mod", __name__, url_prefix = "/mod/") bp = Blueprint("mod", __name__, url_prefix = "/mod/")
@bp.get("/sort-topics") @bp.get("/sort-topics")
@ -31,3 +34,18 @@ def sort_topics_post():
def user_list(): def user_list():
users = Users.select() users = Users.select()
return render_template("mod/user-list.html", users = users) return render_template("mod/user-list.html", users = users)
@bp.post("/reset-pass/<user_id>")
@login_required
@mod_only("topics.all_topics")
def create_reset_pass(user_id):
now = int(time.time())
key = secrets.token_urlsafe(20)
reset_link = PasswordResetLinks.create({
'user_id': int(user_id),
'expires_at': now + 24 * 60 * 60,
'key': key,
})
return redirect(url_for('users.reset_link_login', key=key))

View File

@ -13,6 +13,20 @@ import time
bp = Blueprint("threads", __name__, url_prefix = "/threads/") bp = Blueprint("threads", __name__, url_prefix = "/threads/")
def get_post_url(post_id, _anchor=False):
post = Posts.find({'id': post_id})
if not post:
return ""
thread = Threads.find({'id': post.thread_id})
res = url_for('threads.thread', slug=thread.slug, after=post_id)
if not _anchor:
return res
return f"{res}#post-{post_id}"
@bp.get("/<slug>") @bp.get("/<slug>")
def thread(slug): def thread(slug):
POSTS_PER_PAGE = 10 POSTS_PER_PAGE = 10

View File

@ -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 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>")
@ -516,3 +545,126 @@ def inbox(username):
}) })
return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions) return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions)
@bp.get('/reset-link/<key>')
def reset_link_login(key):
reset_link = PasswordResetLinks.find({
'key': key
})
if not reset_link:
return redirect(url_for('topics.all_topics'))
if int(time.time()) > int(reset_link.expires_at):
reset_link.delete()
return redirect(url_for('topics.all_topics'))
target_user = Users.find({
'id': reset_link.user_id
})
return render_template('users/reset_link_login.html', username = target_user.username)
@bp.post('/reset-link/<key>')
def reset_link_login_form(key):
reset_link = PasswordResetLinks.find({
'key': key
})
if not reset_link:
return redirect('topics.all_topics')
if int(time.time()) > int(reset_link.expires_at):
reset_link.delete()
return redirect('topics.all_topics')
password = request.form.get('password')
password2 = request.form.get('password2')
if not validate_password(password):
flash("Invalid password.", InfoboxKind.ERROR)
return redirect(url_for('.reset_link_login', key=key))
if password != password2:
flash("Passwords do not match.", InfoboxKind.ERROR)
return redirect(url_for('.reset_link_login', key=key))
target_user = Users.find({
'id': reset_link.user_id
})
reset_link.delete()
hashed = digest(password)
target_user.update({'password_hash': hashed})
session_obj = create_session(target_user.id)
session['pyrom_session_key'] = session_obj.key
flash("Logged in!", InfoboxKind.INFO)
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))

View File

@ -83,6 +83,19 @@ SCHEMA = [
"reaction_text" TEXT NOT NULL DEFAULT '' "reaction_text" TEXT NOT NULL DEFAULT ''
)""", )""",
"""CREATE TABLE IF NOT EXISTS "password_reset_links" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"expires_at" INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
"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)",

View File

@ -4,9 +4,9 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
{% if self.title() %} {% if self.title() %}
<title>Porom - {% block title %}{% endblock %}</title> <title>{{config.SITE_NAME}} - {% block title %}{% endblock %}</title>
{% else %} {% else %}
<title>Porom</title> <title>{{config.SITE_NAME}}</title>
{% endif %} {% endif %}
<link rel="stylesheet" href="{{ "/static/style.css" | cachebust }}"> <link rel="stylesheet" href="{{ "/static/style.css" | cachebust }}">
<link rel="icon" type="image/png" href="/static/favicon.png"> <link rel="icon" type="image/png" href="/static/favicon.png">

View File

@ -1,10 +1,14 @@
<nav id="topnav"> <nav id="topnav">
<span> <span>
<a class="site-title" href="{{url_for('topics.all_topics')}}">Porom</a> <a class="site-title" href="{{url_for('topics.all_topics')}}">{{config.SITE_NAME}}</a>
</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>
&bullet; &bullet;
<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() %}
&bullet;
<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() %}
&bullet; &bullet;
<a href="{{ url_for("mod.user_list") }}">User list</a> <a href="{{ url_for("mod.user_list") }}">User list</a>

View File

@ -37,6 +37,9 @@
<th>Username</th> <th>Username</th>
<th class="small">Permission</th> <th class="small">Permission</th>
<th class="small">Signed up on</th> <th class="small">Signed up on</th>
{% if active_user.is_admin() %}
<th class="small">Create password reset link</th>
{% endif %}
</thead> </thead>
{% for user in not_guests %} {% for user in not_guests %}
<tr> <tr>
@ -50,6 +53,13 @@
<td> <td>
{{ timestamp(user.created_at) }} {{ timestamp(user.created_at) }}
</td> </td>
{% if active_user.is_admin() %}
<td>
<form method="post" action="{{url_for('mod.create_reset_pass', user_id=user.id)}}">
<input type="submit" class="warn" value="Create password reset link">
</form>
</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@ -23,7 +23,7 @@
{% if topic['id'] in active_threads %} {% if topic['id'] in active_threads %}
{% with thread=active_threads[topic['id']] %} {% with thread=active_threads[topic['id']] %}
<span> <span>
Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['username'] }}</a> at <a href="">{{ timestamp(thread['post_created_at']) }}</a> Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['username'] }}</a> at <a href="{{ get_post_url(thread.post_id, _anchor=true) }}">{{ timestamp(thread['post_created_at']) }}</a>
</span> </span>
{% endwith %} {% endwith %}
{% endif %} {% endif %}

View 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 %}

View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %}Reset password{% endblock %}
{% block content %}
<div class="darkbg login-container">
<h1>Reset password for {{username}}</h1>
<p>Send this link to {{username}} to allow them to reset their password.</p>
<form method="post">
<label for="password">New password</label><br>
<input type="password" id="password" name="password" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required><br>
<label for="password2">Confirm password</label><br>
<input type="password" id="password2" name="password2" autocomplete="new-password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required><br>
<input type="submit" value="Reset password">
</form>
</div>
{% endblock %}

View File

@ -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 %}

View File

@ -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:

8
config/pyrom_config.toml Normal file
View File

@ -0,0 +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.