restructure for docker

This commit is contained in:
Ville Rantanen
2023-07-24 20:02:45 +03:00
parent 79780f0769
commit 58abf04d2c
45 changed files with 152 additions and 17 deletions

36
forum/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update -yqq \
&& apt-get install -y --no-install-recommends \
sqlite3 \
tzdata \
git \
make \
python3-venv \
python3-pip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ARG UID
ARG GID
ARG TZ
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN groupadd -g $GID user && \
useradd -u $UID -g $GID -ms /bin/bash user && \
mkdir -p /opt/venv && chown $UID:$GID /opt/venv
COPY ./requirements.txt /requirements.txt
COPY docker-builder.sh /
USER user
RUN bash /docker-builder.sh
#COPY ./ /app
#USER root
#RUN chown -R $UID:$GID /app
USER user
WORKDIR /app
COPY docker-entrypoint.sh /
CMD bash /docker-entrypoint.sh

25
forum/captcha.py Normal file
View File

@@ -0,0 +1,25 @@
from random import randint
import hashlib, base64
# FIXME hash can be reused
def generate(key):
"""
Generate a simple CAPTCHA.
It is based on a simple math expression which stops the simplest of bots.
"""
# The parameters are chosen such that they are simple to solve on paper.
a = randint(1, 10)
b = randint(1, 10)
c = randint(10, 20)
return f"{a} * {b} + {c} = ", _hash_answer(key, str(a * b + c))
def verify(key, answer, hash):
return _hash_answer(key, answer) == hash
def _hash_answer(key, answer):
return base64.b64encode(
hashlib.sha256((key + answer).encode("utf-8")).digest()
).decode("ascii")

696
forum/db/sqlite.py Normal file
View File

