コミットを比較

..

6 コミット

作成者 SHA1 メッセージ 日付
n9k 886c360e26 Tell websockets which users are watching
This adds a field 'watching' in `user_for_websocket` that's True iff WATCHING,
False iff NOTWATCHING, and None otherwise (since clients don't need to know if
a user is tentative or absent). When the value of this field changes for any
user, they get added to the update buffer (like with any other change).

Removed race condition in `t_sunset_users`: `broadcast_users_update` was being
called *after* a user was removed from memory (and for each user being removed,
which was redundant). In that scenario if there's a user in the update buffer
and `t_sunset_users` wins the race between it and `t_broadcast_users_update`,
then when `t_sunset_users` calls `broadcast_users_update` a KeyError would be
raised since the user's already been removed.

Fixed unintended behaviour of `t_sunset_users`: it was removing users based on
the result of `is_visible`, so users who were actually tenative (as opposed to
absent) were being removed.
2022-02-27 14:35:26 +13:00
n9k 6b26e4fd02 CSS: make users button lighter 2022-02-27 14:35:26 +13:00
n9k 9e08a1c386 Nojs chat: add list of watching/non-watching users 2022-02-27 14:35:26 +13:00
n9k 384942ad15 Link to git repos 2022-02-27 14:35:25 +13:00
n9k fa7ab5d3ed Send new captcha over websocket with js 2022-02-27 01:10:19 +13:00
n9k 515f19f904 Keep track of stream viewership (number of viewers) 2022-02-27 01:10:17 +13:00
13個のファイルの変更317行の追加104行の削除

ファイルの表示

@ -6,6 +6,6 @@ Recipe for livestreaming over Tor
The canonical location of this repo is https://git.076.ne.jp/ninya9k/anonstream.
These mirrors exist:
These mirrors also exist:
* https://gitlab.com/ninya9k/anonstream
* https://github.com/ninya9k/anonstream

ファイルの表示

@ -1,25 +1,15 @@
import hashlib
import base64
from collections import OrderedDict
from enum import Enum
from math import inf
from quart import current_app
from anonstream.utils.colour import generate_colour, colour_to_color
from anonstream.utils.user import Presence
CONFIG = current_app.config
Presence = Enum(
'Presence',
names=(
'WATCHING',
'NOTWATCHING',
'TENTATIVE',
'ABSENT',
)
)
def generate_token_hash_and_tag(token):
parts = CONFIG['SECRET_KEY'] + b'token-hash\0' + token.encode()
digest = hashlib.sha256(parts).digest()
@ -29,7 +19,7 @@ def generate_token_hash_and_tag(token):
return token_hash, tag
def generate_user(timestamp, token, broadcaster):
def generate_user(timestamp, token, broadcaster, presence):
colour = generate_colour(
seed='name\0' + token,
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
@ -51,6 +41,7 @@ def generate_user(timestamp, token, broadcaster):
'seen': timestamp,
'watching': -inf,
},
'presence': presence,
}
def get_default_name(user):
@ -75,20 +66,3 @@ def get_presence(timestamp, user):
return Presence.TENTATIVE
return Presence.ABSENT
def is_watching(timestamp, user):
return get_presence(timestamp, user) == Presence.WATCHING
def is_listed(timestamp, user):
return (
get_presence(timestamp, user)
in {Presence.WATCHING, Presence.NOTWATCHING}
)
def is_visible(timestamp, messages, user):
def user_left_messages():
return any(
message['token'] == user['token']
for message in messages
)
return is_listed(timestamp, user) or user_left_messages()

ファイルの表示

