restructure for docker
This commit is contained in:
36
forum/Dockerfile
Normal file
36
forum/Dockerfile
Normal 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
25
forum/captcha.py
Normal 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
696
forum/db/sqlite.py
Normal 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
7
forum/docker-builder.sh
Executable 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
50
forum/docker-entrypoint.sh
Executable 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
58
forum/init_sqlite.sh
Executable 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
935
forum/main.py
Normal 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
70
forum/minimd.py
Executable 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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
9
forum/password.py
Normal 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
5
forum/requirements.txt
Normal 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
27
forum/restart.sh
Executable 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
22
forum/run_sqlite.sh
Executable 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
55
forum/schema.txt
Normal 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
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
140
forum/static/theme.css
Normal 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;
|
||||
}
|
||||
45
forum/templates/admin/base.html
Normal file
45
forum/templates/admin/base.html
Normal 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>
|
||||
129
forum/templates/admin/index.html
Normal file
129
forum/templates/admin/index.html
Normal file
@@ -0,0 +1,129 @@
|
||||
{% extends 'admin/base.html' -%}
|
||||
{% block content -%}
|
||||
<h2>Query</h2>
|
||||
<p>⚠ Only use queries if you know what you're doing ⚠</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 %}
|
||||
17
forum/templates/admin/query.html
Normal file
17
forum/templates/admin/query.html
Normal 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
48
forum/templates/base.html
Normal 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>
|
||||
82
forum/templates/comment.html
Normal file
82
forum/templates/comment.html
Normal 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 -%}
|
||||
16
forum/templates/comments.html
Normal file
16
forum/templates/comments.html
Normal 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> « </span><a href="{{ url_for('forum', forum_id = forum_id) }}">{{ forum_title }}</a><span> « </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 %}
|
||||
14
forum/templates/confirm_delete_comment.html
Normal file
14
forum/templates/confirm_delete_comment.html
Normal 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 %}
|
||||
13
forum/templates/confirm_delete_thread.html
Normal file
13
forum/templates/confirm_delete_thread.html
Normal 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 %}
|
||||
17
forum/templates/edit_comment.html
Normal file
17
forum/templates/edit_comment.html
Normal 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 %}
|
||||
17
forum/templates/edit_thread.html
Normal file
17
forum/templates/edit_thread.html
Normal 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 %}
|
||||
43
forum/templates/forum.html
Normal file
43
forum/templates/forum.html
Normal 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> « </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
19
forum/templates/help.html
Normal 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 %}
|
||||
27
forum/templates/index.html
Normal file
27
forum/templates/index.html
Normal 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 %}
|
||||
11
forum/templates/login.html
Normal file
11
forum/templates/login.html
Normal 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 %}
|
||||
15
forum/templates/moderator.html
Normal file
15
forum/templates/moderator.html
Normal 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 %}
|
||||
45
forum/templates/new_thread.html
Normal file
45
forum/templates/new_thread.html
Normal 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 %}
|
||||
17
forum/templates/register.html
Normal file
17
forum/templates/register.html
Normal 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 %}
|
||||
18
forum/templates/thread.html
Normal file
18
forum/templates/thread.html
Normal 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> « </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 %}
|
||||
21
forum/templates/user_edit.html
Normal file
21
forum/templates/user_edit.html
Normal 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 %}
|
||||
21
forum/templates/user_info.html
Normal file
21
forum/templates/user_info.html
Normal 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
24
forum/test/all.sh
Executable 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
33
forum/test/init_db.txt
Normal 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
28
forum/tool.py
Executable 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)
|
||||
9
forum/upgrade/sqlite/v0.1.sh
Normal file
9
forum/upgrade/sqlite/v0.1.sh
Normal 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
86
forum/upgrade_sqlite.sh
Executable 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
3
forum/version.py
Executable file
@@ -0,0 +1,3 @@
|
||||
|
||||
VERSION = "agreper-v0.1.1q1"
|
||||
|
||||
Reference in New Issue
Block a user