Merge branch 'admin'

This commit is contained in:
David Hoppenbrouwers
2022-10-09 16:11:19 +02:00
18 changed files with 876 additions and 129 deletions

View File

@@ -5,6 +5,12 @@ class DB:
self.conn = conn self.conn = conn
pass 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): def get_forums(self):
return self._db().execute(''' return self._db().execute('''
select f.forum_id, name, description, thread_id, title, update_time select f.forum_id, name, description, thread_id, title, update_time
@@ -29,15 +35,18 @@ class DB:
(forum_id,) (forum_id,)
).fetchone() ).fetchone()
def get_threads(self, forum_id): def get_threads(self, forum_id, offset, limit):
return self._db().execute(''' return self._db().execute('''
select t.thread_id, title, t.create_time, t.update_time, t.author_id, name, count(c.thread_id) select t.thread_id, title, t.create_time, t.update_time, t.author_id, name, count(c.thread_id)
from threads t, users from threads t, users
left join comments c on t.thread_id = c.thread_id left join comments c on t.thread_id = c.thread_id
where forum_id = ? and user_id = t.author_id where forum_id = ? and user_id = t.author_id
group by t.thread_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): def get_thread(self, thread):
@@ -129,6 +138,24 @@ class DB:
(username,) (username,)
).fetchone() ).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): def get_user_public_info(self, user_id):
return self._db().execute(''' return self._db().execute('''
select name, about select name, about
@@ -140,7 +167,7 @@ class DB:
def get_user_private_info(self, user_id): def get_user_private_info(self, user_id):
return self._db().execute(''' return self._db().execute('''
select name, about select about
from users from users
where user_id = ? where user_id = ?
''', ''',
@@ -158,6 +185,15 @@ class DB:
) )
db.commit() 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): def get_user_name(self, user_id):
return self._db().execute(''' return self._db().execute('''
select name select name
@@ -173,11 +209,15 @@ class DB:
c.execute(''' c.execute('''
insert into threads (author_id, forum_id, title, text, insert into threads (author_id, forum_id, title, text,
create_time, modify_time, update_time) 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 rowid = c.lastrowid
if rowid is None:
return None
db.commit() db.commit()
return db.execute(''' return db.execute('''
select thread_id select thread_id
@@ -193,9 +233,13 @@ class DB:
c.execute(''' c.execute('''
delete delete
from threads 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() db.commit()
return c.rowcount > 0 return c.rowcount > 0
@@ -206,9 +250,16 @@ class DB:
c.execute(''' c.execute('''
delete delete
from comments 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() db.commit()
return c.rowcount > 0 return c.rowcount > 0
@@ -219,10 +270,10 @@ class DB:
c.execute(''' c.execute('''
insert into comments(thread_id, author_id, text, create_time, modify_time) insert into comments(thread_id, author_id, text, create_time, modify_time)
select ?, ?, ?, ?, ? select ?, ?, ?, ?, ?
from threads from threads, users
where thread_id = ? 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: if c.rowcount > 0:
print('SHIT') print('SHIT')
@@ -243,10 +294,10 @@ class DB:
c.execute(''' c.execute('''
insert into comments(thread_id, parent_id, author_id, text, create_time, modify_time) insert into comments(thread_id, parent_id, author_id, text, create_time, modify_time)
select thread_id, ?, ?, ?, ?, ? select thread_id, ?, ?, ?, ?, ?
from comments from comments, users
where comment_id = ? 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: if c.rowcount > 0:
c.execute(''' c.execute('''
@@ -270,9 +321,18 @@ class DB:
c.execute(''' c.execute('''
update threads update threads
set title = ?, text = ?, modify_time = ? 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: if c.rowcount > 0:
db.commit() db.commit()
@@ -285,16 +345,51 @@ class DB:
c.execute(''' c.execute('''
update comments update comments
set text = ?, modify_time = ? 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: if c.rowcount > 0:
db.commit() db.commit()
return True return True
return False 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): def add_user(self, username, password, time):
'''
Add a user without checking if registrations are enabled.
'''
try: try:
db = self._db() db = self._db()
c = db.cursor() c = db.cursor()
@@ -312,5 +407,81 @@ class DB:
# User already exists, probably # User already exists, probably
return False 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): def _db(self):
return sqlite3.connect(self.conn) return sqlite3.connect(self.conn, timeout=5)

28
init_sqlite.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
SQLITE=sqlite3
set -e
if [ $# != 1 ]
then
echo "Usage: $0 <file>" >&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
);"

428
main.py
View File

@@ -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 flask import Flask, render_template, session, request, redirect, url_for, flash, g
from db.sqlite import DB from db.sqlite import DB
import os import os, sys, subprocess
import passlib.hash import passlib.hash, secrets
import time import time
from datetime import datetime from datetime import datetime
import captcha import captcha
app = Flask(__name__) app = Flask(__name__)
db = DB(os.getenv('DB')) db = DB(os.getenv('DB'))
NAME = 'Agrepy'
# TODO config file class Config:
app.config['SECRET_KEY'] = 'totally random' pass
captcha_key = 'piss off bots' config = Config()
app.jinja_env.trim_blocks = True config.version, config.server_name, config.server_description, app.config['SECRET_KEY'], config.captcha_key, config.registration_enabled = db.get_config()
app.jinja_env.lstrip_blocks = True
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('/') @app.route('/')
def index(): 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/<int:forum_id>/') @app.route('/forum/<int:forum_id>/')
def forum(forum_id): def forum(forum_id):
title, description = db.get_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( return render_template(
'forum.html', 'forum.html',
title = title, title = title,
user = get_user(),
config = config,
forum_id = forum_id, forum_id = forum_id,
description = description, description = description,
threads = threads, threads = threads,
next_page = next_page,
prev_page = max(offset - THREADS_PER_PAGE, 0) if offset > 0 else None,
) )
@app.route('/thread/<int:thread_id>/') @app.route('/thread/<int:thread_id>/')
@@ -40,6 +68,8 @@ def thread(thread_id):
return render_template( return render_template(
'thread.html', 'thread.html',
title = title, title = title,
config = config,
user = get_user(),
text = text, text = text,
author = author, author = author,
author_id = author_id, author_id = author_id,
@@ -51,7 +81,6 @@ def thread(thread_id):
@app.route('/comment/<int:comment_id>/') @app.route('/comment/<int:comment_id>/')
def comment(comment_id): def comment(comment_id):
user_id = session.get('user_id')
thread_id, parent_id, title, comments = db.get_subcomments(comment_id) thread_id, parent_id, title, comments = db.get_subcomments(comment_id)
comments = create_comment_tree(comments) comments = create_comment_tree(comments)
reply_comment, = comments reply_comment, = comments
@@ -60,6 +89,8 @@ def comment(comment_id):
return render_template( return render_template(
'comments.html', 'comments.html',
title = title, title = title,
config = config,
user = get_user(),
reply_comment = reply_comment, reply_comment = reply_comment,
comments = comments, comments = comments,
parent_id = parent_id, parent_id = parent_id,
@@ -80,8 +111,12 @@ def login():
# Sleep to reduce effectiveness of bruteforce # Sleep to reduce effectiveness of bruteforce
time.sleep(0.1) time.sleep(0.1)
flash('Username or password is invalid', 'error') flash('Username or password is invalid', 'error')
return render_template('login.html', title = "Login") return render_template(
return render_template('login.html', title = "Login") 'login.html',
title = 'Login',
config = config,
user = get_user()
)
@app.route('/logout/') @app.route('/logout/')
def logout(): def logout():
@@ -90,24 +125,44 @@ def logout():
@app.route('/user/', methods = ['GET', 'POST']) @app.route('/user/', methods = ['GET', 'POST'])
def user_edit(): 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') user_id = session.get('user_id')
if user_id is None: if user_id is None:
return redirect(url_for('login')) return redirect(url_for('login'))
if request.method == 'POST': new = request.form['new']
about = request.form['about'].replace('\r', '') if len(new) < 8:
db.set_user_private_info(user_id, about) flash('New password must be at least 8 characters long', 'error')
name, = db.get_user_name(user_id)
flash('Updated profile', 'success')
else: else:
name, about = db.get_user_private_info(user_id) hash, = db.get_user_password_by_id(user_id)
if verify_password(request.form['old'], hash):
return render_template( if db.set_user_password(user_id, hash_password(new)):
'user_edit.html', flash('Updated password', 'success')
name = name, else:
title = 'Edit profile', flash('Failed to update password', 'error')
about = about else:
) flash('Old password does not match', 'error')
return redirect(url_for('user_edit'))
@app.route('/user/<int:user_id>/') @app.route('/user/<int:user_id>/')
def user_info(user_id): def user_info(user_id):
@@ -115,6 +170,8 @@ def user_info(user_id):
return render_template( return render_template(
'user_info.html', 'user_info.html',
title = 'Profile', title = 'Profile',
config = config,
user = get_user(),
name = name, name = name,
about = about about = about
) )
@@ -126,13 +183,24 @@ def new_thread(forum_id):
return redirect(url_for('login')) return redirect(url_for('login'))
if request.method == 'POST': if request.method == 'POST':
id, = db.add_thread(user_id, forum_id, request.form['title'], request.form['text'].replace('\r', ''), time.time_ns()) title, text = request.form['title'], trim_text(request.form['text'])
flash('Created thread', 'success') if title == '' or text == '':
return redirect(url_for('thread', thread_id = id)) 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( return render_template(
'new_thread.html', 'new_thread.html',
title = 'Create new thread', title = 'Create new thread',
config = config,
user = get_user(),
) )
@app.route('/thread/<int:thread_id>/confirm_delete/') @app.route('/thread/<int:thread_id>/confirm_delete/')
@@ -141,6 +209,8 @@ def confirm_delete_thread(thread_id):
return render_template( return render_template(
'confirm_delete_thread.html', 'confirm_delete_thread.html',
title = 'Delete thread', title = 'Delete thread',
config = config,
user = get_user(),
thread_title = title, thread_title = title,
) )
@@ -163,7 +233,10 @@ def add_comment(thread_id):
if user_id is None: if user_id is None:
return redirect(url_for('login')) 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') flash('Added comment', 'success')
else: else:
flash('Failed to add comment', 'error') flash('Failed to add comment', 'error')
@@ -175,7 +248,10 @@ def add_comment_parent(comment_id):
if user_id is None: if user_id is None:
return redirect(url_for('login')) 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') flash('Added comment', 'success')
else: else:
flash('Failed to add comment', 'error') flash('Failed to add comment', 'error')
@@ -187,6 +263,8 @@ def confirm_delete_comment(comment_id):
return render_template( return render_template(
'confirm_delete_comment.html', 'confirm_delete_comment.html',
title = 'Delete comment', title = 'Delete comment',
config = config,
user = get_user(),
thread_title = title, thread_title = title,
text = text, text = text,
) )
@@ -211,11 +289,14 @@ def edit_thread(thread_id):
return redirect(url_for('login')) return redirect(url_for('login'))
if request.method == 'POST': 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, thread_id,
user_id, user_id,
request.form['title'], title,
request.form['text'].replace('\r', ''), text,
time.time_ns(), time.time_ns(),
): ):
flash('Thread has been edited', 'success') flash('Thread has been edited', 'success')
@@ -228,6 +309,8 @@ def edit_thread(thread_id):
return render_template( return render_template(
'edit_thread.html', 'edit_thread.html',
title = 'Edit thread', title = 'Edit thread',
config = config,
user = get_user(),
thread_title = title, thread_title = title,
text = text, text = text,
) )
@@ -239,10 +322,13 @@ def edit_comment(comment_id):
return redirect(url_for('login')) return redirect(url_for('login'))
if request.method == 'POST': 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, comment_id,
user_id, user_id,
request.form['text'].replace('\r', ''), trim_text(request.form['text']),
time.time_ns(), time.time_ns(),
): ):
flash('Comment has been edited', 'success') flash('Comment has been edited', 'success')
@@ -255,6 +341,8 @@ def edit_comment(comment_id):
return render_template( return render_template(
'edit_comment.html', 'edit_comment.html',
title = 'Edit comment', title = 'Edit comment',
config = config,
user = get_user(),
thread_title = title, thread_title = title,
text = text, text = text,
) )
@@ -268,25 +356,198 @@ def register():
elif len(password) < 8: elif len(password) < 8:
flash('Password must be at least 8 characters long', 'error') flash('Password must be at least 8 characters long', 'error')
elif not captcha.verify( elif not captcha.verify(
captcha_key, config.captcha_key,
request.form['captcha'], request.form['captcha'],
request.form['answer'], request.form['answer'],
): ):
flash('CAPTCHA answer is incorrect', 'error') 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') flash('Failed to create account (username may already be taken)', 'error')
else: else:
flash('Account has been created. You can login now.', 'success') flash('Account has been created. You can login now.', 'success')
return redirect(url_for('index')) return redirect(url_for('index'))
capt, answer = captcha.generate(captcha_key) capt, answer = captcha.generate(config.captcha_key)
return render_template( return render_template(
'register.html', 'register.html',
title = 'Register', title = 'Register',
config = config,
user = get_user(),
captcha = capt, captcha = capt,
answer = answer, 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/<int:forum_id>/edit/<string:what>/', 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/<int:user_id>/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/<int:user_id>/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, ('<h1>Forbidden</h1>', 403)
return True, user
class Comment: class Comment:
def __init__(self, id, author_id, author, text, create_time, modify_time, parent_id): 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 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 @app.context_processor
def utility_processor(): def utility_processor():
def format_since(t): def _format_time_delta(n, t):
n = time.time_ns()
if n < t:
return 'In a distant future'
# Try the sane thing first # Try the sane thing first
dt = (n - t) // 10 ** 9 dt = (n - t) // 10 ** 9
if dt < 1: if dt < 1:
return "less than a second ago" return "less than a second"
if dt < 2: if dt < 2:
return f"1 second ago" return f"1 second"
if dt < 60: if dt < 60:
return f"{dt} seconds ago" return f"{dt} seconds"
if dt < 119: if dt < 119:
return f"1 minute ago" return f"1 minute"
if dt < 3600: if dt < 3600:
return f"{dt // 60} minutes ago" return f"{dt // 60} minutes"
if dt < 3600 * 2: if dt < 3600 * 2:
return f"1 hour ago" return f"1 hour"
if dt < 3600 * 24: if dt < 3600 * 24:
return f"{dt // 3600} hours ago" return f"{dt // 3600} hours"
if dt < 3600 * 24 * 31: if dt < 3600 * 24 * 31:
return f"{dt // (3600 * 24)} days ago" return f"{dt // (3600 * 24)} days"
# Try some very rough estimate, whatever # Try some very rough estimate, whatever
f = lambda x: datetime.utcfromtimestamp(x // 10 ** 9) f = lambda x: datetime.utcfromtimestamp(x // 10 ** 9)
n, t = f(n), f(t) n, t = f(n), f(t)
def f(x, y, s): 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: if t.year < n.year:
return f(t.year, n.year, "year") return f(t.year, n.year, "year")
if t.month < n.month: if t.month < n.month:
return f(t.month, n.month, "month") return f(t.month, n.month, "month")
# This shouldn't be reachable, but it's still better to return something assert False, 'unreachable'
return "incredibly long ago"
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): def minimd(text):
# Replace angle brackets to prevent XSS # Replace angle brackets to prevent XSS
@@ -377,6 +672,8 @@ def utility_processor():
return { return {
'format_since': format_since, 'format_since': format_since,
'format_time': format_time,
'format_until': format_until,
'minimd': minimd, 'minimd': minimd,
} }
@@ -386,3 +683,24 @@ def hash_password(password):
def verify_password(password, hash): def verify_password(password, hash):
return passlib.hash.argon2.verify(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', '')

4
restart.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# This script is intended for dev environments only.
touch main.py

View File

@@ -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 ( create table users (
user_id integer unique not null primary key autoincrement, user_id integer unique not null primary key autoincrement,
name varchar(32) unique not null, name varchar(32) unique not null,
password varchar(128) not null, password varchar(128) not null,
email varchar(254),
about text not null default '', about text not null default '',
join_time integer not null, 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 ( create table threads (
@@ -34,10 +43,9 @@ create table comments (
); );
create table forums ( 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, name varchar(64) not null,
description text, description text not null default ''
allowed_roles_mask integer not null
); );
-- Both of these speed up searches significantly if there are many threads or comments. -- Both of these speed up searches significantly if there are many threads or comments.

45
templates/admin/base.html Normal file
View File

@@ -0,0 +1,45 @@
{# Don't use the default theme to emphasize this page is special -#}
<!doctype html>
<head>
<title>{{ title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta content="utf-8" http-equiv="encoding">
<style>
body {
font-family: sans-serif;
}
.flash.success {
background-color: lightgreen;
border-radius: 5px;
padding: 8px;
}
.flash.error {
background-color: #ff4646;
border-radius: 5px;
padding: 8px;
}
table {
border-collapse: collapse;
width: 80%;
}
th, td {
border: 1px solid;
padding: 5px;
text-align: left;
}
textarea {
width: 95%;
}
</style>
</head>
<body>
<h1>{{ title }}</h1>
<p>
<a href="{{ url_for('admin') }}">Admin panel</a>
<a href="{{ url_for('index') }}">Home page</a>
</p>
{%- for category, msg in get_flashed_messages(True) -%}
<p class="flash {{ category }}">{{ msg }}</p>
{%- endfor %}
{%- block content %}{% endblock -%}
</body>

125
templates/admin/index.html Normal file
View File

@@ -0,0 +1,125 @@
{% extends 'admin/base.html' -%}
{% block content -%}
<h2>Query</h2>
<p>&#9888; Only use queries if you know what you're doing &#9888;</p>
<form action=query/ method=post>
<input type=text name=q placeholder="SELECT * from users">
<input type=submit value=Submit>
</form>
<h2>Configuration</h2>
<form action=config/edit/ method=post>
<table>
<tr>
<td>Server name</td>
<td><input type=text name=server_name value="{{ config.server_name }}"></td>
</tr>
<tr>
<td>Server description</td>
<td><textarea name=server_description>{{ config.server_description }}</textarea></td>
</tr>
<tr>
<td>Registration enabled</td>
<td><input name=registration_enabled type=checkbox {{ 'checked' if config.registration_enabled else '' }}></td>
</tr>
</table>
<input type=submit value=Update>
</form>
<p>
<form action=config/new_secrets/ method=post>
<input type=submit value="Generate new secrets">
</form>
</p>
<p>
<form action=restart/ method=post>
<input type=submit value="Restart">
</form>
</p>
<h2>Forums</h2>
<table>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>Actions</th>
</tr>
{% for id, name, description, _, _, _ in forums %}
<tr>
<td>{{ id }}</td>
<td>
<form method=post action="forum/{{ id }}/edit/name/">
<input type=text name=name value="{{ name }}"</input>
<input type=submit value="Set name">
</form>
<td>
<form method=post action="forum/{{ id }}/edit/description/">
<textarea name=description>{{ description }}</textarea>
<input type=submit value="Set description">
</form>
</td>
<td><a href="#">Remove</a></td>
</tr>
{% endfor %}
</table>
<h3>Add forum</h3>
<form method=post action="forum/new/">
<table>
<tr>
<td>Name</td>
<td><input type=text name=name></td>
</tr>
<tr>
<td>Description</td>
<td><textarea name=description></textarea></td>
</tr>
</table>
<input type=submit value="Add forum">
</form>
<h2>Users</h2>
<table>
<tr>
<th>ID</th>
<th>Name</th>
<th>Join date</th>
<th>Role</th>
<th>Banned</th>
</tr>
{%- for id, name, join_date, role, banned_until in users -%}
<tr>
<td>{{ id }}</td>
<td>{{ name }}</td>
<td>{{ format_time(join_date) }}</td>
<td>
<form method=post action="user/{{ id }}/edit/role/">
<select name=role>
<option value=0 {{ 'selected' if role == 0 else '' }}>user</option>
<option value=1 {{ 'selected' if role == 1 else '' }}>moderator</option>
<option value=2 {{ 'selected' if role == 2 else '' }}>admin</option>
</select>
<input type=submit value="Set role">
</form>
</td>
<td>
{%- if banned_until > 0 -%}
<form method=post action="user/{{ id }}/unban/">
{{- format_time(banned_until) }}
<input type=submit value=Unban>
</form>
{%- endif -%}
<form method=post action="user/{{ id }}/ban/">
<input type=number name=days placeholder=days>
<input type=time name=time>
<input type=submit value=Ban>
</form>
</td>
</tr>
{%- endfor -%}
</table>
<h3>Add user</h3>
<form method=post action=user/new/>
<table>
<tr><td>Name</td><td><input type=text name=name></td></tr>
<tr><td>Password</td><td><input type=password name=password></td></tr>
</table>
<input type=submit value="Add user">
</form>
{%- endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'admin/base.html' -%}
{% block content -%}
<form method=post>
<input type=text name=q placeholder="SELECT * from users">
<input type=submit value=Submit>
</form>
<table style="font-family:monospace;white-space:pre">
{%- for r in rows -%}
<tr>
{%- for c in r -%}
<td>{{ c }}</td>
{%- endfor -%}
</tr>
{%- endfor -%}
</table>
{%- endblock -%}

View File

@@ -9,21 +9,30 @@
<nav> <nav>
<a class=logo href="{{ url_for('index') }}">A</a> <a class=logo href="{{ url_for('index') }}">A</a>
<div style="margin:auto"></div> <div style="margin:auto"></div>
{% if 'user_id' in session %} {%- if user is not none -%}
<a href="{{ url_for('user_edit') }}">User panel</a> <a href="{{ url_for('user_edit') }}">
<span>|</span> {{- user.name }}
{%- if user.is_banned() %} (banned for {{ format_until(user.banned_until) }}){% endif -%}
</a>
<span> | </span>
{%- if user.is_admin() -%}
<a href="{{ url_for('admin') }}">Admin panel</a>
<span> | </span>
{%- endif -%}
<a href="{{ url_for('logout') }}">Logout</a> <a href="{{ url_for('logout') }}">Logout</a>
{% else %} {%- else -%}
{%- if config.registration_enabled -%}
<a href="{{ url_for('register') }}">Register</a> <a href="{{ url_for('register') }}">Register</a>
<span>|</span> <span> | </span>
{%- endif -%}
<a href="{{ url_for('login') }}">Login</a> <a href="{{ url_for('login') }}">Login</a>
{% endif %} {%- endif -%}
</nav> </nav>
<main> <main>
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
{% for category, msg in get_flashed_messages(True) %} {%- for category, msg in get_flashed_messages(True) -%}
<p class="flash {{ category }}">{{ msg }}</p> <p class="flash {{ category }}">{{ msg }}</p>
{% endfor %} {%- endfor -%}
{% block content %}{% endblock %} {%- block content %}{% endblock -%}
</main> </main>
</body> </body>

View File

@@ -2,7 +2,7 @@
<i><a href="{{ url_for('user_info', user_id = id) }}">{{ name }}</a> - {{ format_since(ctime) }}{% if ctime != mtime %} (last modified {{ format_since(mtime) }}){% endif %}</i> <i><a href="{{ url_for('user_info', user_id = id) }}">{{ name }}</a> - {{ format_since(ctime) }}{% if ctime != mtime %} (last modified {{ format_since(mtime) }}){% endif %}</i>
{%- endmacro -%} {%- endmacro -%}
{%- macro comment_author(comment, thread_id) -%} {%- macro comment_author(comment, thread_id, can_delete) -%}
<p><sub> <p><sub>
{{- author(comment.author_id, comment.author, comment.create_time, comment.modify_time) }} | {{- author(comment.author_id, comment.author, comment.create_time, comment.modify_time) }} |
{# Suffixing a # prevents unnecessary reloads #} {# Suffixing a # prevents unnecessary reloads #}
@@ -10,30 +10,28 @@
{%- if comment.parent_id is not none -%} {%- if comment.parent_id is not none -%}
<a href="{{ url_for('comment', comment_id = comment.parent_id) }}#"> parent</a> <a href="{{ url_for('comment', comment_id = comment.parent_id) }}#"> parent</a>
{%- endif -%} {%- 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() -%}
<a href="{{ url_for('edit_comment', comment_id = comment.id) }}"> edit</a> <a href="{{ url_for('edit_comment', comment_id = comment.id) }}"> edit</a>
{%- endif -%} {%- if can_delete -%}
{%- if comment.author_id == session.get('user_id') -%}
<a href="{{ url_for('confirm_delete_comment', comment_id = comment.id) }}"> delete</a> <a href="{{ url_for('confirm_delete_comment', comment_id = comment.id) }}"> delete</a>
{%- endif -%} {%- endif -%}
{%- endif -%}
</sub></p> </sub></p>
{%- endmacro -%} {%- endmacro -%}
{%- macro thread_author(author_id, name, ctime, mtime) -%} {%- macro thread_author(author_id, name, ctime, mtime) -%}
<p><sub> <p><sub>
{{- 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() -%}
<a href="{{ url_for('edit_thread', thread_id = thread_id) }}"> edit</a> <a href="{{ url_for('edit_thread', thread_id = thread_id) }}"> edit</a>
{%- endif -%}
{%- if author_id == session.get('user_id') -%}
<a href="{{ url_for('confirm_delete_thread', thread_id = thread_id) }}"> delete</a> <a href="{{ url_for('confirm_delete_thread', thread_id = thread_id) }}"> delete</a>
{%- endif -%} {%- endif -%}
</sub></p> </sub></p>
{%- endmacro -%} {%- endmacro -%}
{%- macro render_comment_pre(comment, thread_id) -%} {%- macro render_comment_pre(comment, thread_id, can_delete) -%}
<div class=comment> <div class=comment>
{{- comment_author(comment, thread_id) -}} {{- comment_author(comment, thread_id, can_delete) -}}
<p>{{- minimd(comment.text) | safe -}}</p> <p>{{- minimd(comment.text) | safe -}}</p>
{%- endmacro -%} {%- endmacro -%}
@@ -45,13 +43,13 @@
{%- endmacro -%} {%- endmacro -%}
{%- macro render_comment(comment, thread_id) -%} {%- macro render_comment(comment, thread_id) -%}
{{- render_comment_pre(comment, thread_id) -}} {{- render_comment_pre(comment, thread_id, comment.children | length == 0) -}}
<sup><a href="{{ url_for("comment", comment_id = comment.id) }}">reply</a></sup> <sup><a href="{{ url_for("comment", comment_id = comment.id) }}">reply</a></sup>
{{- render_comment_post(comment, thread_id) -}} {{- render_comment_post(comment, thread_id) -}}
{%- endmacro -%} {%- endmacro -%}
{%- macro reply() -%} {%- macro reply() -%}
{%- if 'user_id' in session -%} {%- if user is not none and not user.is_banned() -%}
<form method="post" action="comment/"> <form method="post" action="comment/">
<p><textarea name="text"></textarea></p> <p><textarea name="text"></textarea></p>
<p><input type="submit" value="Post comment"></p> <p><input type="submit" value="Post comment"></p>

View File

@@ -3,7 +3,7 @@
{% block content %} {% block content %}
{{ render_comment_pre(reply_comment, thread_id) }} {{ render_comment_pre(reply_comment, thread_id, comments | length == 0) }}
{{ reply() }} {{ reply() }}

View File

@@ -1,8 +1,17 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {%- macro nav() -%}
<p style=text-align:center>
{%- if prev_page is not none %}<a href="./?p={{ prev_page }}">prev</a>{% endif -%}
{%- if prev_page is not none and next_page is not none %} | {% endif -%}
{%- if next_page is not none %}<a href="./?p={{ next_page }}">next</a>{% endif -%}
</p>
{%- endmacro -%}
{% block content -%}
<p>{{ description }}</p> <p>{{ description }}</p>
<p><a href="{{ url_for('new_thread', forum_id = forum_id) }}">Create thread</a></p> <p><a href="{{ url_for('new_thread', forum_id = forum_id) }}">Create thread</a></p>
{{- nav() -}}
<table> <table>
<tr> <tr>
<th>Topic</th> <th>Topic</th>
@@ -19,6 +28,7 @@
<td>{{ format_since(utime) }}</td> <td>{{ format_since(utime) }}</td>
<td>{{ comment_count }}</td> <td>{{ comment_count }}</td>
</tr> </tr>
{% endfor %} {%- endfor -%}
</table> </table>
{{- nav() -}}
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {%- block content %}
<p>{{ minimd(description) | safe }}</p>
<table> <table>
<tr> <tr>
<th>Forum</th> <th>Forum</th>
@@ -10,7 +11,7 @@
<tr> <tr>
<td> <td>
<p><a href="{{ url_for('forum', forum_id = id) }}"><b>{{ name }}</b></a></p> <p><a href="{{ url_for('forum', forum_id = id) }}"><b>{{ name }}</b></a></p>
<p>{{ description }}</p> <p>{{ minimd(description) | safe }}</p>
</td> </td>
{% if t_id %} {% if t_id %}
<td> <td>
@@ -21,6 +22,6 @@
<td>No threads</td> <td>No threads</td>
{% endif %} {% endif %}
</tr> </tr>
{% endfor %} {%- endfor -%}
</table> </table>
{% endblock %} {%- endblock %}

View File

@@ -1,9 +1,11 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<form method="post"> <form method="post" class=login>
<p>Username: <input type="text" name="username"></p> <table>
<p>Password: <input type="password" name="password"></p> <tr><td>Username</td><td><input type="text" name="username"></td></tr>
<input type="submit" value="Login"> <tr><td>Password</td><td><input type="password" name="password"></td></tr>
</table>
<input type="submit" value="Login">
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{%- block content %} {%- block content %}
{%- if config.registration_enabled -%}
<form method="post" class=login> <form method="post" class=login>
<table> <table>
<tr><td>Username</td><td><input type="text" name="username" minlength=3></td></tr> <tr><td>Username</td><td><input type="text" name="username" minlength=3></td></tr>
@@ -10,4 +11,7 @@
<input name="answer" value="{{ answer }}" hidden> <input name="answer" value="{{ answer }}" hidden>
<input type="submit" value="Register"> <input type="submit" value="Register">
</form> </form>
{% endblock %} {%- else -%}
<p>Registrations are disabled.</p>
{%- endif %}
{%- endblock %}

View File

@@ -1,13 +1,21 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<p><a href="{{ url_for('user_info', user_id = session['user_id']) }}">View public profile</a></p> <p><a href="{{ url_for('user_info', user_id = user.id) }}">View public profile</a></p>
<form method="post"> <form method="post">
<table> <table>
<tr><td>Username</td><td>{{ name }}</td></tr> <tr><td>Username</td><td>{{ user.name }}</td></tr>
<tr><td>ID</td><td>{{ session['user_id'] }}</td></tr> <tr><td>ID</td><td>{{ user.id }}</td></tr>
<tr><td>About</td><td><textarea name="about">{{ about }}</textarea></td></tr> <tr><td>About</td><td><textarea name="about">{{ about }}</textarea></td></tr>
</form>
</table> </table>
<input type="submit" value="Update"> <input type="submit" value="Update">
</form>
<br>
<form method="post" action=edit/password/>
<table>
<tr><td>Old password</td><td><input type=password name=old></td></tr>
<tr><td>New password</td><td><input type=password name=new></td></tr>
</table>
<input type="submit" value="Set password">
</form>
{% endblock %} {% endblock %}

View File

@@ -15,7 +15,7 @@ db=$tmp/forum.db
. $base/../venv/bin/activate . $base/../venv/bin/activate
# initialize db # initialize db
$SQLITE $db < $base/../schema.txt $base/../init_sqlite.sh $db
$SQLITE $db < $base/init_db.txt $SQLITE $db < $base/init_db.txt
cd $base/.. cd $base/..

View File

@@ -1,26 +1,25 @@
insert into users (name, password, email, join_time) values ( insert into users (name, password, join_time) values (
"foo", "foo",
-- supasecret -- supasecret
"$argon2id$v=19$m=65536,t=3,p=4$qBWCEAKgdA4BYOy915qzlg$KhGy3UF0QMlplt2eB7r7QNL2kDcggXUimRWUrWql8sI", "$argon2id$v=19$m=65536,t=3,p=4$qBWCEAKgdA4BYOy915qzlg$KhGy3UF0QMlplt2eB7r7QNL2kDcggXUimRWUrWql8sI",
"foo@bar.baz",
0
);
insert into users (name, password, email, join_time) values (
"bar",
-- abraca
"$argon2id$v=19$m=65536,t=3,p=4$klJKCUFoDaF07j3nPCeEUA$lCphd5n1YIs8MaVop2vGNirwknkh91qJIZHMuBOlgWA",
"bar@foo.baz",
0 0
); );
insert into users (name, password, join_time) values ( insert into users (name, password, join_time) values (
"bar",
-- abraca
"$argon2id$v=19$m=65536,t=3,p=4$klJKCUFoDaF07j3nPCeEUA$lCphd5n1YIs8MaVop2vGNirwknkh91qJIZHMuBOlgWA",
0
);
insert into users (name, password, join_time, role) values (
"bazzers", "bazzers",
-- e -- e
"$argon2id$v=19$m=65536,t=3,p=4$9v5fS2ktxTinNEbIGUOoFQ$LMdEuAuuTCJ7utOE88+nXn7o6R/DEKY8ZA6wV+YkVGQ", "$argon2id$v=19$m=65536,t=3,p=4$9v5fS2ktxTinNEbIGUOoFQ$LMdEuAuuTCJ7utOE88+nXn7o6R/DEKY8ZA6wV+YkVGQ",
0 0,
2
); );
insert into forums (name, description, allowed_roles_mask) insert into forums (name, description)
values ("Earth", "The totality of all space and time; all that is, has been, and will be.", 1); values ("Earth", "The totality of all space and time; all that is, has been, and will be.");
insert into threads (author_id, forum_id, create_time, modify_time, update_time, title, text) insert into threads (author_id, forum_id, create_time, modify_time, update_time, title, text)
values (1, 1, 0, 0, 0, "Hello, world!", values (1, 1, 0, 0, 0, "Hello, world!",