コミットを比較

...

12 コミット

作成者 SHA1 メッセージ 日付
n9k 9860559c00 More sensible variable names in colour generation 2022-06-14 06:00:37 +00:00
n9k d8d5796e3d Take a range of contrasts for generating colours 2022-06-14 05:59:52 +00:00
n9k 03acd14b77 Require Authorization header for broadcaster
As opposed to just the broadcaster token. This makes the broadcaster
username/password login mandatory, which previously was only mandatory
in the `auth_required` wrapper, but not elsewhere (so for example
leaving comments as the broadcaster was possible with the token only). A
less safe alternative to this would be to compare tokens in `check_auth`
once the Authorization header didn't match.
2022-06-14 05:04:11 +00:00
n9k 9e7f3f875c Explicitly reject weird tokens
Includes really long tokens
2022-06-14 04:50:28 +00:00
n9k f9b8f7c6f8 Control socket: escape json whitespace if necessary 2022-06-14 03:52:11 +00:00
n9k 7db8895750 Eyes: send Retry-After header during cooldown 2022-06-14 03:33:14 +00:00
n9k a594b6ed73 Eyes: only necessary arguments in exceptions 2022-06-14 03:32:12 +00:00
n9k f081284876 Eyes: cooldown on creating new eyes 2022-06-14 03:02:45 +00:00
n9k 51265fb277 Eyes: delete old eyes
Also implements stack/queue behaviour where if the eyes limit would be
exceeded, either the new eyes cause the oldest eyes to be deleted OR
the new eyes aren't created at all. The default is the first option.
2022-06-14 02:58:11 +00:00
n9k 31ce80b2bf Control socket: view and change users' attributes
Changing things without thinking about it is probably going to cause
weird undefined behaviour.
2022-06-14 02:40:20 +00:00
n9k 84ad17f13d Eyes
This commit adds the concept of eyes. One "eyes" is one instance of a
response to GET /stream.mp4. Currently the number of eyes clients can
have is unbounded, but this is a DoS vector.
2022-06-14 02:40:18 +00:00
n9k 8f06121d8f WS: ping before init 2022-06-14 00:34:24 +00:00
14個のファイルの変更331行の追加48行の削除

ファイルの表示