@ -3,10 +3,10 @@ from quart import current_app, request, render_template, redirect, url_for, esca
from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import add_chat_message, Rejected
from anonstream.stream import get_stream_title, get_stream_uptime, get_stream_viewership
from anonstream.user import add_state, pop_state, try_change_appearance, verify, deverify, BadCaptcha
from anonstream.user import add_state, pop_state, try_change_appearance, get_users_by_presence, Presence, verify, deverify, BadCaptcha
from anonstream.routes.wrappers import with_user_from, render_template_with_etag
from anonstream.helpers.chat import get_scrollback
from anonstream.helpers.user import get_default_name, Presence
from anonstream.helpers.user import get_default_name
from anonstream.utils.chat import generate_nonce
from anonstream.utils.user import concatenate_for_notice
@ -41,6 +41,18 @@ async def nojs_chat(user):
async def nojs_chat_redirect(user):
return redirect(url_for('nojs_chat', _anchor='end'))
@current_app.route('/chat/users.html')
@with_user_from(request)
async def nojs_users(user):
users_by_presence = get_users_by_presence()
return await render_template(
'nojs_users.html',
user=user,
get_default_name=get_default_name,
users_watching=users_by_presence[Presence.WATCHING],
users_notwatching=users_by_presence[Presence.NOTWATCHING],
)
@current_app.route('/chat/form.html')
@with_user_from(request)
async def nojs_form(user):
@ -113,23 +125,24 @@ async def nojs_submit_message(user):
@with_user_from(request)
async def nojs_submit_appearance(user):
form = await request.form
name = form.get('name', '').strip()
color = form.get('color', '')
password = form.get('password', '')
want_delete_tripcode = form.get('clear-tripcode', type=bool)
want_change_tripcode = form.get('set-tripcode', type=bool)
# Collect form data
name = form.get('name', '').strip()
if len(name) == 0 or name == get_default_name(user):
name = None
errors = try_change_appearance(
user,
name,
color,
password,
want_delete_tripcode,
want_change_tripcode,
)
color = form.get('color', '')
password = form.get('password', '')
if form.get('clear-tripcode', type=bool):
want_tripcode = False
elif form.get('set-tripcode', type=bool):
want_tripcode = True
else:
want_tripcode = None
# Change appearance (iff form data was good)
errors = try_change_appearance(user, name, color, password, want_tripcode)
if errors:
notice = Markup('<br>').join(
concatenate_for_notice(*error.args) for error in errors

ファイルの表示

@ -8,7 +8,7 @@ from werkzeug.security import check_password_hash
from anonstream.broadcast import broadcast
from anonstream.user import see
from anonstream.helpers.user import generate_user
from anonstream.utils.user import generate_token
from anonstream.utils.user import generate_token, Presence
CONFIG = current_app.config
MESSAGES = current_app.messages
@ -68,6 +68,7 @@ def with_user_from(context):
timestamp=timestamp,
token=token,
broadcaster=broadcaster,
presence=Presence.NOTWATCHING,
)
USERS_BY_TOKEN[token] = user

ファイルの表示

@ -30,7 +30,7 @@ body {
grid-auto-rows: var(--video-height) auto min-content 1fr auto;
grid-template-areas:
"stream"
"toggle"
"nav"
"info"
"chat"
"footer";
@ -93,11 +93,61 @@ noscript {
grid-area: chat;
height: 50vh;
min-height: 24ch;
position: relative;
}
#chat__toggle {
opacity: 0;
position: absolute;
top: calc(0.5rem + 1px);
left: calc(0.5rem + 4px);
margin: 0;
}
#chat__toggle:checked ~ #chat__messages,
#chat__toggle:not(:checked) ~ #chat__users {
display: none;
}
#chat__toggle:checked + #chat__header > #chat__header__button {
border-style: inset;
background-color: #b3b3bf;
}
#chat__toggle:checked + #chat__header > #chat__header__button:hover {
background-color: #a6a6b1;
}
#chat__toggle:focus-visible + #chat__header > #chat__header__button {
border-color: #3584e4;
box-shadow: 0 0 6px #3584e4;
}
#chat__header {
text-align: center;
padding: 0.5rem 0;
padding: 0.5rem;
border-bottom: var(--chat-border);
display: grid;
align-items: center;
}
#chat__header__button {
grid-column: 1;
grid-row: 1;
width: min-content;
z-index: 1;
padding: 1px 5px;
background-color: #c9c9d3;
border: 3px outset #8f8f9d;
border-radius: 4px;
color: black;
user-select: none;
}
#chat__header__button:hover {
background-color: #b3b3bf;
}
#chat__header__button:active {
border-style: inset;
background-color: #9999a4 !important;
}
#chat__header__text {
margin: 0;
font-weight: normal;
text-align: center;
grid-column: 1;
grid-row: 1;
}
#chat__messages {
position: relative;
@ -153,6 +203,9 @@ noscript {
font-size: 9pt;
cursor: default;
}
#chat-users_nojs {
height: 100%;
}
#chat-form_js {
display: grid;
grid-template-columns: 1fr min-content min-content 5rem;
@ -233,13 +286,13 @@ noscript {
100% {filter: brightness(100%)}
}
#toggle {
grid-area: toggle;
#nav {
grid-area: nav;
border-top: var(--main-border);
display: grid;
grid-template-columns: repeat(3, 1fr);
}
#toggle > a {
#nav > a {
text-align: center;
padding: 1ch;
font-variant: all-small-caps;
@ -266,9 +319,9 @@ footer {
#chat:target, #both:target > #chat {
display: grid;
}
#info:target ~ #toggle > [href="#info"],
#chat:target ~ #toggle > [href="#chat"],
#both:target > #toggle > [href="#both"] {
#info:target ~ #nav > [href="#info"],
#chat:target ~ #nav > [href="#chat"],
#both:target > #nav > [href="#both"] {
background-color: #3065a6;
border-style: inset;
}
@ -291,7 +344,7 @@ footer {
"info chat"
"footer chat";
}
#toggle {
#nav {
display: none;
}
#info {

