diff --git a/db/sqlite.py b/db/sqlite.py index 8394006..bf0fc4d 100644 --- a/db/sqlite.py +++ b/db/sqlite.py @@ -5,6 +5,12 @@ class DB: self.conn = conn pass + def get_config(self): + return self._db().execute(''' + select version, name, description, secret_key, captcha_key, registration_enabled from config + ''' + ).fetchone() + def get_forums(self): return self._db().execute(''' select f.forum_id, name, description, thread_id, title, update_time @@ -29,15 +35,18 @@ class DB: (forum_id,) ).fetchone() - def get_threads(self, forum_id): + def get_threads(self, forum_id, offset, limit): return self._db().execute(''' select t.thread_id, title, t.create_time, t.update_time, t.author_id, name, count(c.thread_id) from threads t, users left join comments c on t.thread_id = c.thread_id where forum_id = ? and user_id = t.author_id group by t.thread_id + order by t.update_time desc + limit ? + offset ? ''', - (forum_id,) + (forum_id, limit, offset) ) def get_thread(self, thread): @@ -129,6 +138,24 @@ class DB: (username,) ).fetchone() + def get_user_password_by_id(self, user_id): + return self._db().execute(''' + select password + from users + where user_id = ? + ''', + (user_id,) + ).fetchone() + + def set_user_password(self, user_id, password): + return self.change_one(''' + update users + set password = ? + where user_id = ? + ''', + (password, user_id) + ) + def get_user_public_info(self, user_id): return self._db().execute(''' select name, about @@ -140,7 +167,7 @@ class DB: def get_user_private_info(self, user_id): return self._db().execute(''' - select name, about + select about from users where user_id = ? ''', @@ -158,6 +185,15 @@ class DB: ) db.commit() + def get_user_name_role_banned(self, user_id): + return self._db().execute(''' + select name, role, banned_until + from users + where user_id = ? + ''', + (user_id,) + ).fetchone() + def get_user_name(self, user_id): return self._db().execute(''' select name @@ -173,11 +209,15 @@ class DB: c.execute(''' insert into threads (author_id, forum_id, title, text, create_time, modify_time, update_time) - values (?, ?, ?, ?, ?, ?, ?) + select ?, ?, ?, ?, ?, ?, ? + from users + where user_id = ? and banned_until < ? ''', - (author_id, forum_id, title, text, time, time, time) + (author_id, forum_id, title, text, time, time, time, author_id, time) ) rowid = c.lastrowid + if rowid is None: + return None db.commit() return db.execute(''' select thread_id @@ -193,9 +233,13 @@ class DB: c.execute(''' delete from threads - where thread_id = ? and author_id = ? + -- 1 = moderator, 2 = admin + where thread_id = ? and ( + author_id = ? + or (select 1 from users where user_id = ? and (role = 1 or role = 2)) + ) ''', - (thread_id, user_id) + (thread_id, user_id, user_id) ) db.commit() return c.rowcount > 0 @@ -206,9 +250,16 @@ class DB: c.execute(''' delete from comments - where comment_id = ? and author_id = ? + where comment_id = ? + and ( + author_id = ? + -- 1 = moderator, 2 = admin + or (select 1 from users where user_id = ? and (role = 1 or role = 2)) + ) + -- Don't allow deleting comments with children + and (select 1 from comments where parent_id = ?) is null ''', - (comment_id, user_id) + (comment_id, user_id, user_id, comment_id) ) db.commit() return c.rowcount > 0 @@ -219,10 +270,10 @@ class DB: c.execute(''' insert into comments(thread_id, author_id, text, create_time, modify_time) select ?, ?, ?, ?, ? - from threads - where thread_id = ? + from threads, users + where thread_id = ? and user_id = ? and banned_until < ? ''', - (thread_id, author_id, text, time, time, thread_id) + (thread_id, author_id, text, time, time, thread_id, author_id, time) ) if c.rowcount > 0: print('SHIT') @@ -243,10 +294,10 @@ class DB: c.execute(''' insert into comments(thread_id, parent_id, author_id, text, create_time, modify_time) select thread_id, ?, ?, ?, ?, ? - from comments - where comment_id = ? + from comments, users + where comment_id = ? and user_id = ? and banned_until < ? ''', - (parent_id, author_id, text, time, time, parent_id) + (parent_id, author_id, text, time, time, parent_id, author_id, time) ) if c.rowcount > 0: c.execute(''' @@ -270,9 +321,18 @@ class DB: c.execute(''' update threads set title = ?, text = ?, modify_time = ? - where thread_id = ? and author_id = ? + where thread_id = ? and ( + (author_id = ? and (select 1 from users where user_id = ? and banned_until < ?)) + -- 1 = moderator, 2 = admin + or (select 1 from users where user_id = ? and (role = 1 or role = 2)) + ) ''', - (title, text, time, thread_id, user_id) + ( + title, text, time, + thread_id, + user_id, user_id, time, + user_id, + ) ) if c.rowcount > 0: db.commit() @@ -285,16 +345,51 @@ class DB: c.execute(''' update comments set text = ?, modify_time = ? - where comment_id = ? and author_id = ? + where comment_id = ? and ( + (author_id = ? and (select 1 from users where user_id = ? and banned_until < ?)) + -- 1 = moderator, 2 = admin + or (select 1 from users where user_id = ? and (role = 1 or role = 2)) + ) ''', - (text, time, comment_id, user_id) + ( + text, time, + comment_id, + user_id, user_id, time, + user_id, + ) ) if c.rowcount > 0: db.commit() return True return False + def register_user(self, username, password, time): + ''' + Add a user if registrations are enabled. + ''' + try: + db = self._db() + c = db.cursor() + c.execute(''' + insert into users(name, password, join_time) + select lower(?), ?, ? + from config + where registration_enabled = 1 + ''', + (username, password, time) + ) + if c.rowcount > 0: + db.commit() + return True + return False + except sqlite3.IntegrityError: + # User already exists, probably + return False + def add_user(self, username, password, time): + ''' + Add a user without checking if registrations are enabled. + ''' try: db = self._db() c = db.cursor() @@ -312,5 +407,81 @@ class DB: # User already exists, probably return False + def get_users(self): + return self._db().execute(''' + select user_id, name, join_time, role, banned_until + from users + ''', + ) + + def set_forum_name(self, forum_id, name): + return self.change_one(''' + update forums + set name = ? + where forum_id = ? + ''', + (name, forum_id) + ) + + def set_forum_description(self, forum_id, description): + return self.change_one(''' + update forums + set description = ? + where forum_id = ? + ''', + (description, forum_id) + ) + + def add_forum(self, name, description): + db = self._db() + db.execute(''' + insert into forums(name, description) + values (?, ?) + ''', + (name, description) + ) + db.commit() + + def set_config(self, server_name, server_description, registration_enabled): + return self.change_one(''' + update config + set name = ?, description = ?, registration_enabled = ? + ''', + (server_name, server_description, registration_enabled) + ) + + def set_config_secrets(self, secret_key, captcha_key): + return self.change_one(''' + update config + set secret_key = ?, captcha_key = ? + ''', + (secret_key, captcha_key) + ) + + def set_user_ban(self, user_id, until): + return self.change_one(''' + update users + set banned_until = ? + where user_id = ? + ''', + (until, user_id) + ) + + def change_one(self, query, values): + db = self._db() + c = db.cursor() + c.execute(query, values) + if c.rowcount > 0: + db.commit() + return True + return False + + def query(self, q): + db = self._db() + c = db.cursor() + rows = c.execute(q) + db.commit() + return rows, c.rowcount + def _db(self): - return sqlite3.connect(self.conn) + return sqlite3.connect(self.conn, timeout=5) diff --git a/init_sqlite.sh b/init_sqlite.sh new file mode 100755 index 0000000..e0dd679 --- /dev/null +++ b/init_sqlite.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +SQLITE=sqlite3 + +set -e + +if [ $# != 1 ] +then + echo "Usage: $0 " >&2 + exit 1 +fi + +$SQLITE $1 -init schema.txt "insert into config ( + version, + name, + description, + secret_key, + captcha_key, + registration_enabled +) +values ( + 'agreper-v0.1', + 'Agreper', + '', + '$(head -c 30 /dev/urandom | base64)', + '$(head -c 30 /dev/urandom | base64)', + 0 +);" diff --git a/main.py b/main.py index 371733d..9e67c05 100644 --- a/main.py +++ b/main.py @@ -1,35 +1,63 @@ +VERSION = 'agreper-v0.1' +# TODO put in config table +THREADS_PER_PAGE = 50 + from flask import Flask, render_template, session, request, redirect, url_for, flash, g from db.sqlite import DB -import os -import passlib.hash +import os, sys, subprocess +import passlib.hash, secrets import time from datetime import datetime import captcha app = Flask(__name__) db = DB(os.getenv('DB')) -NAME = 'Agrepy' -# TODO config file -app.config['SECRET_KEY'] = 'totally random' -captcha_key = 'piss off bots' -app.jinja_env.trim_blocks = True -app.jinja_env.lstrip_blocks = True +class Config: + pass +config = Config() +config.version, config.server_name, config.server_description, app.config['SECRET_KEY'], config.captcha_key, config.registration_enabled = db.get_config() + +if config.version != VERSION: + print(f'Incompatible version {config.version} (expected {VERSION})') + sys.exit(1) + +class Role: + USER = 0 + MODERATOR = 1 + ADMIN = 2 @app.route('/') def index(): - return render_template('index.html', title = NAME, forums = db.get_forums()) + return render_template( + 'index.html', + title = config.server_name, + description = config.server_description, + config = config, + user = get_user(), + forums = db.get_forums() + ) @app.route('/forum//') def forum(forum_id): title, description = db.get_forum(forum_id) - threads = db.get_threads(forum_id) + offset = int(request.args.get('p', 0)) + threads = [*db.get_threads(forum_id, offset, THREADS_PER_PAGE + 1)] + if len(threads) == THREADS_PER_PAGE + 1: + threads.pop() + next_page = offset + THREADS_PER_PAGE + else: + next_page = None return render_template( 'forum.html', title = title, + user = get_user(), + config = config, forum_id = forum_id, description = description, threads = threads, + next_page = next_page, + prev_page = max(offset - THREADS_PER_PAGE, 0) if offset > 0 else None, ) @app.route('/thread//') @@ -40,6 +68,8 @@ def thread(thread_id): return render_template( 'thread.html', title = title, + config = config, + user = get_user(), text = text, author = author, author_id = author_id, @@ -51,7 +81,6 @@ def thread(thread_id): @app.route('/comment//') def comment(comment_id): - user_id = session.get('user_id') thread_id, parent_id, title, comments = db.get_subcomments(comment_id) comments = create_comment_tree(comments) reply_comment, = comments @@ -60,6 +89,8 @@ def comment(comment_id): return render_template( 'comments.html', title = title, + config = config, + user = get_user(), reply_comment = reply_comment, comments = comments, parent_id = parent_id, @@ -80,8 +111,12 @@ def login(): # Sleep to reduce effectiveness of bruteforce time.sleep(0.1) flash('Username or password is invalid', 'error') - return render_template('login.html', title = "Login") - return render_template('login.html', title = "Login") + return render_template( + 'login.html', + title = 'Login', + config = config, + user = get_user() + ) @app.route('/logout/') def logout(): @@ -90,24 +125,44 @@ def logout(): @app.route('/user/', methods = ['GET', 'POST']) def user_edit(): + user = get_user() + if user is None: + return redirect(url_for('login')) + + if request.method == 'POST': + about = trim_text(request.form['about']) + db.set_user_private_info(user.id, about) + flash('Updated profile', 'success') + else: + about, = db.get_user_private_info(user.id) + + return render_template( + 'user_edit.html', + title = 'Edit profile', + config = config, + user = user, + about = about + ) + +@app.route('/user/edit/password/', methods = ['POST']) +def user_edit_password(): user_id = session.get('user_id') if user_id is None: return redirect(url_for('login')) - if request.method == 'POST': - about = request.form['about'].replace('\r', '') - db.set_user_private_info(user_id, about) - name, = db.get_user_name(user_id) - flash('Updated profile', 'success') + new = request.form['new'] + if len(new) < 8: + flash('New password must be at least 8 characters long', 'error') else: - name, about = db.get_user_private_info(user_id) - - return render_template( - 'user_edit.html', - name = name, - title = 'Edit profile', - about = about - ) + hash, = db.get_user_password_by_id(user_id) + if verify_password(request.form['old'], hash): + if db.set_user_password(user_id, hash_password(new)): + flash('Updated password', 'success') + else: + flash('Failed to update password', 'error') + else: + flash('Old password does not match', 'error') + return redirect(url_for('user_edit')) @app.route('/user//') def user_info(user_id): @@ -115,6 +170,8 @@ def user_info(user_id): return render_template( 'user_info.html', title = 'Profile', + config = config, + user = get_user(), name = name, about = about ) @@ -126,13 +183,24 @@ def new_thread(forum_id): return redirect(url_for('login')) if request.method == 'POST': - id, = db.add_thread(user_id, forum_id, request.form['title'], request.form['text'].replace('\r', ''), time.time_ns()) - flash('Created thread', 'success') - return redirect(url_for('thread', thread_id = id)) + title, text = request.form['title'], trim_text(request.form['text']) + if title == '' or text == '': + flash('Title and text may not be empty', 'error') + return redirect(url_for('forum', forum_id = forum_id)) + id = db.add_thread(user_id, forum_id, title, text, time.time_ns()) + if id is None: + flash('Failed to create thread', 'error') + return redirect(url_for('forum', forum_id = forum_id)) + else: + id, = id + flash('Created thread', 'success') + return redirect(url_for('thread', thread_id = id)) return render_template( 'new_thread.html', title = 'Create new thread', + config = config, + user = get_user(), ) @app.route('/thread//confirm_delete/') @@ -141,6 +209,8 @@ def confirm_delete_thread(thread_id): return render_template( 'confirm_delete_thread.html', title = 'Delete thread', + config = config, + user = get_user(), thread_title = title, ) @@ -163,7 +233,10 @@ def add_comment(thread_id): if user_id is None: return redirect(url_for('login')) - if db.add_comment_to_thread(thread_id, user_id, request.form['text'].replace('\r', ''), time.time_ns()): + text = trim_text(request.form['text']) + if text == '': + flash('Text may not be empty', 'error') + elif db.add_comment_to_thread(thread_id, user_id, text, time.time_ns()): flash('Added comment', 'success') else: flash('Failed to add comment', 'error') @@ -175,7 +248,10 @@ def add_comment_parent(comment_id): if user_id is None: return redirect(url_for('login')) - if db.add_comment_to_comment(comment_id, user_id, request.form['text'].replace('\r', ''), time.time_ns()): + text = trim_text(request.form['text']) + if text == '': + flash('Text may not be empty', 'error') + elif db.add_comment_to_comment(comment_id, user_id, text, time.time_ns()): flash('Added comment', 'success') else: flash('Failed to add comment', 'error') @@ -187,6 +263,8 @@ def confirm_delete_comment(comment_id): return render_template( 'confirm_delete_comment.html', title = 'Delete comment', + config = config, + user = get_user(), thread_title = title, text = text, ) @@ -211,11 +289,14 @@ def edit_thread(thread_id): return redirect(url_for('login')) if request.method == 'POST': - if db.modify_thread( + title, text = request.form['title'], trim_text(request.form['text']) + if title == '' or text == '': + flash('Title and text may not be empty', 'error') + elif db.modify_thread( thread_id, user_id, - request.form['title'], - request.form['text'].replace('\r', ''), + title, + text, time.time_ns(), ): flash('Thread has been edited', 'success') @@ -228,6 +309,8 @@ def edit_thread(thread_id): return render_template( 'edit_thread.html', title = 'Edit thread', + config = config, + user = get_user(), thread_title = title, text = text, ) @@ -239,10 +322,13 @@ def edit_comment(comment_id): return redirect(url_for('login')) if request.method == 'POST': - if db.modify_comment( + text = trim_text(request.form['text']) + if text == '': + flash('Text may not be empty', 'error') + elif db.modify_comment( comment_id, user_id, - request.form['text'].replace('\r', ''), + trim_text(request.form['text']), time.time_ns(), ): flash('Comment has been edited', 'success') @@ -255,6 +341,8 @@ def edit_comment(comment_id): return render_template( 'edit_comment.html', title = 'Edit comment', + config = config, + user = get_user(), thread_title = title, text = text, ) @@ -268,25 +356,198 @@ def register(): elif len(password) < 8: flash('Password must be at least 8 characters long', 'error') elif not captcha.verify( - captcha_key, + config.captcha_key, request.form['captcha'], request.form['answer'], ): flash('CAPTCHA answer is incorrect', 'error') - elif not db.add_user(username, hash_password(password), time.time_ns()): + elif not db.register_user(username, hash_password(password), time.time_ns()): flash('Failed to create account (username may already be taken)', 'error') else: flash('Account has been created. You can login now.', 'success') return redirect(url_for('index')) - capt, answer = captcha.generate(captcha_key) + capt, answer = captcha.generate(config.captcha_key) return render_template( 'register.html', title = 'Register', + config = config, + user = get_user(), captcha = capt, answer = answer, ) +@app.route('/admin/') +def admin(): + chk, user = _admin_check() + if not chk: + return user + + return render_template( + 'admin/index.html', + title = 'Admin panel', + config = config, + forums = db.get_forums(), + users = db.get_users(), + ) + +@app.route('/admin/query/', methods = ['GET', 'POST']) +def admin_query(): + chk, user = _admin_check() + if not chk: + return user + + try: + rows, rowcount = db.query(request.form['q']) if request.method == 'POST' else [] + if rowcount > 0: + flash(f'{rowcount} rows changed', 'success') + except Exception as e: + flash(e, 'error') + rows = [] + return render_template( + 'admin/query.html', + title = 'Query', + config = config, + rows = rows, + ) + +@app.route('/admin/forum//edit//', methods = ['POST']) +def admin_edit_forum(forum_id, what): + chk, user = _admin_check() + if not chk: + return user + + try: + if what == 'description': + res = db.set_forum_description(forum_id, trim_text(request.form['description'])) + elif what == 'name': + res = db.set_forum_name(forum_id, request.form['name']) + else: + flash(f'Unknown property "{what}"', 'error') + res = None + if res is True: + flash(f'Updated {what}', 'success') + elif res is False: + flash(f'Failed to update {what}', 'error') + except Exception as e: + flash(e, 'error') + return redirect(url_for('admin')) + +@app.route('/admin/forum/new/', methods = ['POST']) +def admin_new_forum(): + chk, user = _admin_check() + if not chk: + return user + + try: + db.add_forum(request.form['name'], trim_text(request.form['description'])) + flash('Added forum', 'success') + except Exception as e: + flash(str(e), 'error') + return redirect(url_for('admin')) + +@app.route('/admin/config/edit/', methods = ['POST']) +def admin_edit_config(): + chk, user = _admin_check() + if not chk: + return user + + try: + db.set_config( + request.form['server_name'], + trim_text(request.form['server_description']), + 'registration_enabled' in request.form, + ) + flash('Updated config. Refresh the page to see the changes.', 'success') + restart() + except Exception as e: + flash(str(e), 'error') + return redirect(url_for('admin')) + +@app.route('/admin/config/new_secrets/', methods = ['POST']) +def admin_new_secrets(): + chk, user = _admin_check() + if not chk: + return user + + secret_key = secrets.token_urlsafe(30) + captcha_key = secrets.token_urlsafe(30) + try: + db.set_config_secrets(secret_key, captcha_key) + flash('Changed secrets. You will be logged out.', 'success') + restart() + except Exception as e: + flash(str(e), 'error') + return redirect(url_for('admin')) + +@app.route('/admin/user//ban/', methods = ['POST']) +def admin_ban_user(user_id): + chk, user = _admin_check() + if not chk: + return user + + d, t = request.form['days'], request.form['time'] + d = 0 if d == '' else int(d) + h, m = (0, 0) if t == '' else map(int, t.split(':')) + until = time.time_ns() + (d * 24 * 60 + h * 60 + m) * (60 * 10**9) + until = min(until, 0xffff_ffff_ffff_ffff) + + try: + if db.set_user_ban(user_id, until): + flash('Banned user', 'success') + else: + flash('Failed to ban user', 'error') + except Exception as e: + flash(str(e), 'error') + return redirect(url_for('admin')) + +@app.route('/admin/user//unban/', methods = ['POST']) +def admin_unban_user(user_id): + chk, user = _admin_check() + if not chk: + return user + + try: + if db.set_user_ban(user_id, None): + flash('Unbanned user', 'success') + else: + flash('Failed to unban user', 'error') + except Exception as e: + flash(str(e), 'error') + return redirect(url_for('admin')) + +@app.route('/admin/user/new/', methods = ['POST']) +def admin_new_user(): + try: + name, password = request.form['name'], request.form['password'] + if name == '' or password == '': + flash('Name and password may not be empty') + elif db.add_user(name, hash_password(password), time.time_ns()): + flash('Added user', 'success') + else: + flash('Failed to add user', 'error') + except Exception as e: + flash(str(e), 'error') + return redirect(url_for('admin')) + +@app.route('/admin/restart/', methods = ['POST']) +def admin_restart(): + chk, user = _admin_check() + if not chk: + return user + + restart() + return redirect(url_for('admin')) + + +def _admin_check(): + user = get_user() + if user is None: + return False, redirect(url_for('login')) + if not user.is_admin(): + return False, ('

Forbidden

', 403) + return True, user + class Comment: def __init__(self, id, author_id, author, text, create_time, modify_time, parent_id): @@ -326,43 +587,77 @@ def create_comment_tree(comments): return root +class User: + def __init__(self, id, name, role, banned_until): + self.id = id + self.name = name + self.role = role + self.banned_until = banned_until + + def is_moderator(self): + return self.role in (Role.ADMIN, Role.MODERATOR) + + def is_admin(self): + return self.role == Role.ADMIN + + def is_banned(self): + return self.banned_until > time.time_ns() + +def get_user(): + id = session.get('user_id') + if id is not None: + name, role, banned_until = db.get_user_name_role_banned(id) + return User(id, name, role, banned_until) + return None + + @app.context_processor def utility_processor(): - def format_since(t): - n = time.time_ns() - if n < t: - return 'In a distant future' - + def _format_time_delta(n, t): # Try the sane thing first dt = (n - t) // 10 ** 9 if dt < 1: - return "less than a second ago" + return "less than a second" if dt < 2: - return f"1 second ago" + return f"1 second" if dt < 60: - return f"{dt} seconds ago" + return f"{dt} seconds" if dt < 119: - return f"1 minute ago" + return f"1 minute" if dt < 3600: - return f"{dt // 60} minutes ago" + return f"{dt // 60} minutes" if dt < 3600 * 2: - return f"1 hour ago" + return f"1 hour" if dt < 3600 * 24: - return f"{dt // 3600} hours ago" + return f"{dt // 3600} hours" if dt < 3600 * 24 * 31: - return f"{dt // (3600 * 24)} days ago" + return f"{dt // (3600 * 24)} days" # Try some very rough estimate, whatever f = lambda x: datetime.utcfromtimestamp(x // 10 ** 9) n, t = f(n), f(t) def f(x, y, s): - return f'{y - x} {s}{"s" if y - x > 1 else ""} ago' + return f'{y - x} {s}{"s" if y - x > 1 else ""}' if t.year < n.year: return f(t.year, n.year, "year") if t.month < n.month: return f(t.month, n.month, "month") - # This shouldn't be reachable, but it's still better to return something - return "incredibly long ago" + assert False, 'unreachable' + + def format_since(t): + n = time.time_ns() + if n < t: + return 'in a distant future' + return _format_time_delta(n, t) + ' ago' + + def format_until(t): + n = time.time_ns() + if t <= n: + return 'in a distant past' + return _format_time_delta(t, n) + + def format_time(t): + return datetime.utcfromtimestamp(t / 10 ** 9).replace(microsecond=0) def minimd(text): # Replace angle brackets to prevent XSS @@ -377,6 +672,8 @@ def utility_processor(): return { 'format_since': format_since, + 'format_time': format_time, + 'format_until': format_until, 'minimd': minimd, } @@ -386,3 +683,24 @@ def hash_password(password): def verify_password(password, hash): return passlib.hash.argon2.verify(password, hash) + + +def restart(): + ''' + Shut down *all* workers and spawn new ones. + This is necessary on e.g. a configuration change. + + Since restarting workers depends is platform-dependent this task is delegated to an external + program. + ''' + r = subprocess.call(['./restart.sh']) + if r == 0: + flash('Restart script exited successfully', 'success') + else: + flash(f'Restart script exited with error (code {r})', 'error') + +def trim_text(s): + ''' + Because browsers LOVE \\r, trailing whitespace etc. + ''' + return s.strip().replace('\r', '') diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..b6023e3 --- /dev/null +++ b/restart.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +# This script is intended for dev environments only. +touch main.py diff --git a/schema.txt b/schema.txt index 596bfca..fdd8869 100644 --- a/schema.txt +++ b/schema.txt @@ -1,11 +1,20 @@ +create table config ( + version text not null, + name text not null, + description text not null, + secret_key text not null, + captcha_key text not null, + registration_enabled boolean not null +); + create table users ( user_id integer unique not null primary key autoincrement, name varchar(32) unique not null, password varchar(128) not null, - email varchar(254), about text not null default '', join_time integer not null, - role integer not null default 0 + role integer not null default 0, + banned_until integer not null default 0 ); create table threads ( @@ -34,10 +43,9 @@ create table comments ( ); create table forums ( - forum_id integer unique not null primary key autoincrement, + forum_id integer unique not null primary key autoincrement, name varchar(64) not null, - description text, - allowed_roles_mask integer not null + description text not null default '' ); -- Both of these speed up searches significantly if there are many threads or comments. diff --git a/templates/admin/base.html b/templates/admin/base.html new file mode 100644 index 0000000..a864555 --- /dev/null +++ b/templates/admin/base.html @@ -0,0 +1,45 @@ +{# Don't use the default theme to emphasize this page is special -#} + + +{{ title }} + + + + + +

{{ title }}

+

+Admin panel +Home page +

+{%- for category, msg in get_flashed_messages(True) -%} +

{{ msg }}

+{%- endfor %} +{%- block content %}{% endblock -%} + diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..90a0447 --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,125 @@ +{% extends 'admin/base.html' -%} +{% block content -%} +

Query

+

⚠ Only use queries if you know what you're doing ⚠

+
+ + +
+

Configuration

+
+ + + + + + + + + + + + + +
Server name
Server description
Registration enabled
+ +
+

+

+ +
+

+

+

+ +
+

+

Forums

+ + + + + + + +{% for id, name, description, _, _, _ in forums %} + + + + + +{% endfor %} +
IDNameDescriptionActions
{{ id }} +
+ + +
+
+
+ + +
+
Remove
+

Add forum

+
+ + + + + + + + + +
Name
Description
+ +
+

Users

+ + + + + + + + +{%- for id, name, join_date, role, banned_until in users -%} + + + + + + + +{%- endfor -%} +
IDNameJoin dateRoleBanned
{{ id }}{{ name }}{{ format_time(join_date) }} +
+ + +
+
+{%- if banned_until > 0 -%} +
+{{- format_time(banned_until) }} + +
+{%- endif -%} +
+ + + +
+
+

Add user

+
+ + + +
Name
Password
+ +
+{%- endblock %} diff --git a/templates/admin/query.html b/templates/admin/query.html new file mode 100644 index 0000000..8c89a92 --- /dev/null +++ b/templates/admin/query.html @@ -0,0 +1,17 @@ +{% extends 'admin/base.html' -%} +{% block content -%} +
+ + +
+ +{%- for r in rows -%} + +{%- for c in r -%} + +{%- endfor -%} + +{%- endfor -%} +
{{ c }}
+{%- endblock -%} + diff --git a/templates/base.html b/templates/base.html index 331a161..9bfe742 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,21 +9,30 @@

{{ title }}

- {% for category, msg in get_flashed_messages(True) %} + {%- for category, msg in get_flashed_messages(True) -%}

{{ msg }}

- {% endfor %} - {% block content %}{% endblock %} + {%- endfor -%} + {%- block content %}{% endblock -%}
diff --git a/templates/comment.html b/templates/comment.html index 49558af..c52cef9 100644 --- a/templates/comment.html +++ b/templates/comment.html @@ -2,7 +2,7 @@ {{ name }} - {{ format_since(ctime) }}{% if ctime != mtime %} (last modified {{ format_since(mtime) }}){% endif %} {%- endmacro -%} -{%- macro comment_author(comment, thread_id) -%} +{%- macro comment_author(comment, thread_id, can_delete) -%}

{{- author(comment.author_id, comment.author, comment.create_time, comment.modify_time) }} | {# Suffixing a # prevents unnecessary reloads #} @@ -10,30 +10,28 @@ {%- if comment.parent_id is not none -%} parent {%- endif -%} - {%- if comment.author_id == session.get('user_id') -%} + {%- if user is not none and (comment.author_id == user.id or user.is_moderator()) and not user.is_banned() -%} edit - {%- endif -%} - {%- if comment.author_id == session.get('user_id') -%} + {%- if can_delete -%} delete {%- endif -%} + {%- endif -%}

{%- endmacro -%} {%- macro thread_author(author_id, name, ctime, mtime) -%}

{{- author(author_id, name, ctime, mtime) -}} - {%- if author_id == session.get('user_id') -%} + {%- if user is not none and (author_id == user.id or user.is_moderator()) and not user.is_banned() -%} edit - {%- endif -%} - {%- if author_id == session.get('user_id') -%} delete {%- endif -%}

{%- endmacro -%} -{%- macro render_comment_pre(comment, thread_id) -%} +{%- macro render_comment_pre(comment, thread_id, can_delete) -%}
- {{- comment_author(comment, thread_id) -}} + {{- comment_author(comment, thread_id, can_delete) -}}

{{- minimd(comment.text) | safe -}}

{%- endmacro -%} @@ -45,13 +43,13 @@ {%- endmacro -%} {%- macro render_comment(comment, thread_id) -%} -{{- render_comment_pre(comment, thread_id) -}} +{{- render_comment_pre(comment, thread_id, comment.children | length == 0) -}} reply {{- render_comment_post(comment, thread_id) -}} {%- endmacro -%} {%- macro reply() -%} -{%- if 'user_id' in session -%} +{%- if user is not none and not user.is_banned() -%}

diff --git a/templates/comments.html b/templates/comments.html index ff864f9..2e44ff4 100644 --- a/templates/comments.html +++ b/templates/comments.html @@ -3,7 +3,7 @@ {% block content %} -{{ render_comment_pre(reply_comment, thread_id) }} +{{ render_comment_pre(reply_comment, thread_id, comments | length == 0) }} {{ reply() }} diff --git a/templates/forum.html b/templates/forum.html index 22c440b..102a386 100644 --- a/templates/forum.html +++ b/templates/forum.html @@ -1,8 +1,17 @@ {% extends 'base.html' %} -{% block content %} +{%- macro nav() -%} +

+{%- if prev_page is not none %}prev{% endif -%} +{%- if prev_page is not none and next_page is not none %} | {% endif -%} +{%- if next_page is not none %}next{% endif -%} +

+{%- endmacro -%} + +{% block content -%}

{{ description }}

Create thread

+{{- nav() -}} @@ -19,6 +28,7 @@ - {% endfor %} + {%- endfor -%}
Topic{{ format_since(utime) }} {{ comment_count }}
+{{- nav() -}} {% endblock %} diff --git a/templates/index.html b/templates/index.html index 6725f52..e6e91c8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,6 +1,7 @@ {% extends 'base.html' %} -{% block content %} +{%- block content %} +

{{ minimd(description) | safe }}

@@ -10,7 +11,7 @@ {% if t_id %} {% endif %} - {% endfor %} + {%- endfor -%}
Forum

{{ name }}

-

{{ description }}

+

{{ minimd(description) | safe }}

@@ -21,6 +22,6 @@ No threads
-{% endblock %} +{%- endblock %} diff --git a/templates/login.html b/templates/login.html index 9180fbd..8acb77c 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,9 +1,11 @@ {% extends 'base.html' %} {% block content %} - -

Username:

-

Password:

- + + + + +
Username
Password
+
{% endblock %} diff --git a/templates/register.html b/templates/register.html index 5106b63..be15687 100644 --- a/templates/register.html +++ b/templates/register.html @@ -1,6 +1,7 @@ {% extends 'base.html' %} {%- block content %} +{%- if config.registration_enabled -%}