@ -82,6 +82,7 @@ def toml_to_flask_section_memory(config):
def toml_to_flask_section_tasks(config):
cfg = config['tasks']
return {
'TASK_ROTATE_EYES': cfg['rotate_eyes'],
'TASK_ROTATE_USERS': cfg['rotate_users'],
'TASK_ROTATE_CAPTCHAS': cfg['rotate_captchas'],
'TASK_ROTATE_WEBSOCKETS': cfg['rotate_websockets'],
@ -112,11 +113,16 @@ def toml_to_flask_section_chat(config):
def toml_to_flask_section_flood(config):
cfg = config['flood']
assert cfg['video']['max_eyes'] >= 0
return {
'FLOOD_MESSAGE_DURATION': cfg['messages']['duration'],
'FLOOD_MESSAGE_THRESHOLD': cfg['messages']['threshold'],
'FLOOD_LINE_DURATION': cfg['lines']['duration'],
'FLOOD_LINE_THRESHOLD': cfg['lines']['threshold'],
'FLOOD_VIDEO_MAX_EYES': cfg['video']['max_eyes'],
'FLOOD_VIDEO_COOLDOWN': cfg['video']['cooldown'],
'FLOOD_VIDEO_EYES_EXPIRE_AFTER': cfg['video']['expire_after'],
'FLOOD_VIDEO_OVERWRITE': cfg['video']['overwrite'],
}
def toml_to_flask_section_captcha(config):

ファイルの表示

@ -1,8 +1,15 @@
import asyncio
import json
from quart import current_app
from anonstream.chat import delete_chat_messages
from anonstream.stream import get_stream_title, set_stream_title
from anonstream.utils.user import USER_WEBSOCKET_ATTRS
USERS_BY_TOKEN = current_app.users_by_token
USERS = current_app.users
USERS_UPDATE_BUFFER = current_app.users_update_buffer
class UnknownMethod(Exception):
pass
@ -28,6 +35,9 @@ class Failed(Exception):
class Exit(Exception):
pass
def json_dumps_contiguous(obj, **kwargs):
return json.dumps(obj, **kwargs).replace(' ', r'\u0020')
def start_control_server_at(address):
return asyncio.start_unix_server(serve_control_client, address)
@ -210,7 +220,7 @@ async def command_title_set(args):
await set_stream_title(title)
except OSError as e:
raise Failed(str(e)) from e
normal_options = ['set', json.dumps(title).replace(' ', r'\u0020')]
normal_options = ['set', json_dumps_contiguous(title)]
response = ''
case []:
raise Incomplete
@ -218,6 +228,112 @@ async def command_title_set(args):
raise Garbage(garbage)
return normal_options, response
async def command_user_help(args):
match args:
case []:
normal_options = ['help']
response = (
'Usage: user [show | attr USER | get USER ATTR | set USER ATTR VALUE]\n'
'Commands:\n'
' user [show]...........show all users\' tokens\n'
' user attr USER........show names of a user\'s attributes\n'
' user get USER ATTR....show an attribute of a user\n'
' user set USER ATTR....set an attribute of a user\n'
'Definitions:\n'
' USER..................={token TOKEN | hash HASH}\n'
' TOKEN.................a token\n'
' HASH..................a token hash\n'
' ATTR..................a user attribute, re:[a-z0-9_]+\n'
)
case [*garbage]:
raise Garbage(garbage)
return normal_options, response
async def command_user_show(args):
match args:
case []:
normal_options = ['show']
response = json.dumps(tuple(USERS_BY_TOKEN)) + '\n'
case [*garbage]:
raise Garbage(garbage)
return normal_options, response
async def command_user_attr(args):
match args:
case []:
raise Incomplete
case ['token', token_json]:
try:
token = json.loads(token_json)
except json.JSONDecodeError:
raise BadArgument('could not decode token as json')
try:
user = USERS_BY_TOKEN[token]
except KeyError:
raise Failed(f"no user exists with token {token!r}, try 'user show'")
normal_options = ['attr', 'token', json_dumps_contiguous(token)]
response = json.dumps(tuple(user.keys())) + '\n'
case [*garbage]:
raise Garbage(garbage)
return normal_options, response
async def command_user_get(args):
match args:
case ['token', token_json, attr]:
try:
token = json.loads(token_json)
except json.JSONDecodeError:
raise BadArgument('could not decode token as json')
try:
user = USERS_BY_TOKEN[token]
except KeyError:
raise Failed(f"no user exists with token {token!r}, try 'user show'")
try:
value = user[attr]
except KeyError:
raise Failed(f"user has no attribute {attr!r}, try 'user attr token {json_dumps_contiguous(token)}'")
try:
value_json = json.dumps(value)
except TypeError:
raise Failed(f'attribute {attr!r} is not JSON serializable')
normal_options = ['get', 'token', json_dumps_contiguous(token), attr]
response = value_json + '\n'
case []:
raise Incomplete
case [*garbage]:
raise Garbage(garbage)
return normal_options, response
async def command_user_set(args):
match args:
case ['token', token_json, attr, value_json]:
try:
token = json.loads(token_json)
except json.JSONDecodeError:
raise BadArgument('could not decode token as json')
try:
user = USERS_BY_TOKEN[token]
except KeyError:
raise Failed(f"no user exists with token {token!r}, try 'user show'")
try:
value = user[attr]
except KeyError:
raise Failed(f"user has no attribute {attr!r}, try 'user attr token {json_dumps_contiguous(token)}")
try:
value = json.loads(value_json)
except JSON.JSONDecodeError:
raise Failed('could not decode json')
user[attr] = value
if attr in USER_WEBSOCKET_ATTRS:
USERS_UPDATE_BUFFER.add(token)
normal_options = ['set', 'token', json_dumps_contiguous(token), attr, json_dumps_contiguous(value)]
response = ''
case []:
raise Incomplete
case [*garbage]:
raise Garbage(garbage)
return normal_options, response
async def command_chat_help(args):
match args:
case []:
@ -254,6 +370,7 @@ METHOD_HELP = 'help'
METHOD_EXIT = 'exit'
METHOD_TITLE = 'title'
METHOD_CHAT = 'chat'
METHOD_USER = 'user'
METHOD_COMMAND_FUNCTIONS = {
METHOD_HELP: {
@ -274,5 +391,13 @@ METHOD_COMMAND_FUNCTIONS = {
None: command_chat_help,
'help': command_chat_help,
'delete': command_chat_delete,
}
},
METHOD_USER: {
None: command_user_show,
'help': command_user_help,
'show': command_user_show,
'attr': command_user_attr,
'get': command_user_get,
'set': command_user_set,
},
}

ファイルの表示

@ -38,7 +38,8 @@ def generate_tripcode(password):
background_colour = generate_colour(
seed='tripcode-background\0' + digest,
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
contrast=5.0,
min_contrast=5.0,
max_contrast=5.0,
)
foreground_colour = generate_maximum_contrast_colour(
seed='tripcode-foreground\0' + digest,

ファイルの表示

@ -26,7 +26,7 @@ def generate_user(timestamp, token, broadcaster, presence):
colour = generate_colour(
seed='name\0' + token,
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
contrast=4.53,
min_contrast=4.53,
)
token_hash, tag = generate_token_hash_and_tag(token)
return {
@ -43,9 +43,14 @@ def generate_user(timestamp, token, broadcaster, presence):
'last': {
'seen': timestamp,
'watching': -inf,
'eyes': -inf,
},
'presence': presence,
'linespan': deque(),
'eyes': {
'total': 0,
'current': {},
},
}
def get_default_name(user):

ファイルの表示

@ -6,7 +6,7 @@ from quart.asgi import ASGIHTTPConnection as ASGIHTTPConnection_
from quart.utils import encode_headers
RESPONSE_ITERATOR_TIMEOUT = 10
RESPONSE_ITERATOR_TIMEOUT = 10.0
class ASGIHTTPConnection(ASGIHTTPConnection_):

ファイルの表示

@ -1,12 +1,15 @@
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
# SPDX-License-Identifier: AGPL-3.0-or-later
import math
from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort
from werkzeug.exceptions import TooManyRequests
from anonstream.captcha import get_captcha_image
from anonstream.segments import segments
from anonstream.segments import segments, StopSendingSegments
from anonstream.stream import is_online, get_stream_uptime
from anonstream.user import watched
from anonstream.user import watched, create_eyes, renew_eyes, EyesException, RatelimitedEyes
from anonstream.routes.wrappers import with_user_from, auth_required
from anonstream.utils.security import generate_csp
@ -25,8 +28,20 @@ async def stream(user):
if not is_online():
return abort(404)
try:
eyes_id = create_eyes(user, dict(request.headers))
except RatelimitedEyes as e:
retry_after, *_ = e.args
return TooManyRequests(), {'Retry-After': math.ceil(retry_after)}
except EyesException:
return abort(429)
def segment_read_hook(uri):
print(f'{uri}: {user["token"]}')
try:
renew_eyes(user, eyes_id, just_read_new_segment=True)
except EyesException as e:
raise StopSendingSegments(f'eyes {eyes_id} not allowed: {e!r}') from e
print(f'{uri}: {eyes_id}~{user["token"]}')
watched(user)
generator = segments(segment_read_hook, token=user['token'])

ファイルの表示

@ -2,6 +2,9 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import hashlib
import hmac
import re
import string
import time
from functools import wraps
@ -19,6 +22,15 @@ USERS_BY_TOKEN = current_app.users_by_token
USERS = current_app.users
USERS_UPDATE_BUFFER = current_app.users_update_buffer
TOKEN_ALPHABET = (
string.digits
+ string.ascii_lowercase
+ string.ascii_uppercase
+ string.punctuation
+ ' '
)
RE_TOKEN = re.compile(r'[%s]{1,256}' % re.escape(TOKEN_ALPHABET))
def check_auth(context):
auth = context.authorization
return (
@ -68,6 +80,12 @@ def with_user_from(context):
or context.cookies.get('token')
or generate_token()
)
if hmac.compare_digest(token, CONFIG['AUTH_TOKEN']):
raise abort(401)
# Reject invalid tokens
if not RE_TOKEN.fullmatch(token):
raise abort(400)
# Update / create user
user = USERS_BY_TOKEN.get(token)

ファイルの表示

@ -22,6 +22,9 @@ class Stale(Exception):
class UnsafePath(Exception):
pass
class StopSendingSegments(Exception):
pass
def get_mtime():
try:
mtime = os.path.getmtime(CONFIG['SEGMENT_PLAYLIST'])
@ -148,7 +151,15 @@ async def segments(segment_read_hook=lambda uri: None, token=None):
)
break
segment_read_hook(uri)
try:
segment_read_hook(uri)
except StopSendingSegments as e:
reason, *_ = e.args
print(
f'[debug @ {time.time():.3f}: {token=}] '
f'told to stop sending segments: {reason}'
)
break
try:
async with aiofiles.open(path, 'rb') as fp:
while chunk := await fp.read(8192):

ファイルの表示

@ -43,6 +43,21 @@ def with_period(period):
return periodically
@with_period(CONFIG['TASK_ROTATE_EYES'])
@with_timestamp
async def t_delete_eyes(timestamp, iteration):
if iteration == 0:
return
else:
for user in USERS:
to_delete = []
for eyes_id, eyes in user['eyes']['current'].items():
renewed_ago = timestamp - eyes['renewed']
if renewed_ago >= CONFIG['FLOOD_VIDEO_EYES_EXPIRE_AFTER']:
to_delete.append(eyes_id)
for eyes_id in to_delete:
user['eyes']['current'].pop(eyes_id)
@with_period(CONFIG['TASK_ROTATE_USERS'])
@with_timestamp
async def t_sunset_users(timestamp, iteration):
@ -166,6 +181,7 @@ async def t_broadcast_stream_info_update(iteration):
if payload:
broadcast(USERS, payload={'type': 'info', **payload})
current_app.add_background_task(t_delete_eyes)
current_app.add_background_task(t_sunset_users)
current_app.add_background_task(t_expire_captchas)
current_app.add_background_task(t_close_websockets)

ファイルの表示

@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k]
# SPDX-License-Identifier: AGPL-3.0-or-later
import operator
import time
from math import inf
@ -25,6 +26,21 @@ class BadAppearance(ValueError):
class BadCaptcha(ValueError):
pass
class EyesException(Exception):
pass
class TooManyEyes(EyesException):
pass
class RatelimitedEyes(EyesException):
pass
class DeletedEyes(EyesException):
pass
class ExpiredEyes(EyesException):
pass
def add_state(user, **state):
state_id = time.time_ns() // 1_000_000
user['state'][state_id] = state
@ -219,3 +235,55 @@ def get_users_by_presence(timestamp):
for user in get_users_and_update_presence(timestamp):
users_by_presence[user['presence']].append(user)
return users_by_presence
@with_timestamp
def create_eyes(timestamp, user, headers):
# Enforce cooldown
last_created_ago = timestamp - user['last']['eyes']
cooldown_ended_ago = last_created_ago - CONFIG['FLOOD_VIDEO_COOLDOWN']
cooldown_remaining = -cooldown_ended_ago
if cooldown_remaining > 0:
raise RatelimitedEyes(cooldown_remaining)
# Enforce max_eyes & overwrite
if len(user['eyes']['current']) >= CONFIG['FLOOD_VIDEO_MAX_EYES']:
# Treat eyes as a stack, do not create new eyes if it would
# cause the limit to be exceeded
if not CONFIG['FLOOD_VIDEO_OVERWRITE']:
raise TooManyEyes
# Treat eyes as a queue, expire old eyes upon creating new eyes
# if the limit would have been exceeded otherwise
elif user['eyes']['current']:
oldest_eyes_id = min(user['eyes']['current'])
user['eyes']['current'].pop(oldest_eyes_id)
# Create eyes
eyes_id = user['eyes']['total']
user['eyes']['total'] += 1
user['last']['eyes'] = timestamp
user['eyes']['current'][eyes_id] = {
'id': eyes_id,
'token': user['token'],
'n_segments': 0,
'headers': headers,
'created': timestamp,
'renewed': timestamp,
}
return eyes_id
@with_timestamp
def renew_eyes(timestamp, user, eyes_id, just_read_new_segment=False):
try:
eyes = user['eyes']['current'][eyes_id]
except KeyError:
raise DeletedEyes
# Enforce expire_after (if the background task hasn't already)
renewed_ago = timestamp - eyes['renewed']
if renewed_ago >= CONFIG['FLOOD_VIDEO_EYES_EXPIRE_AFTER']:
user['eyes']['current'].pop(eyes_id)
raise ExpiredEyes
if just_read_new_segment:
eyes['n_segments'] += 1
eyes['renewed'] = timestamp

