server WIP

This commit is contained in:
jaseg 2023-12-23 14:05:28 +01:00
parent 7d00923533
commit 7622cc99ca
6 changed files with 198 additions and 10 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
__pycache__
db.sqlite3
venv

6
eightserve.cfg Normal file
View file

@ -0,0 +1,6 @@
DISPLAY_COLUMNS = 32
DATABASE_URL = "sqlite:///db.sqlite3"
SUSLIST_TIMEOUT = "01:00"
SUSLIST_THRESHOLD = 5
MESSAGE_MAX_LINES = 10

View file

@ -1,15 +1,26 @@
#!/usr/bin/env python #!/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 = Quart(__name__)
app.config.from_envvar('APP_CONFIG') app.config.from_envvar('APP_CONFIG', silent=True)
if app.config['SECRET_KEY'] is None: 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() app.config['SECRET_KEY'] = p.read_bytes()
else: else:
app.config['SECRET_KEY'] = os.urandom(32) app.config['SECRET_KEY'] = os.urandom(32)
try: try:
p.touch(0o600)
p.write_bytes(app.config['SECRET_KEY']) p.write_bytes(app.config['SECRET_KEY'])
except: except:
pass pass
@ -18,7 +29,12 @@ app.config.update(
SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax', 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 @app.before_request
@ -29,13 +45,109 @@ async def ensure_session_id():
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
async def index(): 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']) @app.route('/post', methods=['GET', 'POST'])
async def post(): async def post():
if session.get('consent', False): if not session.get('consent'):
return await fun(*args, **kwargs) 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: 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)

34
migrations/0.py Normal file
View file

@ -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

View file

@ -1,5 +1,8 @@
<html> <!DOCTYPE html>
<html lang="en">
<head> <head>
<title>8seg Web Interface</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head> </head>
<body> <body>
<h1>8seg Web Interface</h1> <h1>8seg Web Interface</h1>
@ -7,10 +10,14 @@
offensive messages, spam or similar. 8seg is currently open to the congress network on the hope that y'all will 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? ;) </p> behave civilly. Please don't make me put a login wall in front of this thing, alright? ;) </p>
<p> If you have any questions, or you see 8seg being used by someone for bad, please email me at <p>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.</p>
<p> If you have any questions, or you see 8seg being used for something stupid or bad, please email me at
<a href="mailto:37c3.m@jaseg.de">37c3.m@jaseg.de</a> </p> <a href="mailto:37c3.m@jaseg.de">37c3.m@jaseg.de</a> </p>
<form action="POST"> <form method="POST">
<input type="hidden" name="csrf_token" value="{{csrf_token}}"/>
<button type="submit">I agree to not be an asshole</button> <button type="submit">I agree to not be an asshole</button>
</form> </form>
</body> </body>

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>8seg Web Interface</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class=flashes>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
There are approximately {{count}} messages in queue.
<form id="message-form" method="POST">
<input type="hidden" name="csrf_token" value="{{csrf_token}}"/>
<textarea name="message" cols="{{cols}}" rows="{{rows}}" placeholder="Message up to {{cols}} characters per line, displayed line by line">{{message}}</textarea>
<button type="submit">Post</button>
</form>
</body>
<style>