コミットを比較

...

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
18個のファイルの変更520行の追加156行の削除

ファイルの表示

@ -1,3 +1,11 @@
# onion-livestreaming
# anonstream
Recipe for livestreaming over the Tor network
Recipe for livestreaming over Tor
## Repo
The canonical location of this repo is https://git.076.ne.jp/ninya9k/anonstream.
These mirrors also exist:
* https://gitlab.com/ninya9k/anonstream
* https://github.com/ninya9k/anonstream

ファイルの表示

@ -89,8 +89,9 @@ def create_app(config_file):
# State for tasks
app.users_update_buffer = set()
app.stream_uptime = None
app.stream_title = None
app.stream_uptime = None
app.stream_viewership = None
# Background tasks' asyncio.sleep tasks, cancelled on shutdown
app.background_sleep = set()

ファイルの表示

@ -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,17 +66,3 @@ def get_presence(timestamp, user):
return Presence.TENTATIVE
return Presence.ABSENT
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()

ファイルの表示

@ -2,8 +2,8 @@ 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
from anonstream.user import add_state, pop_state, try_change_appearance, verify, deverify, BadCaptcha
from anonstream.stream import get_stream_title, get_stream_uptime, get_stream_viewership
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
@ -19,8 +19,9 @@ async def nojs_info(user):
return await render_template(
'nojs_info.html',
user=user,
title=await get_stream_title(),
viewership=get_stream_viewership(),
uptime=get_stream_uptime(),
title=await get_stream_title(),
)
@current_app.route('/chat/messages.html')
@ -40,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):
@ -112,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

ファイルの表示

