diff --git a/README.md b/README.md index b19456d..400be37 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ Lastly, run with: You will need a proxy such as nginx to access the forum on the public internet. +## Upgrading + +To upgrade from a previous version, run ``upgrade_sqlite.sh`` + ## Screenshots ![Index](https://static.agreper.com/index.png) diff --git a/db/sqlite.py b/db/sqlite.py index 0d72f44..dad5562 100644 --- a/db/sqlite.py +++ b/db/sqlite.py @@ -19,7 +19,7 @@ class DB: on t.thread_id = ( select tt.thread_id from threads tt - where f.forum_id = tt.forum_id + where f.forum_id = tt.forum_id and not tt.hidden order by update_time desc limit 1 ) @@ -35,31 +35,64 @@ class DB: (forum_id,) ).fetchone() - def get_threads(self, forum_id, offset, limit): + 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) - from threads t, users - left join comments c on t.thread_id = c.thread_id - where forum_id = ? and user_id = t.author_id + 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, 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 = db.execute(''' - select title, text, name, author_id, create_time, modify_time + title, text, author, author_id, create_time, modify_time, hidden = db.execute(''' + select title, text, name, author_id, create_time, modify_time, hidden 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 + select + comment_id, + parent_id, + author_id, + name, + text, + create_time, + modify_time, + hidden from comments left join users on author_id = user_id @@ -67,7 +100,7 @@ class DB: ''', (thread,) ) - return title, text, author, author_id, create_time, modify_time, comments + return title, text, author, author_id, create_time, modify_time, comments, hidden def get_thread_title(self, thread_id): return self._db().execute(''' @@ -123,8 +156,21 @@ class DB: union select comment_id from descendant_of, comments where id = parent_id ) - select id, parent_id, author_id, name, text, create_time, modify_time from descendant_of, comments, users - where id = comment_id and user_id = author_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,) ) @@ -475,6 +521,24 @@ class DB: (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() diff --git a/init_sqlite.sh b/init_sqlite.sh index 6eb1f95..2709ceb 100755 --- a/init_sqlite.sh +++ b/init_sqlite.sh @@ -38,7 +38,7 @@ $SQLITE "$1" -init schema.txt "insert into config ( registration_enabled ) values ( - 'agreper-v0.1', + 'agreper-v0.1.1', 'Agreper', '', '$(head -c 30 /dev/urandom | base64)', diff --git a/main.py b/main.py index 97ac77d..e7d1ed8 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -VERSION = 'agreper-v0.1' +VERSION = 'agreper-v0.1.1' # TODO put in config table THREADS_PER_PAGE = 50 @@ -42,7 +42,8 @@ def index(): def forum(forum_id): title, description = db.get_forum(forum_id) offset = int(request.args.get('p', 0)) - threads = [*db.get_threads(forum_id, offset, THREADS_PER_PAGE + 1)] + 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 @@ -62,18 +63,19 @@ def forum(forum_id): @app.route('/thread//') def thread(thread_id): - user_id = session.get('user_id') - title, text, author, author_id, create_time, modify_time, comments = db.get_thread(thread_id) - comments = create_comment_tree(comments) + user = get_user() + title, text, author, author_id, create_time, modify_time, comments, hidden = db.get_thread(thread_id) + comments = create_comment_tree(comments, user) return render_template( 'thread.html', title = title, config = config, - user = get_user(), + user = user, text = text, author = author, author_id = author_id, thread_id = thread_id, + hidden = hidden, create_time = create_time, modify_time = modify_time, comments = comments, @@ -81,8 +83,9 @@ def thread(thread_id): @app.route('/comment//') def comment(comment_id): + user = get_user() thread_id, parent_id, title, comments = db.get_subcomments(comment_id) - comments = create_comment_tree(comments) + comments = create_comment_tree(comments, user) reply_comment, = comments comments = reply_comment.children reply_comment.children = [] @@ -90,7 +93,7 @@ def comment(comment_id): 'comments.html', title = title, config = config, - user = get_user(), + user = user, reply_comment = reply_comment, comments = comments, parent_id = parent_id, @@ -574,6 +577,42 @@ def admin_restart(): restart() return redirect(url_for('admin')) +@app.route('/thread//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//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(): @@ -601,7 +640,7 @@ def _admin_check(): class Comment: - def __init__(self, id, author_id, author, text, create_time, modify_time, parent_id): + 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 @@ -610,23 +649,35 @@ class Comment: self.create_time = create_time self.modify_time = modify_time self.parent_id = parent_id + self.hidden = hidden -def create_comment_tree(comments): +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 = { - comment_id: Comment(comment_id, author_id, author, text, create_time, modify_time, parent_id) - for comment_id, parent_id, author_id, author, text, create_time, modify_time - in comments - } + 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 - for comment in comment_map.values(): + 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) diff --git a/schema.txt b/schema.txt index fdd8869..819618e 100644 --- a/schema.txt +++ b/schema.txt @@ -27,7 +27,7 @@ create table threads ( title varchar(64) not null, text text not null, score integer not null default 0, - dead boolean not null default false + hidden boolean not null default false ); create table comments ( @@ -39,7 +39,7 @@ create table comments ( modify_time integer not null, text text not null, score integer not null default 0, - dead boolean not null default false + hidden boolean not null default false ); create table forums ( diff --git a/templates/comment.html b/templates/comment.html index d67ca67..d4b4024 100644 --- a/templates/comment.html +++ b/templates/comment.html @@ -1,10 +1,13 @@ +{% from 'moderator.html' import moderate_comment with context -%} + {%- macro author(id, name, ctime, mtime) -%} {{ name }} - {{ format_since(ctime) }}{% if ctime != mtime %} (last modified {{ format_since(mtime) }}){% endif %} {%- endmacro -%} {%- macro comment_author(comment, thread_id, can_delete) -%} -{{- author(comment.author_id, comment.author, comment.create_time, comment.modify_time) }} | +{{- '[hidden]' if comment.hidden else '' }} +{{ author(comment.author_id, comment.author, comment.create_time, comment.modify_time) }} | {# Suffixing a # prevents unnecessary reloads #} thread {%- if comment.parent_id is not none -%} @@ -15,6 +18,9 @@ {%- if can_delete -%} delete {%- endif -%} +{%- if user.is_moderator() -%} +{{ moderate_comment(comment.id, comment.hidden) }} +{%- endif -%} {%- endif -%} {%- endmacro -%} diff --git a/templates/forum.html b/templates/forum.html index 7c4350f..ac7c1f4 100644 --- a/templates/forum.html +++ b/templates/forum.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{%- from 'moderator.html' import moderate_thread with context %} {%- macro nav() -%}