ファイルの表示

@ -1,3 +1,5 @@
import itertools
import operator
import time
import aiofiles
@ -5,7 +7,7 @@ from quart import current_app
from anonstream.segments import get_playlist, Offline
from anonstream.wrappers import ttl_cache_async, with_timestamp
from anonstream.helpers.user import is_watching
from anonstream.user import get_watching_users
CONFIG = current_app.config
USERS = current_app.users
@ -36,7 +38,11 @@ def get_stream_uptime(rounded=True):
@with_timestamp
def get_stream_viewership(timestamp):
return sum(map(lambda user: is_watching(timestamp, user), USERS))
users = get_watching_users(timestamp)
return max(
map(operator.itemgetter(0), zip(itertools.count(1), users)),
default=0,
)
def get_stream_viewership_or_none(uptime):
viewership = get_stream_viewership()

ファイルの表示

@ -6,8 +6,8 @@ from quart import current_app
from anonstream.broadcast import broadcast, broadcast_users_update
from anonstream.stream import is_online, get_stream_title, get_stream_uptime, get_stream_viewership_or_none
from anonstream.user import get_sunsettable_users
from anonstream.wrappers import with_timestamp
from anonstream.helpers.user import is_visible
CONFIG = current_app.config
MESSAGES = current_app.messages
@ -42,25 +42,20 @@ def with_period(period):
@with_period(CONFIG['TASK_PERIOD_ROTATE_USERS'])
@with_timestamp
async def t_sunset_users(iteration, timestamp):
async def t_sunset_users(timestamp, iteration):
if iteration == 0:
return
tokens = []
for token in USERS_BY_TOKEN:
user = USERS_BY_TOKEN[token]
if not is_visible(timestamp, MESSAGES, user):
tokens.append(token)
# Broadcast a users update, in case any users being
# removed have been mutated or are new.
broadcast_users_update()
token_hashes = []
while tokens:
token = tokens.pop()
token_hash = USERS_BY_TOKEN.pop(token)['token_hash']
token_hashes.append(token_hash)
# Broadcast a users update, in case any users being
# removed have been mutated or are new.
broadcast_users_update()
users = list(get_sunsettable_users(timestamp))
while users:
user = users.pop()
USERS_BY_TOKEN.pop(user['token'])
token_hashes.append(user['token_hash'])
if token_hashes:
broadcast(

ファイルの表示

@ -10,15 +10,22 @@
<noscript><iframe id="info_nojs" src="{{ url_for('nojs_info', token=user.token) }}" data-js="false"></iframe></noscript>
</article>
<aside id="chat">
<header id="chat__header">Stream chat</header>
<input id="chat__toggle" type="checkbox">
<header id="chat__header">
<label id="chat__header__button" for="chat__toggle">Users</label>
<h3 id="chat__header__text">Stream chat</h3>
</header>
<article id="chat__messages">
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token, _anchor='end') }}" data-js="false"></iframe></noscript>
</article>
<article id="chat__users">
<noscript><iframe id="chat-users_nojs" src="{{ url_for('nojs_users', token=user.token) }}" data-js="false"></iframe></noscript>
</article>
<section id="chat__form">
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_form', token=user.token) }}" data-js="false"></iframe></noscript>
</section>
</aside>
<nav id="toggle">
<nav id="nav">
<a href="#info">info</a>
<a href="#chat">chat</a>
<a href="#both">both</a>

