Captchas, require captcha initially, generalize notices to states
このコミットが含まれているのは:
コミット
b7313eec22
|
@ -5,9 +5,10 @@ from collections import OrderedDict
|
|||
from quart import Quart
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from anonstream.utils.user import generate_token
|
||||
from anonstream.utils.colour import color_to_colour
|
||||
from anonstream.segments import DirectoryCache
|
||||
from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer
|
||||
from anonstream.utils.colour import color_to_colour
|
||||
from anonstream.utils.user import generate_token
|
||||
|
||||
def create_app():
|
||||
with open('config.toml') as fp:
|
||||
|
@ -26,7 +27,8 @@ def create_app():
|
|||
'AUTH_TOKEN': generate_token(),
|
||||
'DEFAULT_HOST_NAME': config['names']['broadcaster'],
|
||||
'DEFAULT_ANON_NAME': config['names']['anonymous'],
|
||||
'MAX_NOTICES': config['memory']['notices'],
|
||||
'MAX_STATES': config['memory']['states'],
|
||||
'MAX_CAPTCHAS': config['memory']['captchas'],
|
||||
'MAX_CHAT_MESSAGES': config['memory']['chat_messages'],
|
||||
'MAX_CHAT_SCROLLBACK': config['memory']['chat_scrollback'],
|
||||
'CHECKUP_PERIOD_USER': config['intervals']['sunset_users'],
|
||||
|
@ -39,9 +41,15 @@ def create_app():
|
|||
'CHAT_NAME_MAX_LENGTH': config['chat']['max_name_length'],
|
||||
'CHAT_NAME_MIN_CONTRAST': config['chat']['min_name_contrast'],
|
||||
'CHAT_BACKGROUND_COLOUR': color_to_colour(config['chat']['background_color']),
|
||||
'CAPTCHA_LIFETIME': config['captcha']['lifetime'],
|
||||
'CAPTCHA_FONTS': config['captcha']['fonts'],
|
||||
'CAPTCHA_ALPHABET': config['captcha']['alphabet'],
|
||||
'CAPTCHA_LENGTH': config['captcha']['length'],
|
||||
'CAPTCHA_BACKGROUND_COLOUR': color_to_colour(config['captcha']['background_color']),
|
||||
'CAPTCHA_FOREGROUND_COLOUR': color_to_colour(config['captcha']['foreground_color']),
|
||||
})
|
||||
|
||||
assert app.config['MAX_NOTICES'] >= 0
|
||||
assert app.config['MAX_STATES'] >= 0
|
||||
assert app.config['MAX_CHAT_SCROLLBACK'] >= 0
|
||||
assert (
|
||||
app.config['MAX_CHAT_MESSAGES'] >= app.config['MAX_CHAT_SCROLLBACK']
|
||||
|
@ -57,6 +65,9 @@ def create_app():
|
|||
app.messages = app.messages_by_id.values()
|
||||
app.users = app.users_by_token.values()
|
||||
app.segments_directory_cache = DirectoryCache(config['stream']['segments_dir'])
|
||||
app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS'])
|
||||
app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY'])
|
||||
app.captchas = OrderedDict()
|
||||
|
||||
app.background_sleep = set()
|
||||
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
import secrets
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.helpers.captcha import generate_captcha_digest, generate_captcha_image
|
||||
|
||||
CONFIG = current_app.config
|
||||
CAPTCHA_FACTORY = current_app.captcha_factory
|
||||
CAPTCHA_SIGNER = current_app.captcha_signer
|
||||
CAPTCHAS = current_app.captchas
|
||||
|
||||
def generate_random_captcha_solution():
|
||||
return ''.join(
|
||||
secrets.choice(CONFIG['CAPTCHA_ALPHABET'])
|
||||
for _ in range(CONFIG['CAPTCHA_LENGTH'])
|
||||
)
|
||||
|
||||
def _get_random_cached_captcha_digest():
|
||||
chosen_index = secrets.randbelow(len(CAPTCHAS))
|
||||
for index, digest in enumerate(CAPTCHAS):
|
||||
if index == chosen_index:
|
||||
break
|
||||
return digest
|
||||
|
||||
def get_random_captcha_digest():
|
||||
if len(CAPTCHAS) >= CONFIG['MAX_CAPTCHAS']:
|
||||
digest = _get_random_cached_captcha_digest()
|
||||
else:
|
||||
salt = secrets.token_bytes(16)
|
||||
solution = generate_random_captcha_solution()
|
||||
digest = generate_captcha_digest(CAPTCHA_SIGNER, salt, solution)
|
||||
CAPTCHAS[digest] = {'solution': solution}
|
||||
while len(CAPTCHAS) >= CONFIG['MAX_CAPTCHAS']:
|
||||
CAPTCHAS.popitem(last=False)
|
||||
|
||||
return digest
|
||||
|
||||
def get_captcha_image(digest):
|
||||
try:
|
||||
captcha = CAPTCHAS[digest]
|
||||
except KeyError:
|
||||
return None
|
||||
else:
|
||||
if 'image' not in captcha:
|
||||
captcha['image'] = generate_captcha_image(
|
||||
factory=CAPTCHA_FACTORY,
|
||||
solution=captcha.pop('solution'),
|
||||
)
|
||||
return captcha['image']
|
|
@ -12,7 +12,7 @@ MESSAGES = current_app.messages
|
|||
USERS_BY_TOKEN = current_app.users_by_token
|
||||
USERS = current_app.users
|
||||
|
||||
class Rejected(Exception):
|
||||
class Rejected(ValueError):
|
||||
pass
|
||||
|
||||
def broadcast(users, payload):
|
||||
|
@ -29,7 +29,11 @@ def messages_for_websocket():
|
|||
get_scrollback(MESSAGES),
|
||||
))
|
||||
|
||||
def add_chat_message(user, nonce, comment):
|
||||
def add_chat_message(user, nonce, comment, ignore_empty=False):
|
||||
# special case: if the comment is empty, do nothing and return
|
||||
if ignore_empty and len(comment) == 0:
|
||||
return
|
||||
|
||||
# check message
|
||||
message_id = generate_nonce_hash(nonce)
|
||||
if message_id in MESSAGES_BY_ID:
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import io
|
||||
from enum import Enum
|
||||
|
||||
from itsdangerous import TimestampSigner
|
||||
from itsdangerous.exc import BadTimeSignature, SignatureExpired
|
||||
from quart import current_app
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
||||
Answer = Enum('Answer', names=('OK', 'EXPIRED', 'BAD', 'MISSING'))
|
||||
|
||||
def generate_captcha_image(factory, solution):
|
||||
im = factory.create_captcha_image(
|
||||
solution,
|
||||
CONFIG['CAPTCHA_FOREGROUND_COLOUR'],
|
||||
CONFIG['CAPTCHA_BACKGROUND_COLOUR'],
|
||||
)
|
||||
buffer = io.BytesIO()
|
||||
im.save(buffer, format='jpeg', quality=75, optimize=True)
|
||||
buffer.seek(0)
|
||||
return buffer.read()
|
||||
|
||||
def _generate_captcha_unsigned_digest(salt, solution):
|
||||
parts = (
|
||||
CONFIG['SECRET_KEY']
|
||||
+ b'captcha-digest\0'
|
||||
+ salt
|
||||
+ solution.encode()
|
||||
)
|
||||
raw_unsigned_digest = hashlib.sha256(parts).digest()[:16] + salt
|
||||
return base64.b64encode(raw_unsigned_digest).removesuffix(b'=')
|
||||
|
||||
def generate_captcha_digest(signer, salt, solution):
|
||||
unsigned_digest = _generate_captcha_unsigned_digest(salt, solution)
|
||||
return signer.sign(unsigned_digest).decode()
|
||||
|
||||
def check_captcha_digest(signer, digest, answer):
|
||||
if len(answer) == 0:
|
||||
result = Answer.MISSING
|
||||
else:
|
||||
try:
|
||||
unsigned_digest = signer.unsign(
|
||||
digest,
|
||||
max_age=CONFIG['CAPTCHA_LIFETIME']
|
||||
)
|
||||
except BadTimeSignature:
|
||||
result = Answer.BAD
|
||||
except SignatureExpired:
|
||||
result = Answer.EXPIRED
|
||||
else:
|
||||
try:
|
||||
raw_unsigned_digest = (
|
||||
base64.urlsafe_b64decode(unsigned_digest + b'=')
|
||||
)
|
||||
except binascii.Error:
|
||||
result = Answer.BAD
|
||||
else:
|
||||
salt = raw_unsigned_digest[16:]
|
||||
true_unsigned_digest = (
|
||||
_generate_captcha_unsigned_digest(salt, answer)
|
||||
)
|
||||
if unsigned_digest == true_unsigned_digest:
|
||||
result = Answer.OK
|
||||
else:
|
||||
result = Answer.BAD
|
||||
|
||||
return result
|
|
@ -34,12 +34,13 @@ def generate_user(timestamp, token, broadcaster):
|
|||
return {
|
||||
'token': token,
|
||||
'token_hash': generate_token_hash(token),
|
||||
'websockets': set(),
|
||||
'broadcaster': broadcaster,
|
||||
'verified': broadcaster,
|
||||
'websockets': set(),
|
||||
'name': None,
|
||||
'color': colour_to_color(colour),
|
||||
'tripcode': None,
|
||||
'notices': OrderedDict(),
|
||||
'state': OrderedDict(),
|
||||
'last': {
|
||||
'seen': timestamp,
|
||||
'watching': -inf,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from quart import current_app, request, render_template, redirect, url_for
|
||||
from quart import current_app, request, render_template, redirect, url_for, abort
|
||||
|
||||
from anonstream.captcha import get_captcha_image
|
||||
from anonstream.segments import CatSegments, Offline
|
||||
from anonstream.routes.wrappers import with_user_from, auth_required
|
||||
|
||||
|
@ -27,3 +28,13 @@ async def stream(user):
|
|||
@auth_required
|
||||
async def login():
|
||||
return redirect(url_for('home'))
|
||||
|
||||
@current_app.route('/captcha.jpg')
|
||||
@with_user_from(request)
|
||||
async def captcha(user):
|
||||
digest = request.args.get('digest', '')
|
||||
image = get_captcha_image(digest)
|
||||
if image is None:
|
||||
return abort(410)
|
||||
else:
|
||||
return image
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
from quart import current_app, request, render_template, redirect, url_for, escape, Markup
|
||||
|
||||
from anonstream.stream import get_stream_title
|
||||
from anonstream.user import add_notice, pop_notice, try_change_appearance
|
||||
from anonstream.captcha import get_random_captcha_digest
|
||||
from anonstream.chat import add_chat_message, Rejected
|
||||
from anonstream.stream import get_stream_title
|
||||
from anonstream.user import add_state, pop_state, try_change_appearance, verify, BadCaptcha
|
||||
from anonstream.routes.wrappers import with_user_from, render_template_with_etag
|
||||
from anonstream.helpers.user import get_default_name
|
||||
from anonstream.helpers.chat import get_scrollback
|
||||
from anonstream.helpers.user import get_default_name
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
from anonstream.utils.user import concatenate_for_notice
|
||||
|
||||
CONFIG = current_app.config
|
||||
USERS_BY_TOKEN = current_app.users_by_token
|
||||
|
||||
@current_app.route('/info.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_info(user):
|
||||
|
@ -24,13 +28,13 @@ async def nojs_chat(user):
|
|||
return await render_template_with_etag(
|
||||
'nojs_chat.html',
|
||||
user=user,
|
||||
users_by_token=current_app.users_by_token,
|
||||
users_by_token=USERS_BY_TOKEN,
|
||||
messages=get_scrollback(current_app.messages),
|
||||
timeout=current_app.config['THRESHOLD_NOJS_CHAT_TIMEOUT'],
|
||||
timeout=CONFIG['THRESHOLD_NOJS_CHAT_TIMEOUT'],
|
||||
get_default_name=get_default_name,
|
||||
)
|
||||
|
||||
@current_app.route('/chat/redirect')
|
||||
@current_app.route('/chat/messages')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_redirect(user):
|
||||
return redirect(url_for('nojs_chat', _anchor='end'))
|
||||
|
@ -38,35 +42,70 @@ async def nojs_chat_redirect(user):
|
|||
@current_app.route('/chat/form.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_form(user):
|
||||
notice_id = request.args.get('notice', type=int)
|
||||
notice, verbose = pop_notice(user, notice_id)
|
||||
state_id = request.args.get('state', type=int)
|
||||
state = pop_state(user, state_id)
|
||||
prefer_chat_form = request.args.get('landing') != 'appearance'
|
||||
digest = None if user['verified'] else get_random_captcha_digest()
|
||||
return await render_template(
|
||||
'nojs_form.html',
|
||||
user=user,
|
||||
notice=notice,
|
||||
verbose=verbose,
|
||||
state=state,
|
||||
prefer_chat_form=prefer_chat_form,
|
||||
nonce=generate_nonce(),
|
||||
digest=digest,
|
||||
default_name=get_default_name(user),
|
||||
)
|
||||
|
||||
@current_app.post('/chat/form')
|
||||
@with_user_from(request)
|
||||
async def nojs_form_redirect(user):
|
||||
comment = (await request.form).get('comment', '')
|
||||
if len(comment) > CONFIG['CHAT_COMMENT_MAX_LENGTH']:
|
||||
comment = ''
|
||||
|
||||
if comment:
|
||||
state_id = add_state(user, comment=comment)
|
||||
else:
|
||||
state_id = None
|
||||
|
||||
return redirect(url_for('nojs_form', state=state_id))
|
||||
|
||||
@current_app.post('/chat/message')
|
||||
@with_user_from(request)
|
||||
async def nojs_submit_message(user):
|
||||
form = await request.form
|
||||
|
||||
comment = form.get('comment', '')
|
||||
nonce = form.get('nonce', '')
|
||||
|
||||
digest = form.get('captcha-digest', '')
|
||||
answer = form.get('captcha-answer', '')
|
||||
try:
|
||||
add_chat_message(user, nonce, comment)
|
||||
except Rejected as e:
|
||||
verification_happened = verify(user, digest, answer)
|
||||
except BadCaptcha as e:
|
||||
notice, *_ = e.args
|
||||
notice_id = add_notice(user, notice)
|
||||
state_id = add_state(user, notice=notice, comment=comment)
|
||||
else:
|
||||
notice_id = None
|
||||
nonce = form.get('nonce', '')
|
||||
try:
|
||||
# if the comment is empty but the captcha was just solved,
|
||||
# be lenient: don't raise an exception and don't create a notice
|
||||
add_chat_message(
|
||||
user,
|
||||
nonce,
|
||||
comment,
|
||||
ignore_empty=verification_happened,
|
||||
)
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
state_id = add_state(user, notice=notice)
|
||||
else:
|
||||
state_id = None
|
||||
|
||||
return redirect(url_for('nojs_form', token=user['token'], landing='chat', notice=notice_id))
|
||||
return redirect(url_for(
|
||||
'nojs_form',
|
||||
token=user['token'],
|
||||
landing='chat',
|
||||
state=state_id,
|
||||
))
|
||||
|
||||
@current_app.post('/chat/appearance')
|
||||
@with_user_from(request)
|
||||
|
@ -93,10 +132,10 @@ async def nojs_submit_appearance(user):
|
|||
else:
|
||||
notice = 'Changed appearance'
|
||||
|
||||
notice_id = add_notice(user, notice, verbose=len(errors) > 1)
|
||||
state_id = add_state(user, notice=notice, verbose=len(errors) > 1)
|
||||
return redirect(url_for(
|
||||
'nojs_form',
|
||||
token=user['token'],
|
||||
landing='appearance' if errors else 'chat',
|
||||
notice=notice_id,
|
||||
state=state_id,
|
||||
))
|
||||
|
|
|
@ -11,7 +11,7 @@ async def live(user):
|
|||
queue = asyncio.Queue(maxsize=0)
|
||||
user['websockets'].add(queue)
|
||||
|
||||
producer = websocket_outbound(queue)
|
||||
producer = websocket_outbound(queue, user)
|
||||
consumer = websocket_inbound(queue, user)
|
||||
try:
|
||||
await asyncio.gather(producer, consumer)
|
||||
|
|
|
@ -81,11 +81,11 @@
|
|||
|
||||
#chat-form {
|
||||
display: grid;
|
||||
grid: auto 2rem / auto 5rem;
|
||||
grid: auto 2rem / auto min-content min-content 5rem;
|
||||
}
|
||||
#chat-form__comment {
|
||||
resize: none;
|
||||
grid-column: 1 / span 2;
|
||||
grid-column: 1 / span 4;
|
||||
background-color: #434347;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
|
@ -100,6 +100,18 @@
|
|||
background-color: black;
|
||||
border-color: #3584e4;
|
||||
}
|
||||
#chat-form__captcha-image {
|
||||
align-self: center;
|
||||
font-size: 8pt;
|
||||
color: inherit;
|
||||
}
|
||||
#chat-form__captcha-answer {
|
||||
min-width: auto;
|
||||
width: 8ch;
|
||||
}
|
||||
#chat-form__submit {
|
||||
grid-column: 4;
|
||||
}
|
||||
|
||||
#appearance-form {
|
||||
grid-auto-rows: 1fr 1fr 2rem;
|
||||
|
@ -166,7 +178,7 @@
|
|||
#chat:target ~ #appearance-form {
|
||||
display: none;
|
||||
}
|
||||
{% if notice != none %}
|
||||
{% if state.notice %}
|
||||
#chat-form {
|
||||
display: none;
|
||||
}
|
||||
|
@ -183,17 +195,22 @@
|
|||
<body>
|
||||
<div id="chat"></div>
|
||||
<div id="appearance"></div>
|
||||
{% if notice != none %}
|
||||
<a id="notice" {% if verbose %}class="verbose" {% endif %}{% if prefer_chat_form %}href="#chat"{% else %}href="#appearance"{% endif %}>
|
||||
<header><h1>{{ notice }}</h1></header>
|
||||
{% if state.notice %}
|
||||
<a id="notice" {% if state.verbose %}class="verbose" {% endif %}{% if prefer_chat_form %}href="#chat"{% else %}href="#appearance"{% endif %}>
|
||||
<header><h1>{{ state.notice }}</h1></header>
|
||||
<small>Click to dismiss</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
<form id="chat-form" action="{{ url_for('nojs_submit_message', token=user.token) }}" method="post">
|
||||
<input type="hidden" name="nonce" value="{{ nonce }}">
|
||||
<textarea id="chat-form__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1" tabindex="1"></textarea>
|
||||
<textarea id="chat-form__comment" name="comment" maxlength="512" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1">{{ state.comment }}</textarea>
|
||||
<div id="chat-form__exit"><a href="#appearance">Settings</a></div>
|
||||
<input id="chat-form__submit" type="submit" value="Chat" tabindex="2" accesskey="p">
|
||||
{% if digest %}
|
||||
<input type="hidden" name="captcha-digest" value="{{ digest }}">
|
||||
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="Captcha failed to load" title="Click for a new captcha" tabindex="2">
|
||||
<input id="chat-form__captcha-answer" name="captcha-answer" required placeholder="Captcha" tabindex="3">
|
||||
{% endif %}
|
||||
<input id="chat-form__submit" type="submit" value="Chat" tabindex="4" accesskey="p">
|
||||
</form>
|
||||
<form id="appearance-form" action="{{ url_for('nojs_submit_appearance', token=user.token) }}" method="post">
|
||||
<label id="appearance-form__label-name" for="appearance-form__name">Name:</label>
|
||||
|
|
|
@ -6,6 +6,7 @@ from quart import current_app
|
|||
from anonstream.chat import broadcast
|
||||
from anonstream.wrappers import try_except_log, with_timestamp
|
||||
from anonstream.helpers.user import is_visible
|
||||
from anonstream.helpers.captcha import check_captcha_digest, Answer
|
||||
from anonstream.helpers.tripcode import generate_tripcode
|
||||
from anonstream.utils.colour import color_to_colour, get_contrast, NotAColor
|
||||
from anonstream.utils.user import user_for_websocket
|
||||
|
@ -13,23 +14,27 @@ from anonstream.utils.user import user_for_websocket
|
|||
CONFIG = current_app.config
|
||||
MESSAGES = current_app.messages
|
||||
USERS = current_app.users
|
||||
CAPTCHA_SIGNER = current_app.captcha_signer
|
||||
|
||||
class BadAppearance(Exception):
|
||||
class BadAppearance(ValueError):
|
||||
pass
|
||||
|
||||
def add_notice(user, notice, verbose=False):
|
||||
notice_id = time.time_ns() // 1_000_000
|
||||
user['notices'][notice_id] = (notice, verbose)
|
||||
while len(user['notices']) > CONFIG['MAX_NOTICES']:
|
||||
user['notices'].popitem(last=False)
|
||||
return notice_id
|
||||
class BadCaptcha(ValueError):
|
||||
pass
|
||||
|
||||
def pop_notice(user, notice_id):
|
||||
def add_state(user, **state):
|
||||
state_id = time.time_ns() // 1_000_000
|
||||
user['state'][state_id] = state
|
||||
while len(user['state']) > CONFIG['MAX_STATES']:
|
||||
user['state'].popitem(last=False)
|
||||
return state_id
|
||||
|
||||
def pop_state(user, state_id):
|
||||
try:
|
||||
notice, verbose = user['notices'].pop(notice_id)
|
||||
state = user['state'].pop(state_id)
|
||||
except KeyError:
|
||||
notice, verbose = None, False
|
||||
return notice, verbose
|
||||
state = None
|
||||
return state
|
||||
|
||||
def try_change_appearance(user, name, color, password,
|
||||
want_delete_tripcode, want_change_tripcode):
|
||||
|
@ -117,3 +122,20 @@ def users_for_websocket(timestamp):
|
|||
user['token_hash']: user_for_websocket(user)
|
||||
for user in visible_users
|
||||
}
|
||||
|
||||
def verify(user, digest, answer):
|
||||
if user['verified']:
|
||||
verification_happened = False
|
||||
else:
|
||||
match check_captcha_digest(CAPTCHA_SIGNER, digest, answer):
|
||||
case Answer.MISSING:
|
||||
raise BadCaptcha('Captcha is required')
|
||||
case Answer.BAD:
|
||||
raise BadCaptcha('Captcha was incorrect')
|
||||
case Answer.EXPIRED:
|
||||
raise BadCaptcha('Captcha has expired')
|
||||
case Answer.OK:
|
||||
user['verified'] = True
|
||||
verification_happened = True
|
||||
|
||||
return verification_happened
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import hashlib
|
||||
|
||||
from captcha.image import ImageCaptcha
|
||||
from itsdangerous import TimestampSigner
|
||||
|
||||
def create_captcha_factory(fonts):
|
||||
return ImageCaptcha(
|
||||
width=72,
|
||||
height=30,
|
||||
fonts=fonts,
|
||||
font_sizes=(24, 27, 30),
|
||||
)
|
||||
|
||||
def create_captcha_signer(secret_key):
|
||||
return TimestampSigner(
|
||||
secret_key=secret_key,
|
||||
salt=b'captcha-signature',
|
||||
digest_method=hashlib.sha256,
|
||||
)
|
|
@ -13,4 +13,7 @@ def parse_websocket_data(receipt):
|
|||
if not isinstance(nonce, str):
|
||||
raise Malformed('malformed nonce')
|
||||
|
||||
return nonce, comment
|
||||
digest = receipt.get('digest', '')
|
||||
answer = receipt.get('answer', '')
|
||||
|
||||
return nonce, comment, digest, answer
|
||||
|
|
|
@ -3,15 +3,16 @@ import asyncio
|
|||
from quart import current_app, websocket
|
||||
|
||||
from anonstream.stream import get_stream_title, get_stream_uptime
|
||||
from anonstream.captcha import get_random_captcha_digest
|
||||
from anonstream.chat import messages_for_websocket, add_chat_message, Rejected
|
||||
from anonstream.user import users_for_websocket, see
|
||||
from anonstream.user import users_for_websocket, see, verify, BadCaptcha
|
||||
from anonstream.wrappers import with_first_argument
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
from anonstream.utils.websocket import parse_websocket_data, Malformed
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
||||
async def websocket_outbound(queue):
|
||||
async def websocket_outbound(queue, user):
|
||||
payload = {
|
||||
'type': 'init',
|
||||
'nonce': generate_nonce(),
|
||||
|
@ -24,6 +25,7 @@ async def websocket_outbound(queue):
|
|||
False: CONFIG['DEFAULT_ANON_NAME'],
|
||||
},
|
||||
'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'],
|
||||
'digest': None if user['verified'] else get_random_captcha_digest(),
|
||||
}
|
||||
await websocket.send_json(payload)
|
||||
while True:
|
||||
|
@ -35,7 +37,7 @@ async def websocket_inbound(queue, user):
|
|||
receipt = await websocket.receive_json()
|
||||
see(user)
|
||||
try:
|
||||
nonce, comment = parse_websocket_data(receipt)
|
||||
nonce, comment, digest, answer = parse_websocket_data(receipt)
|
||||
except Malformed as e:
|
||||
error , *_ = e.args
|
||||
payload = {
|
||||
|
@ -44,17 +46,27 @@ async def websocket_inbound(queue, user):
|
|||
}
|
||||
else:
|
||||
try:
|
||||
markup = add_chat_message(user, nonce, comment)
|
||||
except Rejected as e:
|
||||
verify(user, digest, answer)
|
||||
except BadCaptcha as e:
|
||||
notice, *_ = e.args
|
||||
payload = {
|
||||
'type': 'reject',
|
||||
'type': 'captcha',
|
||||
'notice': notice,
|
||||
'digest': get_random_captcha_digest(),
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
'type': 'ack',
|
||||
'nonce': nonce,
|
||||
'next': generate_nonce(),
|
||||
}
|
||||
try:
|
||||
markup = add_chat_message(user, nonce, comment)
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
payload = {
|
||||
'type': 'reject',
|
||||
'notice': notice,
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
'type': 'ack',
|
||||
'nonce': nonce,
|
||||
'next': generate_nonce(),
|
||||
}
|
||||
queue.put_nowait(payload)
|
||||
|
|
27
config.toml
27
config.toml
|
@ -6,6 +6,24 @@ username = "broadcaster"
|
|||
[stream]
|
||||
segments_dir = "stream/"
|
||||
|
||||
[captcha]
|
||||
lifetime = 1800
|
||||
fonts = []
|
||||
alphabet = "346abegkmprtuwxy"
|
||||
length = 3
|
||||
background_color = "#232327"
|
||||
foreground_color = "#dddddd"
|
||||
|
||||
[memory]
|
||||
states = 32
|
||||
captchas = 256
|
||||
chat_messages = 8192
|
||||
chat_scrollback = 256
|
||||
|
||||
[intervals]
|
||||
sunset_users = 60
|
||||
expire_captchas = 60
|
||||
|
||||
[names]
|
||||
broadcaster = "Broadcaster"
|
||||
anonymous = "Anonymous"
|
||||
|
@ -16,15 +34,6 @@ max_name_length = 24
|
|||
min_name_contrast = 3.0
|
||||
background_color = "#232327"
|
||||
|
||||
[memory]
|
||||
notices = 32
|
||||
chat_messages = 8192
|
||||
chat_scrollback = 256
|
||||
|
||||
[intervals]
|
||||
sunset_users = 60
|
||||
expire_captchas = 60
|
||||
|
||||
[thresholds]
|
||||
user_notwatching = 8
|
||||
user_tentative = 20
|
||||
|
|
読み込み中…
新しいイシューから参照