diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ba40d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +db.sqlite3 +venv diff --git a/eightserve.cfg b/eightserve.cfg new file mode 100644 index 0000000..f993fbf --- /dev/null +++ b/eightserve.cfg @@ -0,0 +1,6 @@ + +DISPLAY_COLUMNS = 32 +DATABASE_URL = "sqlite:///db.sqlite3" +SUSLIST_TIMEOUT = "01:00" +SUSLIST_THRESHOLD = 5 +MESSAGE_MAX_LINES = 10 diff --git a/eightserve.py b/eightserve.py index 22f13b4..c7f24c8 100644 --- a/eightserve.py +++ b/eightserve.py @@ -1,15 +1,26 @@ #!/usr/bin/env python -from quart import Quart, url_for, redirect, session, make_response, render_template, request, send_file, abort, flash +import re +import secrets +from pathlib import Path +import os +import hashlib +from uuid import uuid4 + +from quart import Quart, url_for, redirect, session, make_response, render_template, request, send_file, abort, flash, g +from quart_db import QuartDB +import datetime app = Quart(__name__) -app.config.from_envvar('APP_CONFIG') +app.config.from_envvar('APP_CONFIG', silent=True) if app.config['SECRET_KEY'] is None: - if (p := Path('/run/secrets/eightserve')).is_file(): + if (p := Path('/tmp/eightserve-secret')).is_file(): app.config['SECRET_KEY'] = p.read_bytes() + else: app.config['SECRET_KEY'] = os.urandom(32) try: + p.touch(0o600) p.write_bytes(app.config['SECRET_KEY']) except: pass @@ -18,7 +29,12 @@ app.config.update( SESSION_COOKIE_SECURE=True, SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax', +# SERVER_NAME='8seg.jaseg.de', ) +DISPLAY_COLUMNS = app.config.get('DISPLAY_COLUMNS', 32) +MESSAGE_MAX_LINES = app.config.get('MESSAGE_MAX_LINES', 10) + +db = QuartDB(app, url=app.config.get('DATABASE_URL', 'sqlite:///db.sqlite3'), ) @app.before_request @@ -29,13 +45,109 @@ async def ensure_session_id(): @app.route('/', methods=['GET', 'POST']) async def index(): - return await render_template('index.html') + if session.get('consent'): + return redirect(url_for('post')) + + elif (await request.form).get('csrf_token') == session.get('csrf_token', -1): + session['consent'] = True + return redirect(url_for('post')) + + else: + tok = session['csrf_token'] = secrets.token_urlsafe() + return await render_template('index.html', csrf_token=tok) @app.route('/post', methods=['GET', 'POST']) async def post(): - if session.get('consent', False): - return await fun(*args, **kwargs) + if not session.get('consent'): + return redirect(url_for('index')) + + result = await g.connection.fetch_one( + 'SELECT COUNT(*) AS count FROM messages WHERE timestamp_displayed IS NULL AND suppress_display = 0') + digest = hashlib.sha3_256(b'eightserve_queue_length_random') + digest.update(request.remote_addr.encode()) + digest.update(app.config['SECRET_KEY']) + digest.update(str(result['count']).encode()) + digest.update(datetime.datetime.now().strftime('%y%m%d%H%M%S').encode()) + digest = digest.digest() + count = round(max(5, (result['count'] * (1 + (digest[23]/255 - 0.5) * 0.1)) + (digest[42//2]%16 - 8))/5) * 5 + + if request.method == 'POST': + data = await request.form + message = data.get('message', '') + sub = re.sub("[^a-zA-Z0-9\',._:<(\[\])>!?^]", "", message).strip() + + if data.get('csrf_token') != session.get('csrf_token', -1): + await flash('Incorrect CSRF token.') + + elif not re.sub('[^a-zA-Z0-9\',._:<(\[\])>!?^]', '', message).strip(): + await flash('Message does not contain any displayable characters.') + + elif any(len(line.strip()) > DISPLAY_COLUMNS*2 for line in message.splitlines()): + await flash('Message has one or more lines that are too long.') + + elif len(message.splitlines()) > MESSAGE_MAX_LINES: + await flash('Message has too many lines.') else: - return redirect(url_for('/')) + + result = await g.connection.fetch_one( + 'SELECT COUNT(*) AS count FROM shitlist WHERE remote_ip = :ip', {'ip': request.remote_addr}) + on_shitlist = result['count'] > 0 + + result = await g.connection.fetch_one( + 'SELECT COUNT(*) AS count FROM suslist WHERE remote_ip = :ip AND timestamp_added > datetime("now", :timeout)', + {'ip': request.remote_addr, 'timeout': '-' + app.config.get('SUSLIST_TIMEOUT', '01:00')}) + on_suslist = result['count'] > app.config.get('SUSLIST_THRESHOLD', 5) + + badwords = {row['word'] for row in await g.connection.fetch_all('SELECT word FROM badwords')} + words = [re.sub(r'[^a-zA-Z]', '', word).lower().strip() for word in message.split()] + has_badwords = any(word in badwords for word in words if word) + + if on_shitlist: + suppress = True + moderated = False + + elif on_suslist or has_badwords: + moderated = True + suppress = True + await g.connection.execute('INSERT INTO suslist (remote_ip, reason) VALUES (:ip, :reason)', + {'ip': request.remote_addr, 'reason': 'suslist' if on_suslist else 'badwords'}) + + else: + moderated = False + suppress = False + + flags = [] + if on_suslist: + flags.append('suslist') + if on_shitlist: + flags.append('shitlist') + if moderated: + flags.append('moderated') + if suppress: + flags.append('suppress') + + flags = f' ({", ".join(flags)})' if flags else '' + print(f'Message submitted to queue(n={result["count"]}){flags}: {message!r}') + await g.connection.execute( + '''INSERT INTO messages (message, suppress_display, moderated, remote_ip) + VALUES (:message, :suppress_display, :moderated, :remote_ip)''', dict( + message=message, + suppress_display=suppress, + moderated=moderated, + remote_ip=request.remote_addr)) + + await flash('Message submitted.') + message = '' + + tok = session['csrf_token'] = secrets.token_urlsafe() + return await render_template('post.html', cols=DISPLAY_COLUMNS, rows=MESSAGE_MAX_LINES, message=message, count=count, csrf_token=tok) + + else: + tok = session['csrf_token'] = secrets.token_urlsafe() + return await render_template('post.html', cols=DISPLAY_COLUMNS, rows=MESSAGE_MAX_LINES, message='', count=count, csrf_token=tok) + +if __name__ == '__main__': + app.run(debug=True) + diff --git a/migrations/0.py b/migrations/0.py new file mode 100644 index 0000000..ca90295 --- /dev/null +++ b/migrations/0.py @@ -0,0 +1,34 @@ + +async def migrate(conn): + await conn.execute('''CREATE TABLE IF NOT EXISTS messages ( + message TEXT, + suppress_display INTEGER, + moderated INTEGER, + remote_ip TEXT, + timestamp_received TEXT DEFAULT (datetime('now')), + timestamp_displayed TEXT DEFAULT NULL + )''') + + await conn.execute('''CREATE TABLE IF NOT EXISTS shitlist ( + remote_ip TEXT, + reason TEXT, + timestamp_added TEXT DEFAULT (datetime('now')) + )''') + + await conn.execute('''CREATE TABLE IF NOT EXISTS suslist ( + remote_ip TEXT, + reason TEXT, + timestamp_added TEXT DEFAULT (datetime('now')) + )''') + + await conn.execute('''CREATE TABLE IF NOT EXISTS badwords ( + word TEXT, + reason TEXT, + timestamp_added TEXT DEFAULT (datetime('now')) + )''') + + await conn.execute('''INSERT INTO badwords (word, reason) VALUES ("bitcoin", "cryptocurrency")''') + + +async def valid_migration(conn): + return True diff --git a/templates/index.html b/templates/index.html index 8efabbb..b818ff0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,5 +1,8 @@ - + + + 8seg Web Interface +

8seg Web Interface

@@ -7,10 +10,14 @@ offensive messages, spam or similar. 8seg is currently open to the congress network on the hope that y'all will behave civilly. Please don't make me put a login wall in front of this thing, alright? ;)

-

If you have any questions, or you see 8seg being used by someone for bad, please email me at +

Please do not post automated messages at a frequency higher than one per 20 minutes. No spam, no + commercial advertising and absolutely nothing cryptocurrency-related.

+ +

If you have any questions, or you see 8seg being used for something stupid or bad, please email me at 37c3.m@jaseg.de

-
+ +
diff --git a/templates/post.html b/templates/post.html index e69de29..5658424 100644 --- a/templates/post.html +++ b/templates/post.html @@ -0,0 +1,26 @@ + + + + 8seg Web Interface + + + + {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} + + There are approximately {{count}} messages in queue. + +
+ + + +
+ +