22
anonstream/templates/macros/user.html ノーマルファイル
ファイルの表示

@ -0,0 +1,22 @@
{%
macro appearance(
user,
name_class,
tag_class,
tripcode_nbsp_class='for-tripcode',
tripcode_class='tripcode for-tripcode'
)
%}
{{- '' -}}
<span class="{{ name_class }}" style="color:{{ user.color }};">
{{- user.name or get_default_name(user) -}}
{%- if not user.broadcaster and user.name is none -%}
<sup class="{{ tag_class }}">{{ user.tag }}</sup>
{%- endif -%}
</span>
{%- if user.tripcode -%}
<span class="{{ tripcode_nbsp_class }}">&nbsp;</span>
{{- '' -}}
<span class="{{ tripcode_class }}" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>
{%- endif -%}
{% endmacro %}

ファイルの表示

@ -1,3 +1,4 @@
{% from 'macros/user.html' import appearance with context %}
<!doctype html>
<html>
<head>
@ -138,17 +139,7 @@
{% with user = users_by_token[message.token] %}
<time class="chat-message__time" datetime="{{ message.date }}T{{ message.time_seconds }}Z" title="{{ message.date }} {{ message.time_seconds }}">{{ message.time_minutes }}</time>
{{- '&nbsp;' | safe -}}
<span class="chat-message__name" style="color:{{ user.color }};">
{{- user.name or get_default_name(user) -}}
{%- if not user.broadcaster and user.name is none -%}
<sup class="chat-message__name__tag">{{ user.tag }}</sup>
{%- endif -%}
</span>
{%- if user.tripcode -%}
<span class="for-tripcode">&nbsp;</span>
{{- '' -}}
<span class="tripcode for-tripcode" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>
{%- endif -%}
{{ appearance(user, name_class='chat-message__name', tag_class='chat-message__name__tag') }}
{{- ':&nbsp;' | safe -}}
<span class="chat-message__markup">{{ message.markup }}</span>
{% endwith %}

75
anonstream/templates/nojs_users.html ノーマルファイル
ファイルの表示