ファイルの表示

@ -3,6 +3,7 @@
import re
import random
from math import inf
class NotAColor(Exception):
pass
@ -47,7 +48,7 @@ def _tc_to_sc(tc):
Almost-inverse of _sc_to_tc.
The function _sc_to_tc is not injective (because of the discontinuity at
sc=0.03928), thus it has no true inverse. In this implementation, whenever
sc=0.03928), thus it has no true inverse. In this implementation, whenever
for a given `tc` there are two distinct values of `sc` such that
sc_to_tc(`sc`)=`tc`, the smaller sc is chosen. (The smaller one is less
expensive to compute).
@ -89,22 +90,23 @@ def get_contrast(bg, fg):
)
return (max(lumas) + 0.05) / (min(lumas) + 0.05)
def generate_colour(seed, bg, contrast=4.5, lighter=True):
def generate_colour(seed, bg, min_contrast=4.5, max_contrast=inf, lighter=True):
'''
Generate a random colour with given contrast to `bg`.
Generate a random colour with a contrast to `bg` in a given interval.
Channels of `t` are uniformly distributed. No characteristics of the
returned colour are guaranteed to be chosen uniformly from the space of
possible values.
This works by generating an intermediate 3-tuple `t` and transforming it
into the returned colour. Channels of `t` are uniformly distributed, but no
characteristics of the returned colour are guaranteed to be chosen uniformly
from the space of possible values.
If `lighter` is true, the returned colour is forced to have a higher
relative luminance than `bg`. This is fine if `bg` is dark; if `bg` is
not dark, the space of possible returned colours will be a lot smaller
(and might be empty). If `lighter` is false, the returned colour is
forced to have a lower relative luminance than `bg`.
relative luminance than `bg`. This is fine if `bg` is dark; if `bg` is not
dark, the space of possible returned colours will be a lot smaller (and
might be empty). If `lighter` is false, the returned colour is forced to
have a lower relative luminance than `bg`.
It's simple to calculate the maximum possible contrast between `bg` and
any other colour. (The minimum contrast is always 1.)
It's simple to calculate the maximum possible contrast between `bg` and any
other colour. (The minimum contrast is always 1.)
>>> bg = (0x23, 0x23, 0x27)
>>> luma = get_relative_luminance(bg)
@ -113,11 +115,13 @@ def generate_colour(seed, bg, contrast=4.5, lighter=True):
>>> 1.05 / (luma + 0.05) # maximum contrast for colours with greater luma
15.657919499763137
There are values of `contrast` for which the space of possible returned
colours is empty. For example a `contrast` greater than 21 is always
impossible, but the exact upper bound depends on `bg`. The desired
relative luminance of the returned colour must exist in the interval [0,1].
The formula for desired luma is given below.
There are contrast intervals for which the space of possible returned
colours is empty. For example a contrast greater than 21 is always
impossible, but the exact upper bound depends on `bg`. The desired relative
luminance of the returned colour must exist in the interval [0,1]. The
formula for desired luma is given below. This is for one particular
contrast but the same formula can be used twice (once with `min_contrast` and
once with `max_contrast`) to get a range of desired lumas.
>>> bg_luma = get_relative_luminance(bg)
>>> desired_luma = (
@ -131,32 +135,37 @@ def generate_colour(seed, bg, contrast=4.5, lighter=True):
r = random.Random(seed)
if lighter:
desired_luma = contrast * (get_relative_luminance(bg) + 0.05) - 0.05
min_desired_luma = min_contrast * (get_relative_luminance(bg) + 0.05) - 0.05
max_desired_luma = max_contrast * (get_relative_luminance(bg) + 0.05) - 0.05
else:
desired_luma = (get_relative_luminance(bg) + 0.05) / contrast - 0.05
min_desired_luma = (get_relative_luminance(bg) + 0.05) / max_contrast - 0.05
max_desired_luma = (get_relative_luminance(bg) + 0.05) / min_contrast - 0.05
V = (0.2126, 0.7152, 0.0722)
indices = [0, 1, 2]
r.shuffle(indices)
i, j, k = indices
# V[i] * ci + V[j] * 0 + V[k] * 0 <= desired_luma
# V[i] * ci + V[j] * 1 + V[k] * 1 >= desired_luma
ci_upper = (desired_luma - V[j] * 0 - V[k] * 0) / V[i]
ci_lower = (desired_luma - V[j] * 1 - V[k] * 1) / V[i]
ci = r.uniform(max(0, ci_lower), min(1, ci_upper))
# V[i] * ti + V[j] * 0 + V[k] * 0 <= max_desired_luma
# V[i] * ti + V[j] * 1 + V[k] * 1 >= min_desired_luma
ti_upper = (max_desired_luma - V[j] * 0 - V[k] * 0) / V[i]
ti_lower = (min_desired_luma - V[j] * 1 - V[k] * 1) / V[i]
ti = r.uniform(max(0, ti_lower), min(1, ti_upper))
# V[i] * ci + V[j] * cj + V[k] * 0 <= desired_luma
# V[i] * ci + V[j] * cj + V[k] * 1 >= desired_luma
cj_upper = (desired_luma - V[i] * ci - V[k] * 0) / V[j]
cj_lower = (desired_luma - V[i] * ci - V[k] * 1) / V[j]
cj = r.uniform(max(0, cj_lower), min(1, cj_upper))
# V[i] * ti + V[j] * tj + V[k] * 0 <= max_desired_luma
# V[i] * ti + V[j] * tj + V[k] * 1 >= min_desired_luma
tj_upper = (max_desired_luma - V[i] * ti - V[k] * 0) / V[j]
tj_lower = (min_desired_luma - V[i] * ti - V[k] * 1) / V[j]
tj = r.uniform(max(0, tj_lower), min(1, tj_upper))
# V[i] * ci + V[j] * cj + V[k] * ck = desired_luma
ck = (desired_luma - V[i] * ci - V[j] * cj) / V[k]
# V[i] * ti + V[j] * tj + V[k] * tk <= max_desired_luma
# V[i] * ti + V[j] * tj + V[k] * tk >= min_desired_luma
tk_upper = (max_desired_luma - V[i] * ti - V[j] * tj) / V[k]
tk_lower = (min_desired_luma - V[i] * ti - V[j] * tj) / V[k]
tk = r.uniform(max(0, tk_lower), min(1, tk_upper))
t = [None, None, None]
t[i], t[j], t[k] = ci, cj, ck
t[i], t[j], t[k] = ti, tj, tk
s = map(_tc_to_sc, t)
colour = map(lambda sc: round(sc * 255), s)
@ -185,10 +194,12 @@ def generate_maximum_contrast_colour(seed, bg, proportion_of_max=31/32):
max_darker_contrast = get_maximum_contrast(bg, lighter=False)
max_contrast = max(max_lighter_contrast, max_darker_contrast)
practical_max_contrast = max_contrast * proportion_of_max
colour = generate_colour(
seed,
bg,
contrast=max_contrast * proportion_of_max,
min_contrast=practical_max_contrast,
max_contrast=practical_max_contrast,
lighter=max_lighter_contrast > max_darker_contrast,
)

ファイルの表示

@ -10,6 +10,8 @@ from math import inf
from quart import escape, Markup
USER_WEBSOCKET_ATTRS = {'broadcaster', 'name', 'color', 'tripcode', 'tag'}
Presence = Enum(
'Presence',
names=(
@ -44,8 +46,7 @@ def trilean(presence):
return None
def get_user_for_websocket(user):
keys = ('broadcaster', 'name', 'color', 'tripcode', 'tag')
return {
**{key: user[key] for key in keys},
**{key: user[key] for key in USER_WEBSOCKET_ATTRS},
'watching': trilean(user['presence']),
}

ファイルの表示

@ -17,7 +17,8 @@ from anonstream.utils.websocket import parse_websocket_data, Malformed, WS
CONFIG = current_app.config
async def websocket_outbound(queue, user):
payload = {
await websocket.send_json({'type': 'ping'})
await websocket.send_json({
'type': 'init',
'nonce': generate_nonce(),
'title': await get_stream_title(),
@ -31,9 +32,7 @@ async def websocket_outbound(queue, user):
'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'],
'digest': get_random_captcha_digest_for(user),
'pingpong': CONFIG['TASK_BROADCAST_PING'],
}
await websocket.send_json(payload)
await websocket.send_json({'type': 'ping'})
})
while True:
payload = await queue.get()
if payload['type'] == 'close':

ファイルの表示

@ -34,6 +34,7 @@ chat_messages = 8192
chat_scrollback = 256
[tasks]
rotate_eyes = 3.0
rotate_users = 60.0
rotate_captchas = 60.0
rotate_websockets = 2.0
@ -60,6 +61,12 @@ threshold = 4
duration = 20.0
threshold = 20
[flood.video]
max_eyes = 3
cooldown = 12.0
expire_after = 5.0
overwrite = true
[thresholds]
user_notwatching = 8.0
user_tentative = 20.0