@@ -0,0 +1,696 @@
import sqlite3
class DB:
def __init__(self, conn):
self.conn = conn
pass
def get_config(self):
return (
self._db()
.execute(
"""
select version, name, description, secret_key, captcha_key, registration_enabled, login_required from config
"""
)
.fetchone()
)
def get_forums(self):
return self._db().execute(
"""
select f.forum_id, name, description, thread_id, title, update_time
from forums f
left join threads t
on t.thread_id = (
select tt.thread_id
from threads tt
where f.forum_id = tt.forum_id and not tt.hidden
order by update_time desc
limit 1
)
"""
)
def get_forum(self, forum_id):
return (
self._db()
.execute(
"""
select name, description
from forums
where forum_id = ?
""",
(forum_id,),
)
.fetchone()
)
def get_thread_forum(self, thread_id):
""" Returns forum_id of a thread """
return (
self._db()
.execute(
"""
select forum_id
from threads
where thread_id = ?
""",
(thread_id,),
)
.fetchone()[0]
)
def get_threads(self, forum_id, offset, limit, user_id):
return self._db().execute(
"""
select
t.thread_id,
title,
t.create_time,
t.update_time,
t.author_id,
name,
count(c.thread_id),
t.hidden
from
threads t,
users
left join
comments c
on
t.thread_id = c.thread_id
where forum_id = ?
and user_id = t.author_id
and (
t.hidden = 0 or (
select 1 from users
where user_id = ?
and (
user_id = t.author_id
-- 1 = moderator, 2 = admin
or role in (1, 2)
)
)
)
group by t.thread_id
order by t.update_time desc
limit ?
offset ?
""",
(forum_id, user_id, limit, offset),
)
def get_thread(self, thread):
db = self._db()
title, text, author, author_id, create_time, modify_time, hidden, forum_id = db.execute(
"""
select title, text, name, author_id, create_time, modify_time, hidden, forum_id
from threads, users
where thread_id = ? and author_id = user_id
""",
(thread,),
).fetchone()
comments = db.execute(
"""
select
comment_id,
parent_id,
author_id,
name,
text,
create_time,
modify_time,
hidden
from comments
left join users
on author_id = user_id
where thread_id = ?
""",
(thread,),
)
return (
title,
text,
author,
author_id,
create_time,
modify_time,
comments,
hidden,
forum_id
)
def get_thread_title(self, thread_id):
return (
self._db()
.execute(
"""
select title
from threads
where thread_id = ?
""",
(thread_id,),
)
.fetchone()
)
def get_thread_title_text(self, thread_id):
return (
self._db()
.execute(
"""
select title, text
from threads
where thread_id = ?
""",
(thread_id,),
)
.fetchone()
)
def get_recent_threads(self, limit):
return self._db().execute(
"""
select thread_id, title, modify_date
from threads
order by modify_date
limit ?
""",
(limit,),
)
def get_comment(self, comment_id):
return (
self._db()
.execute(
"""
select title, c.text
from comments c, threads t
where comment_id = ? and c.thread_id = t.thread_id
""",
(comment_id,),
)
.fetchone()
)
def get_subcomments(self, comment_id):
db = self._db()
thread_id, parent_id, title = db.execute(
"""
select threads.thread_id, parent_id, title
from threads, comments
where comment_id = ? and threads.thread_id = comments.thread_id
""",
(comment_id,),
).fetchone()
# Recursive CTE, see https://www.sqlite.org/lang_with.html
return (
thread_id,
parent_id,
title,
db.execute(
"""
with recursive
descendant_of(id) as (
select comment_id from comments where comment_id = ?
union
select comment_id from descendant_of, comments where id = parent_id
)
select
id,
parent_id,
author_id,
name,
text,
create_time,
modify_time,
hidden
from
descendant_of,
comments,
users
where id = comment_id
and user_id = author_id
""",
(comment_id,),
),
)
def get_user_password(self, username):
return (
self._db()
.execute(
"""
select user_id, password
from users
where name = lower(?)
""",
(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, banned_until
from users
where user_id = ?
""",
(user_id,),
)
.fetchone()
)
def get_user_private_info(self, user_id):
return (
self._db()
.execute(
"""
select about
from users
where user_id = ?
""",
(user_id,),
)
.fetchone()
)
def set_user_private_info(self, user_id, about):
db = self._db()
db.execute(
"""
update users
set about = ?
where user_id = ?
""",
(about, user_id),
)
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
from users
where user_id = ?
""",
(user_id,),
)
.fetchone()
)
def add_thread(self, author_id, forum_id, title, text, time):
db = self._db()
c = db.cursor()
c.execute(
"""
insert into threads (author_id, forum_id, title, text,
create_time, modify_time, update_time)
select ?, ?, ?, ?, ?, ?, ?
from users
where user_id = ? and banned_until < ?
""",
(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
from threads
where rowid = ?
""",
(rowid,),
).fetchone()
def delete_thread(self, user_id, thread_id):
db = self._db()
c = db.cursor()
c.execute(
"""
delete
from threads
-- 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, user_id),
)
db.commit()
return c.rowcount > 0
def delete_comment(self, user_id, comment_id):
db = self._db()
c = db.cursor()
c.execute(
"""
delete
from comments
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, user_id, comment_id),
)
db.commit()
return c.rowcount > 0
def add_comment_to_thread(self, thread_id, author_id, text, time):
db = self._db()
c = db.cursor()
c.execute(
"""
insert into comments(thread_id, author_id, text, create_time, modify_time)
select ?, ?, ?, ?, ?
from threads, users
where thread_id = ? and user_id = ? and banned_until < ?
""",
(thread_id, author_id, text, time, time, thread_id, author_id, time),
)
if c.rowcount > 0:
c.execute(
"""
update threads
set update_time = ?
where thread_id = ?
""",
(time, thread_id),
)
db.commit()
return True
return False
def add_comment_to_comment(self, parent_id, author_id, text, time):
db = self._db()
c = db.cursor()
c.execute(
"""
insert into comments(thread_id, parent_id, author_id, text, create_time, modify_time)
select thread_id, ?, ?, ?, ?, ?
from comments, users
where comment_id = ? and user_id = ? and banned_until < ?
""",
(parent_id, author_id, text, time, time, parent_id, author_id, time),
)
if c.rowcount > 0:
c.execute(
"""
update threads
set update_time = ?
where threads.thread_id = (
select c.thread_id
from comments c
where comment_id = ?
)
""",
(time, parent_id),
)
db.commit()
return True
return False
def modify_thread(self, thread_id, user_id, title, text, time):
db = self._db()
c = db.cursor()
c.execute(
"""
update threads
set title = ?, text = ?, modify_time = ?
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,
user_id,
time,
user_id,
),
)
if c.rowcount > 0:
db.commit()
return True
return False
def modify_comment(self, comment_id, user_id, text, time):
db = self._db()
c = db.cursor()
c.execute(
"""
update comments
set text = ?, modify_time = ?
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,
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()
# TODO find a way to get the (autoincremented) user ID without looking
# up by name.
# ROWID is *probably* not always consistent (race conditions).
# Ideally we get the ID immediately on insert.
return c.execute(
"""
select user_id
from users
where name = lower(?)
""",
(username,),
).fetchone()
return None
except sqlite3.IntegrityError:
# User already exists, probably
return None
def add_user(self, username, password, time):
"""
Add a user without checking if registrations are enabled.
"""
try:
db = self._db()
c = db.cursor()
c.execute(
"""
insert into users(name, password, join_time)
values (lower(?), ?, ?)
""",
(username, password, time),
)
if c.rowcount > 0:
db.commit()
return True
return False
except sqlite3.IntegrityError:
# 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, login_required
):
return self.change_one(
"""
update config
set name = ?, description = ?, registration_enabled = ?, login_required = ?
""",
(server_name, server_description, registration_enabled, login_required),
)
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 set_user_role(self, user_id, role):
return self.change_one(
"""
update users
set role = ?
where user_id = ?
""",
(role, user_id),
)
def set_thread_hidden(self, thread_id, hide):
return self.change_one(
"""
update threads
set hidden = ?
where thread_id = ?
""",
(hide, thread_id),
)
def set_comment_hidden(self, comment_id, hide):
return self.change_one(
"""
update comments
set hidden = ?
where comment_id = ?
""",
(hide, comment_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, timeout=5)

7
forum/docker-builder.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
set -eux
python3 -m venv /opt/venv
. /opt/venv/bin/activate
pip3 install -r requirements.txt

50
forum/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/sh
PYTHON=python3
SQLITE=sqlite3
export DB="/app/forum.db"
export SERVER=gunicorn
export PID="forum.pid"
export WORKERS=4
if [[ $( stat -c %u /app ) -ne $( id -u ) ]]; then
echo User id and /app folder owner do not match
printf 'UID: %s\nFolder: %s\n' $( id -u ) $( stat -c %u /app )
exit 1
fi
set -eu
. /opt/venv/bin/activate
if [[ -e "$DB" ]]; then
$SQLITE -header "$DB" "SELECT version,name,description,registration_enabled,login_required FROM config"
echo Database already exists
else
password=$($PYTHON tool.py password "$ADMINP")
time=$($PYTHON -c 'import time; print(time.time_ns())')
version=$($PYTHON tool.py version)
$SQLITE "$DB" -init schema.txt "insert into config (
version,
name,
description,
secret_key,
captcha_key,
registration_enabled,
login_required
)
values (
'$version',
'Forum',
'',
'$(head -c 30 /dev/urandom | base64)',
'$(head -c 30 /dev/urandom | base64)',
0,
1
)"
$SQLITE "$DB" "
insert into users (name, password, role, join_time)
values (lower('$ADMINU'), '$password', 2, $time)
"
fi
exec "$SERVER" -w $WORKERS 'main:app' --pid="$PID" -b 0.0.0.0:5000

58
forum/init_sqlite.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
SQLITE=sqlite3
PYTHON=python3
set -e
make
. ./venv/bin/activate
if [ $# -le 0 ]
then
echo "Usage: $0 <file> [--no-admin]" >&2
exit 1
fi
if [ -e "$1" ]
then
echo "Database '$1' already exists" >&2
exit 1
fi
if [ "$2" != --no-admin ]
then
read -p 'Admin username: ' username
read -sp 'Admin password: ' password
fi
password=$($PYTHON tool.py password "$password")
time=$($PYTHON -c 'import time; print(time.time_ns())')
$SQLITE "$1" -init schema.txt "insert into config (
version,
name,
description,
secret_key,
captcha_key,
registration_enabled,
login_required
)
values (
'agreper-v0.1.1',
'Agreper',
'',
'$(head -c 30 /dev/urandom | base64)',
'$(head -c 30 /dev/urandom | base64)',
0,
0
)"
if [ "$2" != --no-admin ]
then
$SQLITE "$1" "
insert into users (name, password, role, join_time)
values (lower('$username'), '$password', 2, $time)
"
fi
echo "Database '$1' created" >&2

935
forum/main.py Normal file
View File

@@ -0,0 +1,935 @@
VERSION = "agreper-v0.1.1q1"
# 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, sys, subprocess
import passlib.hash, secrets
import time
import string
from datetime import datetime
import captcha, password, minimd
app = Flask(__name__)
db = DB(os.getenv("DB"))
# This defaults to None, which allows CSRF attacks in FireFox
# and older versions of Chrome.
# 'Lax' is sufficient to prevent malicious POST requests.
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
class Config:
pass
config = Config()
(
config.version,
config.server_name,
config.server_description,
app.config["SECRET_KEY"],
config.captcha_key,
config.registration_enabled,
config.login_required
) = db.get_config()
config.user_css = os.path.exists(os.path.join(app.static_folder, 'user.css'))
if config.version != VERSION:
print(f"Incompatible version {config.version} (expected {VERSION})")
sys.exit(1)
class Role:
USER = 0
MODERATOR = 1
ADMIN = 2
@app.before_request
def before_request():
if config.login_required:
user_id = session.get("user_id", -1)
if user_id == -1 and request.endpoint not in ("login","static"):
return redirect(url_for("login"))
@app.after_request
def after_request(response):
# This forbids other sites from embedding this site in an iframe,
# preventing clickjacking attacks.
response.headers["X-Frame-Options"] = "DENY"
return response
@app.route("/")
def index():
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>/")
def forum(forum_id):
title, description = db.get_forum(forum_id)
offset = int(request.args.get("p", 0))
user_id = session.get("user_id", -1)
threads = [*db.get_threads(forum_id, offset, THREADS_PER_PAGE + 1, user_id)]
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/<int:thread_id>/")
def thread(thread_id):
user = get_user()
(
title,
text,
author,
author_id,
create_time,
modify_time,
comments,
hidden,
forum_id
) = db.get_thread(thread_id)
forum_title, _ = db.get_forum(forum_id)
comments = create_comment_tree(comments, user)
return render_template(
"thread.html",
title=title,
config=config,
user=user,
text=text,
author=author,
author_id=author_id,
thread_id=thread_id,
forum_id=forum_id,
forum_title=forum_title,
hidden=hidden,
create_time=create_time,
modify_time=modify_time,
comments=comments,
)
@app.route("/comment/<int:comment_id>/")
def comment(comment_id):
user = get_user()
thread_id, parent_id, title, comments = db.get_subcomments(comment_id)
forum_id = db.get_thread_forum(thread_id)
forum_title, _ = db.get_forum(forum_id)
comments = create_comment_tree(comments, user)
(reply_comment,) = comments
comments = reply_comment.children
reply_comment.children = []
return render_template(
"comments.html",
title=title,
config=config,
user=user,
reply_comment=reply_comment,
comments=comments,
parent_id=parent_id,
thread_id=thread_id,
forum_id=forum_id,
forum_title=forum_title
)
@app.route("/login/", methods=["GET", "POST"])
def login():
if request.method == "POST":
v = db.get_user_password(request.form["username"])
if v is not None:
id, hash = v
time.sleep(0.3)
if password.verify(request.form["password"], hash):
flash("Logged in", "success")
session["user_id"] = id
session.permanent = True
return redirect(url_for("index"))
else:
# Sleep to reduce effectiveness of bruteforce
time.sleep(0.5)
flash("Username or password is invalid", "error")
return render_template("login.html", title="Login", config=config, user=get_user())
@app.route("/logout/")
def logout():
session.pop("user_id")
return redirect(url_for("index"))
@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"))
new = request.form["new"]
if len(new) < 8:
flash("New password must be at least 8 characters long", "error")
else:
(hash,) = db.get_user_password_by_id(user_id)
if password.verify(request.form["old"], hash):
if db.set_user_password(user_id, password.hash(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/<int:user_id>/")
def user_info(user_id):
name, about, banned_until = db.get_user_public_info(user_id)
return render_template(
"user_info.html",
title="Profile",
config=config,
user=get_user(),
name=name,
id=user_id,
banned_until=banned_until,
about=about,
)
@app.route("/forum/<int:forum_id>/new/", methods=["GET", "POST"])
def new_thread(forum_id):
user_id = session.get("user_id")
if user_id is None and not config.registration_enabled:
# Can't create a thread without an account
return redirect(url_for("login"))
if request.method == "POST":
if user_id is None:
# Attempt to create a user account first
if register_user(True):
user_id = session["user_id"]
if user_id is not None:
title, text = request.form["title"].strip(), trim_text(request.form["text"])
title = title.strip()
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/<int:thread_id>/confirm_delete/")
def confirm_delete_thread(thread_id):
(title,) = db.get_thread_title(thread_id)
return render_template(
"confirm_delete_thread.html",
title="Delete thread",
config=config,
user=get_user(),
thread_title=title,
)
@app.route("/thread/<int:thread_id>/delete/", methods=["POST"])
def delete_thread(thread_id):
user_id = session.get("user_id")
if user_id is None:
return redirect(url_for("login"))
if db.delete_thread(user_id, thread_id):
flash("Thread has been deleted", "success")
else:
flash("Thread could not be removed", "error")
# TODO return 403, maybe?
return redirect(url_for("index"))
def _add_comment_check_user():
user_id = session.get("user_id")
if user_id is not None:
return user_id
if not config.registration_enabled:
flash("Registrations are not enabled. Please log in to comment", "error")
if register_user(True):
return session["user_id"]
@app.route("/thread/<int:thread_id>/comment/", methods=["POST"])
def add_comment(thread_id):
user_id = _add_comment_check_user()
if user_id is not None:
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")
return redirect(url_for("thread", thread_id=thread_id))
@app.route("/comment/<int:comment_id>/comment/", methods=["POST"])
def add_comment_parent(comment_id):
user_id = _add_comment_check_user()
if user_id is not None:
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")
return redirect(url_for("comment", comment_id=comment_id))
@app.route("/comment/<int:comment_id>/confirm_delete/")
def confirm_delete_comment(comment_id):
title, text = db.get_comment(comment_id)
return render_template(
"confirm_delete_comment.html",
title="Delete comment",
config=config,
user=get_user(),
thread_title=title,
text=text,
)
@app.route("/comment/<int:comment_id>/delete/", methods=["POST"])
def delete_comment(comment_id):
user_id = session.get("user_id")
if user_id is None:
return redirect(url_for("login"))
if db.delete_comment(user_id, comment_id):
flash("Comment has been deleted", "success")
else:
flash("Comment could not be removed", "error")
# TODO return 403, maybe?
return redirect(url_for("index"))
@app.route("/thread/<int:thread_id>/edit/", methods=["GET", "POST"])
def edit_thread(thread_id):
user_id = session.get("user_id")
if user_id is None:
return redirect(url_for("login"))
if request.method == "POST":
title, text = request.form["title"].strip(), 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,
title,
text,
time.time_ns(),
):
flash("Thread has been edited", "success")
else:
flash("Thread could not be edited", "error")
return redirect(url_for("thread", thread_id=thread_id))
title, text = db.get_thread_title_text(thread_id)
return render_template(
"edit_thread.html",
title="Edit thread",
config=config,
user=get_user(),
thread_title=title,
text=text,
)
@app.route("/comment/<int:comment_id>/edit/", methods=["GET", "POST"])
def edit_comment(comment_id):
user_id = session.get("user_id")
if user_id is None:
return redirect(url_for("login"))
if request.method == "POST":
text = trim_text(request.form["text"])
if text == "":
flash("Text may not be empty", "error")
elif db.modify_comment(
comment_id,
user_id,
trim_text(request.form["text"]),
time.time_ns(),
):
flash("Comment has been edited", "success")
else:
flash("Comment could not be edited", "error")
return redirect(url_for("comment", comment_id=comment_id))
title, text = db.get_comment(comment_id)
return render_template(
"edit_comment.html",
title="Edit comment",
config=config,
user=get_user(),
thread_title=title,
text=text,
)
@app.route("/register/", methods=["GET", "POST"])
def register():
if request.method == "POST":
username, passwd = request.form["username"], request.form["password"]
if register_user(False):
return redirect(url_for("index"))
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/<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,
"login_required" 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"))
def ban_user(user_id):
chk, user = _moderator_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, 0x7FFF_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")
@app.route("/user/<int:user_id>/ban/", methods=["POST"])
def moderator_ban_user(user_id):
return ban_user(user_id) or redirect(url_for("user_info", user_id=user_id))
@app.route("/admin/user/<int:user_id>/ban/", methods=["POST"])
def admin_ban_user(user_id):
return ban_user(user_id) or redirect(url_for("admin"))
def unban_user(user_id):
chk, user = _moderator_check()
if not chk:
return user
try:
if db.set_user_ban(user_id, 0):
flash("Unbanned user", "success")
else:
flash("Failed to unban user", "error")
except Exception as e:
flash(str(e), "error")
@app.route("/user/<int:user_id>/unban/", methods=["POST"])
def moderator_unban_user(user_id):
return unban_user(user_id) or redirect(url_for("user_info", user_id=user_id))
@app.route("/admin/user/<int:user_id>/unban/", methods=["POST"])
def admin_unban_user(user_id):
return unban_user(user_id) or redirect(url_for("admin"))
@app.route("/admin/user/new/", methods=["POST"])
def admin_new_user():
chk, user = _admin_check()
if not chk:
return user
try:
name, passwd = request.form["name"], request.form["password"]
if name == "" or passwd == "":
flash("Name and password may not be empty")
elif db.add_user(name, password.hash(passwd), 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/user/<int:user_id>/edit/role/", methods=["POST"])
def admin_set_role(user_id):
chk, user = _admin_check()
if not chk:
return user
try:
role = request.form["role"]
if role not in ("0", "1", "2"):
flash(f"Invalid role type ({role})", "error")
else:
db.set_user_role(user_id, role)
flash("Set user role", "success")
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"))
@app.route("/thread/<int:thread_id>/hide/", methods=["POST"])
def set_hide_thread(thread_id):
chk, user = _moderator_check()
if not chk:
return user
try:
hide = request.form["hide"] != "0"
hide_str = "Hidden" if hide else "Unhidden"
if db.set_thread_hidden(thread_id, hide):
flash(f"{hide_str} thread", "success")
else:
flash(f"Failed to {hide_str.lower()} thread", "error")
except Exception as e:
flash(str(e), "error")
return redirect(request.form["redirect"])
@app.route("/comment/<int:comment_id>/hide/", methods=["POST"])
def set_hide_comment(comment_id):
chk, user = _moderator_check()
if not chk:
return user
try:
hide = request.form["hide"] != "0"
hide_str = "Hidden" if hide else "Unhidden"
if db.set_comment_hidden(comment_id, hide):
flash(f"{hide_str} comment", "success")
else:
flash(f"Failed to {hide_str.lower()} comment", "error")
except Exception as e:
flash(str(e), "error")
return redirect(request.form["redirect"])
# TODO can probably be a static-esque page, maybe?
@app.route("/help/")
def help():
return render_template(
"help.html",
title="Help",
user=get_user(),
)
def _moderator_check():
user = get_user()
if user is None:
return False, redirect(url_for("login"))
if not user.is_moderator():
return False, ("<h1>Forbidden</h1>", 403)
return True, user
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:
def __init__(
self, id, parent_id, author_id, author, text, create_time, modify_time, hidden
):
self.id = id
self.author_id = author_id
self.author = author
self.text = text
self.children = []
self.create_time = create_time
self.modify_time = modify_time
self.parent_id = parent_id
self.hidden = hidden
def create_comment_tree(comments, user):
start = time.time()
# Collect comments first, then build the tree in case we encounter a child before a parent
comment_map = {v[0]: Comment(*v) for v in comments}
root = []
# We should keep showing hidden comments if the user replied to them, directly or indirectly.
# To do that, keep track of user comments, then walk up the tree and insert hidden comments.
user_comments = []
# Build tree
def insert(comment):
parent = comment_map.get(comment.parent_id)
if parent is not None:
parent.children.append(comment)
else:
root.append(comment)
for comment in comment_map.values():
if comment.hidden and (not user or not user.is_moderator()):
continue
insert(comment)
if user and (comment.author_id == user.id and not user.is_moderator()):
user_comments.append(comment)
# Insert replied-to hidden comments
for c in user_comments:
while c is not None:
if c.hidden:
insert(c)
c = comment_map.get(c.parent_id)
# Sort each comment based on create time
def sort_time(l):
l.sort(key=lambda c: c.modify_time, reverse=True)
for c in l:
sort_time(c.children)
sort_time(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
def register_user(show_password):
username, passwd = request.form["username"], request.form["password"]
if any(c in username for c in string.whitespace):
# This error is more ergonomic in case someone tries to play tricks again :)
flash("Username may not contain whitespace", "error")
elif len(username) < 3:
flash("Username must be at least 3 characters long", "error")
elif len(passwd) < 8:
flash("Password must be at least 8 characters long", "error")
elif not captcha.verify(
config.captcha_key,
request.form["captcha"],
request.form["answer"],
):
flash("CAPTCHA answer is incorrect", "error")
else:
uid = db.register_user(username, password.hash(passwd), time.time_ns())
if uid is None:
flash("Failed to create account (username may already be taken)", "error")
else:
s = "Account has been created."
if show_password:
s += f" Your password is <code class=spoiler>{passwd}</code> (hover to reveal)."
flash(s, "success")
(uid,) = uid
session["user_id"] = uid
session.permanent = True
return True
return False
@app.context_processor
def utility_processor():
def _format_time_delta(n, t):
# Try the sane thing first
dt = (n - t) // 10**9
if dt < 1:
return "less than a second"
if dt < 2:
return f"1 second"
if dt < 60:
return f"{dt} seconds"
if dt < 119:
return f"1 minute"
if dt < 3600:
return f"{dt // 60} minutes"
if dt < 3600 * 2:
return f"1 hour"
if dt < 3600 * 24:
return f"{dt // 3600} hours"
if dt < 3600 * 24 * 31:
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 ""}'
if t.year < n.year:
return f(t.year, n.year, "year")
if t.month < n.month:
return f(t.month, n.month, "month")
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 rand_password():
"""
Generate a random password.
The current implementation returns 12 random lower- and uppercase alphabet characters.
This gives up to `log((26 * 2) ** 12) / log(2) = ~68` bits of entropy, which should be
enough for the foreseeable future.
"""
return "".join(string.ascii_letters[secrets.randbelow(52)] for _ in range(12))
def gen_captcha():
return captcha.generate(config.captcha_key)
return {
"format_since": format_since,
"format_time": format_time,
"format_until": format_until,
"minimd": minimd.html,
"rand_password": rand_password,
"gen_captcha": gen_captcha,
}
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.replace("\r", "")

70
forum/minimd.py Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import re
import markdown2
# https://stackoverflow.com/a/6041965
RE_URL = re.compile(
r"(https?://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-]))"
)
RE_EM = re.compile(r"\*(.*?)\*")
RE_LIST = re.compile(r"(-|[0-9]\.) .*")
RE_PLAINURL = re.compile(
r"([ |\n])(https?://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-]))[^\)]"
)
def html(text):
text = RE_PLAINURL.sub(r'\1[\2](\2)', text)
return markdown2.markdown(text)
def html_old(text):
# Replace angle brackets to prevent XSS
# Also replace ampersands to prevent surprises.
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
html = ["<p>"]
lines = text.split("\n")
in_code = False
in_list = False
for l in lines:
if l == "":
in_list = False
if in_code:
html.append("</pre>")
in_code = False
html.append("</p><p>")
continue
if l.startswith(" "):
in_list = False
l = l[2:]
if not in_code:
html.append("<pre>")
in_code = True
html.append(l)
continue
if in_code:
html.append("</pre>")
in_code = False
l = RE_EM.sub(r"<em>\1</em>", l)
l = RE_URL.sub(r'<a href="\1">\1</a>', l)
if RE_LIST.match(l):
if in_list:
html.append("<br>")
in_list = True
else:
in_list = False
html.append(l)
if in_code:
html.append("</pre>")
html.append("</p>")
return "\n".join(html)
if __name__ == "__main__":
import sys
print(html_old(sys.stdin.read()))

9
forum/password.py Normal file
View File

@@ -0,0 +1,9 @@
import passlib.hash
def hash(password):
return passlib.hash.argon2.hash(password)
def verify(password, hash):
return passlib.hash.argon2.verify(password, hash)

5
forum/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
argon2-cffi==21.3.0
Flask==2.2.2
gunicorn==20.1.0
passlib==1.7.4
markdown2==2.4.9

27
forum/restart.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -e
SERVER="$1"
if [ -z "$SERVER" ]
then
echo "SERVER is not set" >&2
exit 1
fi
case "$SERVER" in
dev)
touch main.py
;;
gunicorn)
if [ -z "$PID" ]
then
echo "PID is not set" >&2
exit 1
fi
kill -hup $(cat "$PID")
;;
*)
echo "Unsupported $SERVER" >&2
exit 1
;;
esac

22
forum/run_sqlite.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -e
if [ ! -e venv ]
then
echo "venv not found, did you run make?" >&2
exit 1
fi
if [ $# != 2 ]
then
echo "Usage: $0 <file.db> <pid file>" >&2
exit 1
fi
. ./venv/bin/activate
export DB="$1"
export SERVER=gunicorn
export PID="$2"
exec gunicorn -w 4 'main:app' --pid="$PID" -b 0.0.0.0:8000

55
forum/schema.txt Normal file
View File

@@ -0,0 +1,55 @@
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,
login_required 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,
about text not null default '',
join_time integer not null,
role integer not null default 0,
banned_until integer not null default 0
);
create table threads (
thread_id integer unique not null primary key autoincrement,
author_id integer not null,
forum_id integer not null,
create_time integer not null,
modify_time integer not null,
update_time integer not null,
title varchar(64) not null,
text text not null,
score integer not null default 0,
hidden boolean not null default false
);
create table comments (
comment_id integer unique not null primary key autoincrement,
thread_id integer not null,
author_id integer not null,
parent_id integer,
create_time integer not null,
modify_time integer not null,
text text not null,
score integer not null default 0,
hidden boolean not null default false
);
create table forums (
forum_id integer unique not null primary key autoincrement,
name varchar(64) not null,
description text not null default ''
);
-- Both of these speed up searches significantly if there are many threads or comments.
-- Other indices have no measureable impact (yet, at least).
create index forum_id on threads(forum_id);
create index thread_id on comments(thread_id);

BIN
forum/static/button.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

140
forum/static/theme.css Normal file
View File

@@ -0,0 +1,140 @@
body {
margin: 0;
font-family: sans-serif;
background-color: #f2f2e2;
margin-bottom: 30px;
}
body > * {
padding-left: 1em;
padding-right: 1em;
}
nav {
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow-x: auto;
background-color: #ffca4b;
}
nav > * {
margin-top: auto;
margin-bottom: auto;
padding: 20px;
color: black;
}
main {
width: 80%;
margin: auto;
}
a {
text-decoration: none;
}
p {
margin-top: 0.7em;
margin-bottom: 0.7em;
}
table {
border-collapse: collapse;
width: 100%;
}
tr:not(:last-child) {
/* FIXME this is sometimes invisible depending on zoom level in Firefox. */
border-bottom: 1px solid;
}
th, td {
padding: 5px;
text-align: left;
}
textarea {
width: min(100%, 500px);
height: 15em;
font-size: 1em;
}
input[type=text], input[type=password] {
width: min(100%, 20em);
font-family: monospace;
font-size: 1em;
}
td > input[type=text], td > input[type=password] {
width: min(100%, 500px);
}
.logo {
margin: 0;
padding: 5px;
padding-left: 15px;
font-size: 3em;
font-weight: bold;
}
form.form {
width: 90%;
}
table.form {
width: 100%;
}
table.form > * > tr > td, th {
vertical-align: top;
}
.comment {
margin-left: 20px;
margin-top: 15px;
margin-bottom: 15px;
padding-left: 10px;
border-left: 1px dotted;
}
.flash.success {
background-color: lightgreen;
border-radius: 5px;
padding: 8px;
}
.flash.error {
background-color: #ff4646;
border-radius: 5px;
padding: 8px;
}
/* Abuse checkboxes to collapse comments */
.collapse {
appearance: none;
cursor: pointer;
}
.collapse:checked + * {
display: none
}
.collapse:after {
content: '[-]';
}
.collapse:checked:after {
content: '[+]';
}
.small {
font-size: 85%;
}
.spoiler {
background-color: black;
color: black;
}
.spoiler:hover {
opacity: 1;
color: white;
}

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>

View File

@@ -0,0 +1,129 @@
{% 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>
<tr>
<td>Login required</td>
<td><input name=login_required type=checkbox {{ 'checked' if config.login_required 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="{{ url_for('admin_unban_user', user_id = id) }}">
{{- format_time(banned_until) }}
<input type=submit value=Unban>
</form>
{%- endif -%}
<form method=post action="{{ url_for('admin_ban_user', user_id = id) }}">
<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 -%}

48
forum/templates/base.html Normal file
View File

@@ -0,0 +1,48 @@
<!doctype html>
<head>
<title>{{ title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="Agreper - minimal, no-JS forum software">
<meta content="utf-8" http-equiv="encoding">
<link rel=stylesheet href="{{ url_for('static', filename='theme.css') }}">
{%- if config.server_name -%}
<link rel=stylesheet href="{{ url_for('static', filename='user.css') }}">
{%- endif -%}
</head>
<body>
<nav>
<a class=logo href="{{ url_for('index') }}">A</a>
<div style="margin:auto"></div>
{%- if user is not none -%}
<a href="{{ url_for('user_edit') }}">
{{- 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>
{%- else -%}
{%- if config.registration_enabled -%}
<a href="{{ url_for('register') }}">Register</a>
<span> | </span>
{%- endif -%}
<a href="{{ url_for('login') }}">Login</a>
{%- endif -%}
<span> | </span>
<a href="{{ url_for('help') }}">Help</a>
</nav>
<main>
<h1>{{ title }}</h1>
{%- for category, msg in get_flashed_messages(True) -%}
{#-
FIXME ensure all flash() messages are free of XSS vectors.
In particular, check places where we flash error messages.
-#}
<p class="flash {{ category }}">{{ msg | safe }}</p>
{%- endfor -%}
{%- block content %}{% endblock -%}
</main>
</body>

View File

@@ -0,0 +1,82 @@
{% from 'moderator.html' import moderate_comment with context -%}
{%- macro author(id, name, ctime, mtime) -%}
<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 -%}
{%- macro comment_author(comment, thread_id, can_delete) -%}
<span class=small>
{{- '[hidden]' if comment.hidden else '' }}
{{ author(comment.author_id, comment.author, comment.create_time, comment.modify_time) }} |
{# Suffixing a # prevents unnecessary reloads #}
<a href="{{ url_for('thread', thread_id = thread_id) }}#"> thread</a>
{%- if comment.parent_id is not none -%}
<a href="{{ url_for('comment', comment_id = comment.parent_id) }}#"> parent</a>
{%- endif -%}
{%- 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>
{%- if can_delete -%}
<a href="{{ url_for('confirm_delete_comment', comment_id = comment.id) }}"> delete</a>
{%- endif -%}
{%- if user.is_moderator() -%}
{{ moderate_comment(comment.id, comment.hidden) }}
{%- endif -%}
{%- endif -%}
</span>
{%- endmacro -%}
{%- macro thread_author(author_id, name, ctime, mtime) -%}
<span class=small>
{{- author(author_id, name, ctime, mtime) -}}
{%- 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('confirm_delete_thread', thread_id = thread_id) }}"> delete</a>
{%- endif -%}
</span>
{%- endmacro -%}
{%- macro render_comment_pre(comment, thread_id, can_delete) -%}
<div class=comment>
{{- comment_author(comment, thread_id, can_delete) -}}
<input type=checkbox class="collapse small">
<div>{{- minimd(comment.text) | safe -}}
{%- endmacro -%}
{%- macro render_comment_post(comment, thread_id) -%}
{%- for c in comment.children -%}
{{- render_comment(c, thread_id) -}}
{%- endfor -%}
</div>
</div>
{%- endmacro -%}
{%- macro render_comment(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>
{{- render_comment_post(comment, thread_id) -}}
{%- endmacro -%}
{%- macro reply() -%}
{%- if user is none -%}
{%- if config.registration_enabled -%}
<form method="post" action="comment/">
<p><textarea name=text></textarea></p>
{#-
Using the password generator for usernames should be sufficient to ensure it is unique.
If not, it means the password generator is broken and *must* be fixed.
-#}
<input type=text name=username value="{{ rand_password() }}" hidden>
<input type=password name=password value="{{ rand_password() }}" hidden>
{% set q, a = gen_captcha() %}
<p>Captcha: {{ q }} <input type=text name=captcha></p>
<input type=text name=answer value="{{ a }}" hidden>
<p><input type=submit value="Register & post comment"> (<a href="{{ url_for('login') }}">I already have an account</a>)</p>
</form>
{%- endif -%}
{%- elif not user.is_banned() -%}
<form method="post" action="comment/">
<p><textarea name="text"></textarea></p>
<p><input type="submit" value="Post comment"></p>
</form>
{%- endif -%}
{%- endmacro -%}

View File

@@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% from 'comment.html' import render_comment, render_comment_pre, render_comment_post, reply with context %}
{% block content %}
<p><span> &laquo; </span><a href="{{ url_for('forum', forum_id = forum_id) }}">{{ forum_title }}</a><span> &laquo; </span><a href="{{ url_for('thread', thread_id = thread_id) }}">{{ title }}</a></p>
{{ render_comment_pre(reply_comment, thread_id, comments | length == 0) }}
{{ reply() }}
{% for c in comments %}
{{ render_comment(c, thread_id) }}
{% endfor %}
{{ render_comment_post(reply_comment, thread_id) }}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block content %}
<p>Are you sure you want to delete this comment on "{{ thread_title }}"?</p>
<div class=comment>{{ minimd(text) | safe }}</div>
<p>
<form method="post" action="../delete" style=inline>
<input type="submit" value="Yes">
</form>
<form method="get" action=".." style=inline>
<input type="submit" value="No">
</form>
</p>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block content %}
<p>Are you sure you want to delete "{{ thread_title }}"?</p>
<p>
<form method="post" action="../delete" style=inline>
<input type="submit" value="Yes">
</form>
<form method="get" action=".." style=inline>
<input type="submit" value="No">
</form>
</p>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block content %}
<form method="post">
<table class=form>
<tr>
<td>Thread</td>
<td>{{ thread_title }}</td>
</tr>
<tr>
<td>Text</td>
<td><textarea name="text">{{ text }}</textarea></td>
</tr>
</table>
<p><input type="submit" value="Post"></p>
</form>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block content %}
<form method="post">
<table class=form>
<tr>
<td>Title</td>
<td><input type="text" name="title" value="{{ thread_title }}"></td>
</tr>
<tr>
<td>Text</td>
<td><textarea name="text">{{ text }}</textarea></td>
</tr>
</table>
<p><input type="submit" value="Post"></p>
</form>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends 'base.html' %}
{%- from 'moderator.html' import moderate_thread with context %}
{%- 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>{{ minimd(description) | safe }}</p>
<p><span> &laquo; </span><a href="{{ url_for('index') }}">Forum list</a><span> | </span><a href="{{ url_for('new_thread', forum_id = forum_id) }}">Create thread</a></p>
{{- nav() -}}
<table>
<tr>
<th>Topic</th>
<th>Author</th>
<th>Created</th>
<th>Updated</th>
<th>Comments</th>
{%- if user is not none and user.is_moderator() -%}
<th>Action</th>
{%- endif -%}
</tr>
{% for id, title, ctime, utime, author_id, author, comment_count, hidden in threads %}
<tr>
<th>{{ '[hidden] ' if hidden else '' }}<a href="{{ url_for('thread', thread_id = id) }}">{{ title }}</a></th>
<td><a href="{{ url_for('user_info', user_id = author_id) }}">{{ author }}</a></td>
<td>{{ format_since(ctime) }}</td>
<td>{{ format_since(utime) }}</td>
<td>{{ comment_count }}</td>
<td>
{%- if user is not none and user.is_moderator() %}
{{- moderate_thread(id, hidden) }}
{%- endif -%}
</td>
</tr>
{%- endfor -%}
</table>
{{- nav() -}}
{% endblock %}

19
forum/templates/help.html Normal file
View File

@@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% block content %}
<h2>Formatting</h2>
<ul>
<li>
Prepend two spaces to format code, e.g.
<div>{{ minimd(' This is code\n More code') | safe }}</div>
</li>
<li>
Lists, starting with '- ' or '0.', '1.' ... automatically have linebreaks added.
e.g.
<div>{{ minimd('- Item A\n- Item B') | safe }}</div>
Note that there must be a single space between '-' and the rest of the line.
</li>
<li>
Words starting with 'http://' or 'https://' are automatically turned into links.
</li>
</ul>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends 'base.html' %}
{%- block content %}
<p>{{ minimd(description) | safe }}</p>
<table>
<tr>
<th>Forum</th>
<th>Last update</th>
</tr>
{% for id, name, description, t_id, t_title, t_mtime in forums %}
<tr>
<td>
<p><a href="{{ url_for('forum', forum_id = id) }}"><b>{{ name }}</b></a></p>
<p>{{ minimd(description) | safe }}</p>
</td>
{% if t_id %}
<td>
<p><a href="{{ url_for('thread', thread_id = t_id) }}"><b>{{ t_title }}</b></a></p>
<p>{{ format_since(t_mtime) }}</p>
</td>
{% else %}
<td>No threads</td>
{% endif %}
</tr>
{%- endfor -%}
</table>
{%- endblock %}

View File

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

View File

@@ -0,0 +1,15 @@
{% macro moderate_thread(id, hidden) %}
<form method=post action="{{ url_for('set_hide_thread', thread_id = id) }}">
<input name=redirect value="{{ request.full_path }}" hidden>
<input name=hide value={{ 0 if hidden else 1 }} hidden>
<input type=submit value="{{ 'Unhide' if hidden else 'Hide' }}">
</form>
{% endmacro %}
{% macro moderate_comment(id, hidden) %}
<form method=post action="{{ url_for('set_hide_comment', comment_id = id) }}" style=display:inline>
<input name=redirect value="{{ request.full_path }}" hidden>
<input name=hide value={{ 0 if hidden else 1 }} hidden>
<input type=submit value="{{ 'Unhide' if hidden else 'Hide' }}">
</form>
{% endmacro %}

View File

@@ -0,0 +1,45 @@
{% extends 'base.html' %}
{% block content %}
{%- if user is none -%}
<form method="post">
{#-
Using the password generator for usernames should be sufficient to ensure it is unique.
If not, it means the password generator is broken and *must* be fixed.
-#}
<input type=text name=username value="{{ rand_password() }}" hidden>
<input type=password name=password value="{{ rand_password() }}" hidden>
{%- set q, a = gen_captcha() -%}
<input type=text name=answer value="{{ a }}" hidden>
<table class=form>
<tr>
<td>Title</td>
<td><input type="text" name="title" required></td>
</tr>
<tr>
<td>Text</td>
<td><textarea name="text" required></textarea></td>
</tr>
<tr>
<td>{{ q }}</td>
<td><input type=text name=captcha required></td>
</tr>
</table>
<p><input type="submit" value="Register & post"> (<a href="{{ url_for('login') }}">I already have an account</a>)</p>
</form>
{%- else -%}
<form method="post">
<table class=form>
<tr>
<td>Title</td>
<td><input type="text" name="title" required></td>
</tr>
<tr>
<td>Text</td>
<td><textarea name="text" required></textarea></td>
</tr>
</table>
<p><input type="submit" value="Post"></p>
</form>
{%- endif -%}
{% endblock %}

View File

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

View File

@@ -0,0 +1,18 @@
{%- extends 'base.html' %}
{%- from 'comment.html' import render_comment, reply, thread_author with context %}
{%- from 'moderator.html' import moderate_thread with context %}
{%- block content %}
<p><span> &laquo; </span><a href="{{ url_for('forum', forum_id = forum_id) }}">{{ forum_title }}</a></p>
{%- if user is not none and user.is_moderator() -%}
<p>{{ moderate_thread(thread_id, hidden) }}</p>
{%- endif -%}
{{- thread_author(author_id, author, create_time, modify_time) }}
<p>{{ minimd(text) | safe }}</p>
{{- reply() }}
{%- for c in comments %}
{{- render_comment(c, thread_id) }}
{%- endfor %}
{%- endblock %}

View File

@@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% block content %}
<p><a href="{{ url_for('user_info', user_id = user.id) }}">View public profile</a></p>
<form method="post">
<table>
<tr><td>Username</td><td>{{ user.name }}</td></tr>
<tr><td>ID</td><td>{{ user.id }}</td></tr>
<tr><td>About</td><td><textarea name="about">{{ about }}</textarea></td></tr>
</table>
<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 required></td></tr>
<tr><td>New password</td><td><input type=password name=new required></td></tr>
</table>
<input type="submit" value="Set password">
</form>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends 'base.html' %}
{%- block content %}
{%- if user is not none and user.is_moderator() -%}
<p>
<form method=post action="{{ url_for('moderator_ban_user', user_id = id) }}">
<input type=number name=days placeholder=days>
<input type=time name=time>
<input type=submit value=Ban>
</form>
{%- if banned_until > 0 -%}
<form method=post action="{{ url_for('moderator_unban_user', user_id = id) }}">
{{- format_time(banned_until) -}}
<input type=submit value=Unban>
</form>
{%- endif -%}
</p>
{%- endif -%}
<p><sup><i>{{ name }}</i></sup></p>
<p>{{ minimd(about) | safe }}<p>
{%- endblock %}

24
forum/test/all.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
SQLITE=sqlite3
FLASK=flask
set -e
set -x
tmp=$(mktemp -d)
trap 'rm -rf $tmp' EXIT
base=$(dirname "$0")
db=$tmp/forum.db
. $base/../venv/bin/activate
# initialize db
$base/../init_sqlite.sh $db --no-admin
$SQLITE $db < $base/init_db.txt
cd $base/..
export DB=$db
export SERVER=dev
$FLASK --app main --debug run $TEST_FLASK_ARGS

33
forum/test/init_db.txt Normal file
View File

@@ -0,0 +1,33 @@
insert into users (name, password, join_time) values (
"foo",
-- supasecret
"$argon2id$v=19$m=65536,t=3,p=4$qBWCEAKgdA4BYOy915qzlg$KhGy3UF0QMlplt2eB7r7QNL2kDcggXUimRWUrWql8sI",
0
);
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",
-- e
"$argon2id$v=19$m=65536,t=3,p=4$9v5fS2ktxTinNEbIGUOoFQ$LMdEuAuuTCJ7utOE88+nXn7o6R/DEKY8ZA6wV+YkVGQ",
0,
2
);
insert into forums (name, description)
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)
values (1, 1, 0, 0, 0, "Hello, world!",
'In its most general sense, the term "world" refers to the totality of entities, to the whole of reality or to everything that is.');
insert into comments (author_id, thread_id, create_time, modify_time, text)
values (2, 1, 0, 0, "Hi!");
insert into comments (author_id, thread_id, create_time, modify_time, text, parent_id)
values (3, 1, 0, 0, "Greetings.", 1);
update config set registration_enabled = 1;

28
forum/tool.py Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import sys, password
def arg(i, s):
if i < len(sys.argv):
return sys.argv[i]
print(s)
sys.exit(1)
def arg_last(i, s):
if i == len(sys.argv) - 1:
return sys.argv[i]
print(s)
sys.exit(1)
proc = "tool.py" if len(sys.argv) < 1 else sys.argv[0]
cmd = arg(1, f"usage: {proc} <command> [...]")
if cmd == "password":
pwd = arg_last(2, "usage: {proc} password <pwd>")
print(password.hash(pwd))
else:
print("unknown command ", cmd)
sys.exit(1)

View File

@@ -0,0 +1,9 @@
set -ex
test $# == 1
"$SQLITE" "$1" "
begin exclusive;
alter table threads rename dead to hidden;
alter table comments rename dead to hidden;
update config set version = 'agreper-v0.1.1';
end;
"

86
forum/upgrade_sqlite.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Script to upgrade a database from one version to another by adding columns,
# tables etc.
# Upgrade scripts go into upgrade/sqlite/
# If there are multiple changes after a revision but before a new one, suffix a
# letter (e.g. `v0.1.1a`).
# When a new revision is out, add a script that changes just the version.
LAST_VERSION=agreper-v0.1.1
SQLITE=sqlite3
export SQLITE
set -e
if [ $# -lt 1 ]
then
echo "Usage: $0 <file.db> [--no-backup]" >&2
exit 1
fi
make_backup=0
if [ $# -ge 2 ]
then
case "$2" in
--no-backup)
make_backup=1
;;
*)
echo "Unknown option $2"
exit 1
;;
esac
fi
if ! [ -f "$1" ]
then
echo "Database '$1' doesn't exist" >&2
exit 1
fi
version=$(sqlite3 "$1" 'select version from config')
while true
do
case "$version" in
# Last version, do nothing
agreper-v0.1.1)
echo "$version is the latest version"
exit 0
;;
# Try to upgrade
agreper-*)
echo "Upgrading from $version"
if [ $make_backup ]
then
backup="$1.bak-$version"
if [ -f "$backup" ]
then
echo "Backup '$backup' already exists (did a previous upgrade fail?)" >&2
exit 1
fi
echo "Creating backup of $1 at $backup"
cp --reflink=auto "$1" "$backup"
make_backup=1
fi
script="./upgrade/sqlite/${version#agreper-}.sh"
if ! bash "$script" "$1"
then
echo "Error while executing $script"
exit 1
fi
;;
# Unrecognized version
*)
echo "Unknown version $version" >&2
exit 1
;;
esac
version=$(sqlite3 "$1" 'select version from config')
done

3
forum/version.py Executable file
View File

@@ -0,0 +1,3 @@
VERSION = "agreper-v0.1.1q1"