@ -0,0 +1,75 @@
{% from 'macros/user.html' import appearance with context %}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html {
min-height: 100%;
}
body {
margin: 0;
background: linear-gradient(#121214 calc(100% - 0.625rem), #232327 calc(100% - 0.125rem));
color: #ddd;
font-family: sans-serif;
}
#header {
padding: 0.5rem;
background-color: #27272a;
border-bottom: 1px solid #4a4a4f;
}
#header > h4 {
margin: 0;
font-weight: normal;
text-align: center;
}
#main {
margin: 0.5rem 0.75rem;
}
#main > h5 {
margin: 0;
}
#main > ul {
margin: 0;
padding-left: 0.75rem;
list-style: none;
}
.user {
line-height: 1.5;
}
.user__name {
font-weight: bold;
cursor: default;
}
.user__name__tag {
font-family: monospace;
font-size: 9pt;
vertical-align: top;
}
</style>
</head>
<body>
<header id="header"><h4>Users in chat</h4></header>
<main id="main">
<h5>Watching ({{ users_watching | length }})</h5>
<ul>
{% for user_listed in users_watching %}
<li class="user">
{{- appearance(user_listed, name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
</li>
{% endfor %}
</ul>
<br>
<h5>Not watching ({{ users_notwatching | length }})</h5>
<ul>
{% for user_listed in users_notwatching %}
<li class="user">
{{- appearance(user_listed, name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
</li>
{% endfor %}
</ul>
</article>
</body>
</html>

ファイルの表示

@ -4,11 +4,11 @@ from math import inf
from quart import current_app
from anonstream.wrappers import try_except_log, with_timestamp
from anonstream.helpers.user import is_visible
from anonstream.helpers.user import get_presence, Presence
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 get_user_for_websocket
from anonstream.utils.user import get_user_for_websocket, trilean
CONFIG = current_app.config
MESSAGES = current_app.messages
@ -36,25 +36,30 @@ def pop_state(user, state_id):
state = None
return state
def try_change_appearance(user, name, color, password,
want_delete_tripcode, want_change_tripcode):
def try_change_appearance(user, name, color, password, want_tripcode):
errors = []
def try_(f, *args, **kwargs):
return try_except_log(errors, BadAppearance)(f)(*args, **kwargs)
try_(change_name, user, name, dry_run=True)
try_(change_color, user, color, dry_run=True)
if want_delete_tripcode:
pass
elif want_change_tripcode:
if want_tripcode:
try_(change_tripcode, user, password, dry_run=True)
if not errors:
change_name(user, name)
change_color(user, color)
if want_delete_tripcode:
# Leave tripcode
if want_tripcode is None:
pass
# Delete tripcode
elif not want_tripcode:
delete_tripcode(user)
elif want_change_tripcode:
# Change tripcode
elif want_tripcode:
change_tripcode(user, password)
# Add to the users update buffer
@ -112,13 +117,9 @@ def watched(timestamp, user):
@with_timestamp
def get_all_users_for_websocket(timestamp):
visible_users = filter(
lambda user: is_visible(timestamp, MESSAGES, user),
USERS,
)
return {
user['token_hash']: get_user_for_websocket(user)
for user in visible_users
for user in get_unsunsettable_users(timestamp)
}
def verify(user, digest, answer):
@ -153,3 +154,55 @@ def deverify(timestamp, user):
if n_user_messages >= CONFIG['FLOOD_THRESHOLD']:
user['verified'] = False
def get_users_and_update_presence(timestamp):
for user in USERS:
old, user['presence'] = user['presence'], get_presence(timestamp, user)
if trilean(user['presence']) != trilean(old):
USERS_UPDATE_BUFFER.add(user['token'])
yield user
def get_watching_users(timestamp):
return filter(
lambda user: user['presence'] == Presence.WATCHING,
get_users_and_update_presence(timestamp),
)
def get_absent_users(timestamp):
return filter(
lambda user: user['presence'] == Presence.ABSENT,
get_users_and_update_presence(timestamp),
)
def is_sunsettable(user):
return user['presence'] == Presence.ABSENT and not has_left_messages(user)
def has_left_messages(user):
return any(
message['token'] == user['token']
for message in MESSAGES
)
def get_sunsettable_users(timestamp):
return filter(
is_sunsettable,
get_users_and_update_presence(timestamp),
)
def get_unsunsettable_users(timestamp):
return filter(
lambda user: not is_sunsettable(user),
get_users_and_update_presence(timestamp),
)
@with_timestamp
def get_users_by_presence(timestamp):
users_by_presence = {
Presence.WATCHING: [],
Presence.NOTWATCHING: [],
Presence.TENTATIVE: [],
Presence.ABSENT: [],
}
for user in get_users_and_update_presence(timestamp):
users_by_presence[user['presence']].append(user)
return users_by_presence

ファイルの表示

@ -2,17 +2,24 @@ import base64
import hashlib
import secrets
from collections import OrderedDict
from enum import Enum
from math import inf
from quart import escape, Markup
Presence = Enum(
'Presence',
names=(
'WATCHING',
'NOTWATCHING',
'TENTATIVE',
'ABSENT',
)
)
def generate_token():
return secrets.token_hex(16)
def get_user_for_websocket(user):
keys = ['broadcaster', 'name', 'color', 'tripcode', 'tag']
return {key: user[key] for key in keys}
def concatenate_for_notice(string, *tuples):
if not tuples:
return string
@ -23,3 +30,19 @@ def concatenate_for_notice(string, *tuples):
)
)
return string + markup
def trilean(presence):
match presence:
case Presence.WATCHING:
return True
case Presence.NOTWATCHING:
return False
case _:
return None
def get_user_for_websocket(user):
keys = ('broadcaster', 'name', 'color', 'tripcode', 'tag')
return {
**{key: user[key] for key in keys},
'watching': trilean(user['presence']),
}