@@ -19,14 +20,22 @@ Created Updated Comments + {%- if user is not none and user.is_moderator() -%} + Action + {%- endif -%} - {% for id, title, ctime, utime, author_id, author, comment_count in threads %} + {% for id, title, ctime, utime, author_id, author, comment_count, hidden in threads %} - {{ title }} + {{ '[hidden] ' if hidden else '' }}{{ title }} {{ author }} {{ format_since(ctime) }} {{ format_since(utime) }} {{ comment_count }} + + {%- if user is not none and user.is_moderator() %} + {{- moderate_thread(id, hidden) }} + {%- endif -%} + {%- endfor -%} diff --git a/templates/moderator.html b/templates/moderator.html new file mode 100644 index 0000000..d6a0bc8 --- /dev/null +++ b/templates/moderator.html @@ -0,0 +1,15 @@ +{% macro moderate_thread(id, hidden) %} +

+ + + +
+{% endmacro %} + +{% macro moderate_comment(id, hidden) %} +
+ + + +
+{% endmacro %} diff --git a/templates/thread.html b/templates/thread.html index 80a6c62..049a636 100644 --- a/templates/thread.html +++ b/templates/thread.html @@ -1,8 +1,12 @@ {%- extends 'base.html' %} {%- from 'comment.html' import render_comment, reply, thread_author with context %} +{%- from 'moderator.html' import moderate_thread with context %} {%- block content %} -{{ thread_author(author_id, author, create_time, modify_time) }} +{%- if user is not none and user.is_moderator() -%} +

{{ moderate_thread(thread_id, hidden) }}

+{%- endif -%} +{{- thread_author(author_id, author, create_time, modify_time) }}

{{ minimd(text) | safe }}

{{- reply() }} diff --git a/upgrade/sqlite/v0.1.sh b/upgrade/sqlite/v0.1.sh new file mode 100644 index 0000000..8c1b7f0 --- /dev/null +++ b/upgrade/sqlite/v0.1.sh @@ -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; +" diff --git a/upgrade_sqlite.sh b/upgrade_sqlite.sh new file mode 100755 index 0000000..5837d8a --- /dev/null +++ b/upgrade_sqlite.sh @@ -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 [--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