From d06a279be6297fdcb2a944a7318bbf49c0567707 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 15 Jun 2022 09:38:45 +0000 Subject: [PATCH 1/7] Sanitize newlines in usernames with js Previously usernames with newlines in them actually went over multiple lines. --- anonstream/static/anonstream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 7aa082d..37de345 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -215,7 +215,7 @@ const create_chat_message = (object) => { const create_chat_user_name = (user) => { const chat_user_name = document.createElement("span"); chat_user_name.classList.add("chat-name"); - chat_user_name.innerText = get_user_name({user}); + chat_user_name.innerText = get_user_name({user}).replaceAll(/\r?\n/g, " "); //chat_user_name.dataset.color = user.color; // not working in any browser if (!user.broadcaster && user.name === null) { const b = document.createElement("b"); From 46cd032510a88e4d06ca656f2320ca28ec705894 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 15 Jun 2022 10:10:04 +0000 Subject: [PATCH 2/7] CSS: always fullheight info in desktop view --- anonstream/static/style.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 74dee06..a2a379b 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -552,6 +552,9 @@ footer { #chat-form_js[data-captcha] #chat-live__status [data-verbose="false"] { display: inline; } + #info_nojs { + height: 100% !important; + } #nochat:target { --chat-width: 0px; } From 55a713991c8150f96fbfbf13ed24928d528d8bff Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 15 Jun 2022 10:14:21 +0000 Subject: [PATCH 3/7] Nojs info: fix invisible uptime counter taking up space Also adds "visibility: hidden;" to `disappear`. It would replace "opacity: 0;" but Firefox acts weird with only visibility set, it only half works until you switch between desktop and mobile view. IDK this isn't that important. --- anonstream/templates/nojs_info.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/anonstream/templates/nojs_info.html b/anonstream/templates/nojs_info.html index 5ce88fe..c770f50 100644 --- a/anonstream/templates/nojs_info.html +++ b/anonstream/templates/nojs_info.html @@ -75,13 +75,13 @@ animation: appear step-end both; } #m1 { - animation-duration: {{ 600 - uptime }}s; + animation-delay: {{ 600 - uptime }}s; } #h0 { - animation-duration: {{ 3600 - uptime }}s; + animation-delay: {{ 3600 - uptime }}s; } #h1 { - animation-duration: {{ 36000 - uptime }}s; + animation-delay: {{ 36000 - uptime }}s; } #uptime-dynamic { animation: disappear step-end {{ 360000 - uptime }}s forwards; @@ -95,12 +95,15 @@ @keyframes appear { from { width: 0; + height: 0; visibility: hidden; } } @keyframes disappear { to { + width: 0; height: 0; + visibility: hidden; opacity: 0; } } From 1f56e635b9cccbcd4199af1a2c1eb7ed7cd3dd43 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 15 Jun 2022 20:39:02 +0000 Subject: [PATCH 4/7] Ensure chat stays at bottom if names/tripcodes change --- anonstream/static/anonstream.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 37de345..2392443 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -648,6 +648,10 @@ const on_websocket_message = (event) => { default_name = receipt.default; max_chat_scrollback = receipt.scrollback; + // if the chat is scrolled all the way to the bottom, make sure this is + // still the case after updating user names and tripcodes + at_bottom = chat_messages.scrollTop === chat_messages.scrollTopMax; + // update users users = receipt.users; update_user_names(); @@ -655,6 +659,15 @@ const on_websocket_message = (event) => { update_user_tripcodes(); update_users_list() + // ensure chat scroll (see above) + if (at_bottom) { + chat_messages.scrollTo({ + left: 0, + top: chat_messages.scrollTopMax, + behavior: "instant", + }); + } + // appearance form default values const user = users[TOKEN_HASH]; if (user.name !== null) { @@ -739,10 +752,26 @@ const on_websocket_message = (event) => { for (const token_hash of Object.keys(receipt.users)) { users[token_hash] = receipt.users[token_hash]; } + + // if the chat is scrolled all the way to the bottom, make sure this is + // still the case after updating user names and tripcodes + at_bottom = chat_messages.scrollTop === chat_messages.scrollTopMax; + + // update users update_user_names(); update_user_colors(); update_user_tripcodes(); update_users_list() + + // ensure chat scroll (see above) + if (at_bottom) { + chat_messages.scrollTo({ + left: 0, + top: chat_messages.scrollTopMax, + behavior: "instant", + }); + } + break; case "rem-users": From e449caff5f1e6d65f9a164bd77b995341ba4512b Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 15 Jun 2022 20:41:35 +0000 Subject: [PATCH 5/7] Reimplement `with_timestamp`, allow ints & floats --- anonstream/segments.py | 2 +- anonstream/stream.py | 6 +++--- anonstream/tasks.py | 6 +++--- anonstream/user.py | 16 ++++++++-------- anonstream/websocket.py | 2 +- anonstream/wrappers.py | 40 +++++++++++++++------------------------- 6 files changed, 31 insertions(+), 41 deletions(-) diff --git a/anonstream/segments.py b/anonstream/segments.py index 2267818..cc4edca 100644 --- a/anonstream/segments.py +++ b/anonstream/segments.py @@ -9,7 +9,7 @@ import aiofiles import m3u8 from quart import current_app -from anonstream.wrappers import ttl_cache, with_timestamp +from anonstream.wrappers import ttl_cache CONFIG = current_app.config diff --git a/anonstream/stream.py b/anonstream/stream.py index 6f130c0..0ee9e52 100644 --- a/anonstream/stream.py +++ b/anonstream/stream.py @@ -44,7 +44,7 @@ def get_stream_uptime(rounded=True): uptime = round(uptime, 2) if rounded else uptime return uptime -@with_timestamp +@with_timestamp() def get_raw_viewership(timestamp): users = get_watching_users(timestamp) return max( @@ -52,8 +52,8 @@ def get_raw_viewership(timestamp): default=0, ) -def get_stream_uptime_and_viewership(for_websocket=False): - uptime = get_stream_uptime() +def get_stream_uptime_and_viewership(rounded=True, for_websocket=False): + uptime = get_stream_uptime(rounded=rounded) if not for_websocket: viewership = None if uptime is None else get_raw_viewership() result = (uptime, viewership) diff --git a/anonstream/tasks.py b/anonstream/tasks.py index f25a2fc..73a714c 100644 --- a/anonstream/tasks.py +++ b/anonstream/tasks.py @@ -44,7 +44,7 @@ def with_period(period): return periodically @with_period(CONFIG['TASK_ROTATE_EYES']) -@with_timestamp +@with_timestamp() async def t_delete_eyes(timestamp, iteration): if iteration == 0: return @@ -59,7 +59,7 @@ async def t_delete_eyes(timestamp, iteration): user['eyes']['current'].pop(eyes_id) @with_period(CONFIG['TASK_ROTATE_USERS']) -@with_timestamp +@with_timestamp() async def t_sunset_users(timestamp, iteration): if iteration == 0: return @@ -102,7 +102,7 @@ async def t_expire_captchas(iteration): CAPTCHAS.pop(digest) @with_period(CONFIG['TASK_ROTATE_WEBSOCKETS']) -@with_timestamp +@with_timestamp() async def t_close_websockets(timestamp, iteration): THRESHOLD = CONFIG['TASK_BROADCAST_PING'] * 1.5 + 4.0 if iteration == 0: diff --git a/anonstream/user.py b/anonstream/user.py index 75c50eb..32ea0a7 100644 --- a/anonstream/user.py +++ b/anonstream/user.py @@ -127,16 +127,16 @@ def change_tripcode(user, password, dry_run=False): def delete_tripcode(user): user['tripcode'] = None -@with_timestamp +@with_timestamp() def see(timestamp, user): user['last']['seen'] = timestamp -@with_timestamp +@with_timestamp() def watched(timestamp, user): user['last']['seen'] = timestamp user['last']['watching'] = timestamp -@with_timestamp +@with_timestamp() def get_all_users_for_websocket(timestamp): return { user['token_hash']: get_user_for_websocket(user) @@ -160,7 +160,7 @@ def verify(user, digest, answer): return verification_happened -@with_timestamp +@with_timestamp() def deverify(timestamp, user): if not user['verified']: return @@ -182,7 +182,7 @@ def _update_presence(timestamp, user): USERS_UPDATE_BUFFER.add(user['token']) return user['presence'] -@with_timestamp +@with_timestamp() def update_presence(timestamp, user): return _update_presence(timestamp, user) @@ -224,7 +224,7 @@ def get_unsunsettable_users(timestamp): get_users_and_update_presence(timestamp), ) -@with_timestamp +@with_timestamp() def get_users_by_presence(timestamp): users_by_presence = { Presence.WATCHING: [], @@ -236,7 +236,7 @@ def get_users_by_presence(timestamp): users_by_presence[user['presence']].append(user) return users_by_presence -@with_timestamp +@with_timestamp(precise=True) def create_eyes(timestamp, user, headers): # Enforce cooldown last_created_ago = timestamp - user['last']['eyes'] @@ -271,7 +271,7 @@ def create_eyes(timestamp, user, headers): } return eyes_id -@with_timestamp +@with_timestamp(precise=True) def renew_eyes(timestamp, user, eyes_id, just_read_new_segment=False): try: eyes = user['eyes']['current'][eyes_id] diff --git a/anonstream/websocket.py b/anonstream/websocket.py index d9bb0cc..4878e1e 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -72,7 +72,7 @@ async def websocket_inbound(queue, user): if payload is not None: queue.put_nowait(payload) -@with_timestamp +@with_timestamp() def handle_inbound_pong(timestamp, queue, user): print(f'[pong] {user["token"]}') user['websockets'][queue] = timestamp diff --git a/anonstream/wrappers.py b/anonstream/wrappers.py index 6e1e857..3a8ead1 100644 --- a/anonstream/wrappers.py +++ b/anonstream/wrappers.py @@ -4,23 +4,25 @@ import time from functools import wraps -def with_timestamp(f): - @wraps(f) - def wrapper(*args, **kwargs): - timestamp = int(time.time()) - return f(timestamp, *args, **kwargs) - - return wrapper - -def with_first_argument(x): - def with_x(f): +def with_function_call(fn, *fn_args, **fn_kwargs): + def with_result(f): @wraps(f) def wrapper(*args, **kwargs): - return f(x, *args, **kwargs) - + result = fn(*fn_args, **fn_kwargs) + return f(result, *args, **kwargs) return wrapper + return with_result - return with_x +def with_constant(x): + return with_function_call(lambda: x) + +def with_timestamp(monotonic=False, precise=False): + n = 1_000_000_000 + if monotonic: + fn = precise and time.monotonic or (lambda: time.monotonic_ns() // n) + else: + fn = precise and time.time or (lambda: time.time_ns() // n) + return with_function_call(fn) def try_except_log(errors, exception_class): def try_except_log_specific(f): @@ -30,9 +32,7 @@ def try_except_log(errors, exception_class): return f(*args, **kwargs) except exception_class as e: errors.append(e) - return wrapper - return try_except_log_specific def ttl_cache(ttl): @@ -42,19 +42,14 @@ def ttl_cache(ttl): ''' def ttl_cache_specific(f): value, expires = None, None - @wraps(f) def wrapper(): nonlocal value, expires - if expires is None or time.monotonic() >= expires: value = f() expires = time.monotonic() + ttl - return value - return wrapper - return ttl_cache_specific def ttl_cache_async(ttl): @@ -63,17 +58,12 @@ def ttl_cache_async(ttl): ''' def ttl_cache_specific(f): value, expires = None, None - @wraps(f) async def wrapper(): nonlocal value, expires - if expires is None or time.monotonic() >= expires: value = await f() expires = time.monotonic() + ttl - return value - return wrapper - return ttl_cache_specific From f3e58fd3fa7b1d4ed1129b814cffb82600466eee Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 15 Jun 2022 20:50:43 +0000 Subject: [PATCH 6/7] Refactor info update background task We now time the interval between consecutive tasks. This is more precise than using the constant interval the task is supposed to run at since there is some drift on each run (~0.004s). --- anonstream/__init__.py | 1 + anonstream/tasks.py | 44 ++++++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/anonstream/__init__.py b/anonstream/__init__.py index d2c609e..b853268 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -47,6 +47,7 @@ def create_app(config_file): app.stream_title = None app.stream_uptime = None app.stream_viewership = None + app.last_info_task = None # Background tasks' asyncio.sleep tasks, cancelled on shutdown app.background_sleep = set() diff --git a/anonstream/tasks.py b/anonstream/tasks.py index 73a714c..802bf40 100644 --- a/anonstream/tasks.py +++ b/anonstream/tasks.py @@ -130,56 +130,58 @@ async def t_broadcast_users_update(iteration): broadcast_users_update() @with_period(CONFIG['TASK_BROADCAST_STREAM_INFO_UPDATE']) -async def t_broadcast_stream_info_update(iteration): +@with_timestamp(precise=True) +async def t_broadcast_stream_info_update(timestamp, iteration): if iteration == 0: title = await get_stream_title() - uptime, viewership = get_stream_uptime_and_viewership() + uptime, viewership = get_stream_uptime_and_viewership(rounded=False) current_app.stream_title = title current_app.stream_uptime = uptime current_app.stream_viewership = viewership else: - payload = {} - + info = {} title = await get_stream_title() - uptime, viewership = get_stream_uptime_and_viewership() + uptime, viewership = get_stream_uptime_and_viewership(rounded=False) # Check if the stream title has changed if current_app.stream_title != title: current_app.stream_title = title - payload['title'] = title + info['title'] = title # Check if the stream uptime has changed unexpectedly - if current_app.stream_uptime is None: - expected_uptime = None + last_uptime, current_app.stream_uptime = ( + current_app.stream_uptime, uptime + ) + if last_uptime is None: + projected_uptime = None else: - expected_uptime = ( - current_app.stream_uptime - + CONFIG['TASK_BROADCAST_STREAM_INFO_UPDATE'] - ) - current_app.stream_uptime = uptime - if uptime is None and expected_uptime is None: + last_info_task_ago = timestamp - current_app.last_info_task + projected_uptime = last_uptime + last_info_task_ago + if uptime is None and projected_uptime is None: stats_changed = False - elif uptime is None or expected_uptime is None: + elif uptime is None or projected_uptime is None: stats_changed = True else: - stats_changed = abs(uptime - expected_uptime) >= 0.5 + stats_changed = abs(uptime - projected_uptime) >= 0.5 # Check if viewership has changed if current_app.stream_viewership != viewership: current_app.stream_viewership = viewership stats_changed = True + # Broadcast iff anything has changed if stats_changed: if uptime is None: - payload['stats'] = None + info['stats'] = None else: - payload['stats'] = { - 'uptime': uptime, + info['stats'] = { + 'uptime': round(uptime, 2), 'viewership': viewership, } + if info: + broadcast(USERS, payload={'type': 'info', **info}) - if payload: - broadcast(USERS, payload={'type': 'info', **payload}) + current_app.last_info_task = timestamp current_app.add_background_task(t_delete_eyes) current_app.add_background_task(t_sunset_users) From 50d03ba8d590238dab63a1a02a5fbe1bb57cfa63 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 15 Jun 2022 21:17:09 +0000 Subject: [PATCH 7/7] Control socket: specify users by token hash --- anonstream/control/spec/common.py | 5 ++++ anonstream/control/spec/methods/user.py | 34 +++++++++++++++---------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/anonstream/control/spec/common.py b/anonstream/control/spec/common.py index 34ec014..0ecd92f 100644 --- a/anonstream/control/spec/common.py +++ b/anonstream/control/spec/common.py @@ -62,11 +62,16 @@ class ArgsInt(Args): return self.spec, 1, [n] class ArgsString(Args): + def transform_string(self, string): + return string + def consume(self, words, index): try: string = words[index] except IndexError: raise NoParse(f'incomplete: expected string') + else: + string = self.transform_string(string) return self.spec, 1, [string] class ArgsJson(Args): diff --git a/anonstream/control/spec/methods/user.py b/anonstream/control/spec/methods/user.py index edafb2c..147f066 100644 --- a/anonstream/control/spec/methods/user.py +++ b/anonstream/control/spec/methods/user.py @@ -20,10 +20,19 @@ class ArgsJsonTokenUser(ArgsJsonString): raise NoParse(f'no user with token {token!r}') return user +class ArgsJsonHashUser(ArgsString): + def transform_string(self, token_hash): + for user in USERS: + if user['token_hash'] == token_hash: + break + else: + raise NoParse(f'no user with token_hash {token_hash!r}') + return user + def ArgsUser(spec): return Str({ 'token': ArgsJsonTokenUser(spec), - #'hash': ArgsJsonHashUser(spec), + 'hash': ArgsJsonHashUser(spec), }) async def cmd_user_help(): @@ -31,19 +40,18 @@ async def cmd_user_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' - ' user eyes USER [show].............show a user\'s active video responses\n' - ' user eyes USER delete EYES_ID.....end a video response to a user\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' + ' user eyes USER [show].........show a user\'s active video responses\n' + ' user eyes USER delete EYES_ID.end a video response to a user\n' 'Definitions:\n' - #' USER..............................={token TOKEN | hash HASH}\n' - ' USER..............................=token TOKEN\n' - ' TOKEN..............................a token, json string\n' - #' HASH..............................a token hash\n' - ' ATTR...............................a user attribute, re:[a-z0-9_]+\n' - ' EYES_ID............................a user\'s eyes_id, base 10 integer\n' + ' USER..........................={token TOKEN | hash HASH}\n' + ' TOKEN.........................a token, json string\n' + ' HASH..........................a token hash\n' + ' ATTR..........................a user attribute, re:[a-z0-9_]+\n' + ' EYES_ID.......................a user\'s eyes_id, base 10 integer\n' ) return normal, response