@ -6,7 +6,9 @@ const jsmarkup_style_color = '<style id="style-color"></style>'
const jsmarkup_style_tripcode_display = '<style id="style-tripcode-display"></style>'
const jsmarkup_style_tripcode_colors = '<style id="style-tripcode-colors"></style>'
const jsmarkup_info = '<div id="info_js" data-js="true"></div>';
const jsmarkup_info_uptime = '<aside id="info_js__uptime"></aside>';
const jsmarkup_info_float = '<aside id="info_js__float"></aside>';
const jsmarkup_info_float_viewership = '<div id="info_js__float__viewership"></div>';
const jsmarkup_info_float_uptime = '<div id="info_js__float__uptime"></div>';
const jsmarkup_info_title = '<header id="info_js__title"></header>';
const jsmarkup_chat_messages = '<ol id="chat-messages_js" data-js="true"></ol>';
const jsmarkup_chat_form = `\
@ -23,7 +25,7 @@ const jsmarkup_chat_form = `\
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
</form>`;
const insert_jsmarkup = () => {
const insert_jsmarkup = () => {jsmarkup_info_float_viewership
if (document.getElementById("style-color") === null) {
const parent = document.head;
parent.insertAdjacentHTML("beforeend", jsmarkup_style_color);
@ -40,9 +42,17 @@ const insert_jsmarkup = () => {
const parent = document.getElementById("info");
parent.insertAdjacentHTML("beforeend", jsmarkup_info);
}
if (document.getElementById("info_js__uptime") === null) {
if (document.getElementById("info_js__float") === null) {
const parent = document.getElementById("info_js");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_uptime);
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float);
}
if (document.getElementById("info_js__float__viewership") === null) {
const parent = document.getElementById("info_js__float");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float_viewership);
}
if (document.getElementById("info_js__float__uptime") === null) {
const parent = document.getElementById("info_js__float");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float_uptime);
}
if (document.getElementById("info_js__title") === null) {
const parent = document.getElementById("info_js");
@ -65,7 +75,8 @@ const stylesheet_tripcode_colors = document.styleSheets[3];
/* create websocket */
const info_title = document.getElementById("info_js__title");
const info_uptime = document.getElementById("info_js__uptime");
const info_viewership = document.getElementById("info_js__float__viewership");
const info_uptime = document.getElementById("info_js__float__uptime");
const chat_messages = document.getElementById("chat-messages_js");
const create_chat_message = (object) => {
@ -285,13 +296,31 @@ const chat_form_captcha_digest = document.getElementById("chat-form_js__captcha-
const chat_form_captcha_image = document.getElementById("chat-form_js__captcha-image");
const chat_form_captcha_answer = document.getElementById("chat-form_js__captcha-answer");
chat_form_captcha_image.addEventListener("loadstart", (event) => {
chat_form_captcha_image.removeAttribute("title");
chat_form_captcha_image.removeAttribute("data-reloadable");
chat_form_captcha_image.alt = "Loading...";
});
chat_form_captcha_image.addEventListener("load", (event) => {
chat_form_captcha_image.removeAttribute("alt");
chat_form_captcha_image.dataset.reloadable = "";
chat_form_captcha_image.title = "Click for a new captcha";
});
chat_form_captcha_image.addEventListener("error", (event) => {
chat_form_captcha_image.alt = "Captcha failed to load";
chat_form_captcha_image.dataset.reloadable = "";
chat_form_captcha_image.title = "Click for a new captcha";
});
chat_form_captcha_image.addEventListener("click", (event) => {
if (chat_form_captcha_image.dataset.reloadable === undefined) {
return;
}
chat_form_submit.disabled = true;
chat_form_captcha_image.alt = "Waiting...";
chat_form_captcha_image.removeAttribute("title");
chat_form_captcha_image.removeAttribute("data-reloadable");
chat_form_captcha_image.removeAttribute("src");
const payload = {type: "captcha"};
ws.send(JSON.stringify(payload));
});
const enable_captcha = (digest) => {
chat_form_captcha_digest.value = digest;
@ -302,6 +331,7 @@ const enable_captcha = (digest) => {
chat_form_comment.required = false;
chat_form_captcha_image.removeAttribute("src");
chat_form_captcha_image.src = `/captcha.jpg?token=${encodeURIComponent(token)}&digest=${encodeURIComponent(digest)}`;
chat_form_submit.disabled = false;
chat_form.dataset.captcha = "";
}
const disable_captcha = () => {
@ -312,6 +342,7 @@ const disable_captcha = () => {
chat_form_captcha_digest.value = "";
chat_form_captcha_answer.value = "";
chat_form_captcha_answer.required = false;
chat_form_submit.disabled = false;
chat_form_captcha_image.removeAttribute("alt");
chat_form_captcha_image.removeAttribute("src");
}
@ -352,24 +383,42 @@ const update_uptime = () => {
}
setInterval(update_uptime, 1000); // always update uptime
const set_viewership = (n) => {
info_viewership.innerText = n === null ? "" : `${n} viewers`;
}
const on_websocket_message = (event) => {
//console.log("websocket message", event);
const receipt = JSON.parse(event.data);
switch (receipt.type) {
case "error":
console.log("ws error", receipt);
chat_form_submit.disabled = false;
break;
case "init":
console.log("ws init", receipt);
// set title
set_title(receipt.title);
// set viewership
set_viewership(receipt.viewership);
// set uptime
set_frozen_uptime(receipt.uptime);
update_uptime();
// chat form nonce
chat_form_nonce.value = receipt.nonce;
// chat form captcha digest
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
// chat form submit button
chat_form_submit.disabled = false;
// remove messages the server isn't acknowledging the existance of
const seqs = new Set(receipt.messages.map((message) => {return message.seq;}));
const to_delete = [];
for (const chat_message of chat_messages.children) {
@ -382,13 +431,17 @@ const on_websocket_message = (event) => {
chat_message.remove();
}
// settings
default_name = receipt.default;
max_chat_scrollback = receipt.scrollback;
// appearances
users = receipt.users;
update_user_names();
update_user_colors();
update_user_tripcodes();
// insert new messages
const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1];
const last_seq = last === null ? null : parseInt(last.dataset.seq);
for (const message of receipt.messages) {
@ -408,6 +461,11 @@ const on_websocket_message = (event) => {
set_frozen_uptime(receipt.uptime);
update_uptime();
}
if (receipt.viewership === 0 && frozen_uptime === null) {
set_viewership(null);
} else if (receipt.viewership !== undefined) {
set_viewership(receipt.viewership);
}
break;
case "ack":
@ -452,6 +510,11 @@ const on_websocket_message = (event) => {
update_user_tripcodes();
break;
case "captcha":
console.log("ws captcha", receipt);
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
break;
default:
console.log("incomprehensible websocket message", receipt);
}
@ -513,7 +576,8 @@ const chat_form_comment = document.getElementById("chat-form_js__comment");
const chat_form_submit = document.getElementById("chat-form_js__submit");
chat_form.addEventListener("submit", (event) => {
event.preventDefault();
const payload = Object.fromEntries(new FormData(chat_form));
const form = Object.fromEntries(new FormData(chat_form));
const payload = {type: "message", form: form};
chat_form_submit.disabled = true;
ws.send(JSON.stringify(payload));
});

ファイルの表示

@ -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";
@ -61,12 +61,18 @@ noscript {
}
#info_js {
overflow-y: auto;
padding: 1ch 1.5ch;
padding: 0.75ch 1.25ch;
height: 100%;
}
#info_js__uptime {
#info_js__float {
float: right;
font-size: 11pt;
display: grid;
grid-auto-flow: column;
grid-gap: 2.5ch;
}
#info_js__float__uptime {
font-variant-numeric: tabular-nums;
}
#info_js__title > h1 {
margin: 0;
@ -87,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;
@ -147,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;
@ -181,6 +240,9 @@ noscript {
color: inherit;
font-size: 8pt;
}
#chat-form_js__captcha-image[data-reloadable] {
cursor: pointer;
}
#chat-form_js__captcha-answer {
width: 8ch;
}
@ -224,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;
@ -257,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;
}
@ -282,7 +344,7 @@ footer {
"info chat"
"footer chat";
}
#toggle {
#nav {
display: none;
}
#info {

ファイルの表示

@ -1,12 +1,16 @@
import itertools
import operator
import time
import aiofiles
from quart import current_app
from anonstream.segments import get_playlist, Offline
from anonstream.wrappers import ttl_cache_async
from anonstream.wrappers import ttl_cache_async, with_timestamp
from anonstream.user import get_watching_users
CONFIG = current_app.config
USERS = current_app.users
@ttl_cache_async(CONFIG['STREAM_TITLE_CACHE_LIFETIME'])
async def get_stream_title():
@ -32,6 +36,18 @@ def get_stream_uptime(rounded=True):
uptime = round(uptime, 2) if rounded else uptime
return uptime
@with_timestamp
def get_stream_viewership(timestamp):
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()
return uptime and viewership
def is_online():
try:
get_playlist()

ファイルの表示

@ -5,9 +5,9 @@ from functools import wraps
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
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(
@ -98,8 +93,12 @@ async def t_broadcast_users_update(iteration):
@with_period(CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE'])
async def t_broadcast_stream_info_update(iteration):
if iteration == 0:
current_app.stream_title = await get_stream_title()
current_app.stream_uptime = get_stream_uptime()
title = await get_stream_title()
uptime = get_stream_uptime()
viewership = get_stream_viewership_or_none(uptime)
current_app.stream_title = title
current_app.stream_uptime = uptime
current_app.stream_viewership = viewership
else:
payload = {}
@ -109,7 +108,7 @@ async def t_broadcast_stream_info_update(iteration):
current_app.stream_title = title
payload['title'] = title
# Check if the stream uptime has changed differently than expected
# Check if the stream uptime has changed more or less than expected
if current_app.stream_uptime is None:
expected_uptime = None
else:
@ -126,6 +125,12 @@ async def t_broadcast_stream_info_update(iteration):
elif abs(uptime - expected_uptime) >= 0.0625:
payload['uptime'] = uptime
# Check if viewership has changed
viewership = get_stream_viewership_or_none(uptime)
if current_app.stream_viewership != viewership:
current_app.stream_viewership = viewership
payload['viewership'] = viewership
if payload:
broadcast(USERS, payload={'type': 'info', **payload})

ファイルの表示

@ -10,20 +10,27 @@
<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>
</nav>
<footer>anonstream 1.0.0 &mdash; <a href="#" target="_blank">source</a></footer>
<footer>anonstream pre-1.0.0 &mdash; <a href="https://git.076.ne.jp/ninya9k/anonstream" target="_blank">source</a></footer>
<script src="{{ url_for('static', filename='anonstream.js') }}" type="text/javascript"></script>
</body>
</html>

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 %}

ファイルの表示

@ -2,16 +2,23 @@
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="12">
<style>
body {
overflow-y: auto;
margin: 1ch 1.5ch;
margin: 0.75ch 1.25ch;
font-family: sans-serif;
color: #ddd;
}
#uptime {
#float {
float: right;
font-size: 11pt;
display: grid;
grid-auto-flow: column;
grid-gap: 2.5ch;
}
#float__uptime {
font-variant-numeric: tabular-nums;
}
#title > h1 {
margin: 0;
@ -22,7 +29,12 @@
</style>
</head>
<body>
<aside id="uptime">{{ uptime }}</aside>
{% if uptime is not none %}
<aside id="float">
<div id="float__viewership">{{ viewership }} viewers</div>
<div id="float__uptime">{{ uptime }}</div>
</aside>
{% endif %}
<header id="title"><h1>{{ title }}</h1></header>
</body>
</html>

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']),
}

ファイルの表示

@ -1,19 +1,31 @@
class Malformed(Exception):
pass
def get(t, pairs, key, default=None):
value = pairs.get(key, default)
if isinstance(value, t):
return value
else:
raise Malformed(f'malformed {key}')
def parse_websocket_data(receipt):
if not isinstance(receipt, dict):
raise Malformed('not a json object')
comment = receipt.get('comment')
if not isinstance(comment, str):
raise Malformed('malformed comment')
match receipt.get('type'):
case 'message':
form = get(dict, receipt, 'form')
nonce = get(str, form, 'nonce')
comment = get(str, form, 'comment')
digest = get(str, form, 'captcha-digest', '')
answer = get(str, form, 'captcha-answer', '')
return nonce, comment, digest, answer
nonce = receipt.get('nonce')
if not isinstance(nonce, str):
raise Malformed('malformed nonce')
case 'appearance':
raise NotImplemented
digest = receipt.get('captcha-digest', '')
answer = receipt.get('captcha-answer', '')
case 'captcha':
return None
return nonce, comment, digest, answer
case _:
raise Malformed('malformed type')

ファイルの表示

@ -3,7 +3,7 @@ import json
from quart import current_app, websocket
from anonstream.stream import get_stream_title, get_stream_uptime
from anonstream.stream import get_stream_title, get_stream_uptime, get_stream_viewership_or_none
from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected
from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha
@ -13,11 +13,14 @@ from anonstream.utils.websocket import parse_websocket_data, Malformed
CONFIG = current_app.config
async def websocket_outbound(queue, user):
uptime = get_stream_uptime()
viewership = get_stream_viewership_or_none(uptime)
payload = {
'type': 'init',
'nonce': generate_nonce(),
'title': await get_stream_title(),
'uptime': get_stream_uptime(),
'uptime': uptime,
'viewership': viewership,
'messages': get_all_messages_for_websocket(),
'users': get_all_users_for_websocket(),
'default': {
@ -41,7 +44,7 @@ async def websocket_inbound(queue, user):
finally:
see(user)
try:
nonce, comment, digest, answer = parse_websocket_data(receipt)
parsed = parse_websocket_data(receipt)
except Malformed as e:
error , *_ = e.args
payload = {
@ -49,29 +52,47 @@ async def websocket_inbound(queue, user):
'because': error,
}
else:
try:
verification_happened = verify(user, digest, answer)
except BadCaptcha as e:
notice, *_ = e.args
else:
try:
message_was_added = add_chat_message(
user,
nonce,
comment,
ignore_empty=verification_happened,
)
except Rejected as e:
notice, *_ = e.args
else:
deverify(user)
notice = None
payload = {
'type': 'ack',
'nonce': nonce,
'next': generate_nonce(),
'notice': notice,
'clear': message_was_added,
'digest': get_random_captcha_digest_for(user),
}
match parsed:
case [nonce, comment, digest, answer]:
payload = handle_inbound_message(user, *parsed)
case None:
payload = handle_inbound_captcha(user)
queue.put_nowait(payload)
def handle_inbound_captcha(user):
return {
'type': 'captcha',
'digest': get_random_captcha_digest_for(user),
}
def handle_inbound_message(user, nonce, comment, digest, answer):
try:
verification_happened = verify(user, digest, answer)
except BadCaptcha as e:
notice, *_ = e.args
message_was_added = False
else:
try:
message_was_added = add_chat_message(
user,
nonce,
comment,
ignore_empty=verification_happened,
)
except Rejected as e:
notice, *_ = e.args
message_was_added = False
else:
notice = None
if message_was_added:
deverify(user)
return {
'type': 'ack',
'nonce': nonce,
'next': generate_nonce(),
'notice': notice,
'clear': message_was_added,
'digest': get_random_captcha_digest_for(user),
}