コミットを比較

...

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 # State for tasks
app.users_update_buffer = set() app.users_update_buffer = set()
app.stream_uptime = None
app.stream_title = None app.stream_title = None
app.stream_uptime = None
app.stream_viewership = None
# Background tasks' asyncio.sleep tasks, cancelled on shutdown # Background tasks' asyncio.sleep tasks, cancelled on shutdown
app.background_sleep = set() app.background_sleep = set()

ファイルの表示

@ -1,25 +1,15 @@
import hashlib import hashlib
import base64 import base64
from collections import OrderedDict from collections import OrderedDict
from enum import Enum
from math import inf from math import inf
from quart import current_app from quart import current_app
from anonstream.utils.colour import generate_colour, colour_to_color from anonstream.utils.colour import generate_colour, colour_to_color
from anonstream.utils.user import Presence
CONFIG = current_app.config CONFIG = current_app.config
Presence = Enum(
'Presence',
names=(
'WATCHING',
'NOTWATCHING',
'TENTATIVE',
'ABSENT',
)
)
def generate_token_hash_and_tag(token): def generate_token_hash_and_tag(token):
parts = CONFIG['SECRET_KEY'] + b'token-hash\0' + token.encode() parts = CONFIG['SECRET_KEY'] + b'token-hash\0' + token.encode()
digest = hashlib.sha256(parts).digest() digest = hashlib.sha256(parts).digest()
@ -29,7 +19,7 @@ def generate_token_hash_and_tag(token):
return token_hash, tag return token_hash, tag
def generate_user(timestamp, token, broadcaster): def generate_user(timestamp, token, broadcaster, presence):
colour = generate_colour( colour = generate_colour(
seed='name\0' + token, seed='name\0' + token,
bg=CONFIG['CHAT_BACKGROUND_COLOUR'], bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
@ -51,6 +41,7 @@ def generate_user(timestamp, token, broadcaster):
'seen': timestamp, 'seen': timestamp,
'watching': -inf, 'watching': -inf,
}, },
'presence': presence,
} }
def get_default_name(user): def get_default_name(user):
@ -75,17 +66,3 @@ def get_presence(timestamp, user):
return Presence.TENTATIVE return Presence.TENTATIVE
return Presence.ABSENT 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.captcha import get_random_captcha_digest_for
from anonstream.chat import add_chat_message, Rejected from anonstream.chat import add_chat_message, Rejected
from anonstream.stream import get_stream_title, get_stream_uptime 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.routes.wrappers import with_user_from, render_template_with_etag
from anonstream.helpers.chat import get_scrollback from anonstream.helpers.chat import get_scrollback
from anonstream.helpers.user import get_default_name from anonstream.helpers.user import get_default_name
@ -19,8 +19,9 @@ async def nojs_info(user):
return await render_template( return await render_template(
'nojs_info.html', 'nojs_info.html',
user=user, user=user,
title=await get_stream_title(), viewership=get_stream_viewership(),
uptime=get_stream_uptime(), uptime=get_stream_uptime(),
title=await get_stream_title(),
) )
@current_app.route('/chat/messages.html') @current_app.route('/chat/messages.html')
@ -40,6 +41,18 @@ async def nojs_chat(user):
async def nojs_chat_redirect(user): async def nojs_chat_redirect(user):
return redirect(url_for('nojs_chat', _anchor='end')) 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') @current_app.route('/chat/form.html')
@with_user_from(request) @with_user_from(request)
async def nojs_form(user): async def nojs_form(user):
@ -112,23 +125,24 @@ async def nojs_submit_message(user):
@with_user_from(request) @with_user_from(request)
async def nojs_submit_appearance(user): async def nojs_submit_appearance(user):
form = await request.form 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): if len(name) == 0 or name == get_default_name(user):
name = None name = None
errors = try_change_appearance( color = form.get('color', '')
user, password = form.get('password', '')
name,
color, if form.get('clear-tripcode', type=bool):
password, want_tripcode = False
want_delete_tripcode, elif form.get('set-tripcode', type=bool):
want_change_tripcode, 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: if errors:
notice = Markup('<br>').join( notice = Markup('<br>').join(
concatenate_for_notice(*error.args) for error in errors 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.broadcast import broadcast
from anonstream.user import see from anonstream.user import see
from anonstream.helpers.user import generate_user 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 CONFIG = current_app.config
MESSAGES = current_app.messages MESSAGES = current_app.messages
@ -68,6 +68,7 @@ def with_user_from(context):
timestamp=timestamp, timestamp=timestamp,
token=token, token=token,
broadcaster=broadcaster, broadcaster=broadcaster,
presence=Presence.NOTWATCHING,
) )
USERS_BY_TOKEN[token] = user 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_display = '<style id="style-tripcode-display"></style>'
const jsmarkup_style_tripcode_colors = '<style id="style-tripcode-colors"></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 = '<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_info_title = '<header id="info_js__title"></header>';
const jsmarkup_chat_messages = '<ol id="chat-messages_js" data-js="true"></ol>'; const jsmarkup_chat_messages = '<ol id="chat-messages_js" data-js="true"></ol>';
const jsmarkup_chat_form = `\ 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> <input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
</form>`; </form>`;
const insert_jsmarkup = () => { const insert_jsmarkup = () => {jsmarkup_info_float_viewership
if (document.getElementById("style-color") === null) { if (document.getElementById("style-color") === null) {
const parent = document.head; const parent = document.head;
parent.insertAdjacentHTML("beforeend", jsmarkup_style_color); parent.insertAdjacentHTML("beforeend", jsmarkup_style_color);
@ -40,9 +42,17 @@ const insert_jsmarkup = () => {
const parent = document.getElementById("info"); const parent = document.getElementById("info");
parent.insertAdjacentHTML("beforeend", jsmarkup_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"); 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) { if (document.getElementById("info_js__title") === null) {
const parent = document.getElementById("info_js"); const parent = document.getElementById("info_js");
@ -65,7 +75,8 @@ const stylesheet_tripcode_colors = document.styleSheets[3];
/* create websocket */ /* create websocket */
const info_title = document.getElementById("info_js__title"); 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 chat_messages = document.getElementById("chat-messages_js");
const create_chat_message = (object) => { 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_image = document.getElementById("chat-form_js__captcha-image");
const chat_form_captcha_answer = document.getElementById("chat-form_js__captcha-answer"); const chat_form_captcha_answer = document.getElementById("chat-form_js__captcha-answer");
chat_form_captcha_image.addEventListener("loadstart", (event) => { 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.alt = "Loading...";
}); });
chat_form_captcha_image.addEventListener("load", (event) => { chat_form_captcha_image.addEventListener("load", (event) => {
chat_form_captcha_image.removeAttribute("alt"); 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.addEventListener("error", (event) => {
chat_form_captcha_image.alt = "Captcha failed to load"; 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) => { const enable_captcha = (digest) => {
chat_form_captcha_digest.value = digest; chat_form_captcha_digest.value = digest;
@ -302,6 +331,7 @@ const enable_captcha = (digest) => {
chat_form_comment.required = false; chat_form_comment.required = false;
chat_form_captcha_image.removeAttribute("src"); chat_form_captcha_image.removeAttribute("src");
chat_form_captcha_image.src = `/captcha.jpg?token=${encodeURIComponent(token)}&digest=${encodeURIComponent(digest)}`; chat_form_captcha_image.src = `/captcha.jpg?token=${encodeURIComponent(token)}&digest=${encodeURIComponent(digest)}`;
chat_form_submit.disabled = false;
chat_form.dataset.captcha = ""; chat_form.dataset.captcha = "";
} }
const disable_captcha = () => { const disable_captcha = () => {
@ -312,6 +342,7 @@ const disable_captcha = () => {
chat_form_captcha_digest.value = ""; chat_form_captcha_digest.value = "";
chat_form_captcha_answer.value = ""; chat_form_captcha_answer.value = "";
chat_form_captcha_answer.required = false; chat_form_captcha_answer.required = false;
chat_form_submit.disabled = false;
chat_form_captcha_image.removeAttribute("alt"); chat_form_captcha_image.removeAttribute("alt");
chat_form_captcha_image.removeAttribute("src"); chat_form_captcha_image.removeAttribute("src");
} }
@ -352,24 +383,42 @@ const update_uptime = () => {
} }
setInterval(update_uptime, 1000); // always 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) => { const on_websocket_message = (event) => {
//console.log("websocket message", event); //console.log("websocket message", event);
const receipt = JSON.parse(event.data); const receipt = JSON.parse(event.data);
switch (receipt.type) { switch (receipt.type) {
case "error": case "error":
console.log("ws error", receipt); console.log("ws error", receipt);
chat_form_submit.disabled = false;
break; break;
case "init": case "init":
console.log("ws init", receipt); console.log("ws init", receipt);
// set title
set_title(receipt.title); set_title(receipt.title);
// set viewership
set_viewership(receipt.viewership);
// set uptime
set_frozen_uptime(receipt.uptime); set_frozen_uptime(receipt.uptime);
update_uptime(); update_uptime();
// chat form nonce
chat_form_nonce.value = receipt.nonce; chat_form_nonce.value = receipt.nonce;
// chat form captcha digest
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.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 seqs = new Set(receipt.messages.map((message) => {return message.seq;}));
const to_delete = []; const to_delete = [];
for (const chat_message of chat_messages.children) { for (const chat_message of chat_messages.children) {
@ -382,13 +431,17 @@ const on_websocket_message = (event) => {
chat_message.remove(); chat_message.remove();
} }
// settings
default_name = receipt.default; default_name = receipt.default;
max_chat_scrollback = receipt.scrollback; max_chat_scrollback = receipt.scrollback;
// appearances
users = receipt.users; users = receipt.users;
update_user_names(); update_user_names();
update_user_colors(); update_user_colors();
update_user_tripcodes(); update_user_tripcodes();
// insert new messages
const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1]; 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); const last_seq = last === null ? null : parseInt(last.dataset.seq);
for (const message of receipt.messages) { for (const message of receipt.messages) {
@ -408,6 +461,11 @@ const on_websocket_message = (event) => {
set_frozen_uptime(receipt.uptime); set_frozen_uptime(receipt.uptime);
update_uptime(); update_uptime();
} }
if (receipt.viewership === 0 && frozen_uptime === null) {
set_viewership(null);
} else if (receipt.viewership !== undefined) {
set_viewership(receipt.viewership);
}
break; break;
case "ack": case "ack":
@ -452,6 +510,11 @@ const on_websocket_message = (event) => {
update_user_tripcodes(); update_user_tripcodes();
break; break;
case "captcha":
console.log("ws captcha", receipt);
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
break;
default: default:
console.log("incomprehensible websocket message", receipt); 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"); const chat_form_submit = document.getElementById("chat-form_js__submit");
chat_form.addEventListener("submit", (event) => { chat_form.addEventListener("submit", (event) => {
event.preventDefault(); 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; chat_form_submit.disabled = true;
ws.send(JSON.stringify(payload)); ws.send(JSON.stringify(payload));
}); });

ファイルの表示

@ -30,7 +30,7 @@ body {
grid-auto-rows: var(--video-height) auto min-content 1fr auto; grid-auto-rows: var(--video-height) auto min-content 1fr auto;
grid-template-areas: grid-template-areas:
"stream" "stream"
"toggle" "nav"
"info" "info"
"chat" "chat"
"footer"; "footer";
@ -61,12 +61,18 @@ noscript {
} }
#info_js { #info_js {
overflow-y: auto; overflow-y: auto;
padding: 1ch 1.5ch; padding: 0.75ch 1.25ch;
height: 100%; height: 100%;
} }
#info_js__uptime { #info_js__float {
float: right; float: right;
font-size: 11pt; 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 { #info_js__title > h1 {
margin: 0; margin: 0;
@ -87,11 +93,61 @@ noscript {
grid-area: chat; grid-area: chat;
height: 50vh; height: 50vh;
min-height: 24ch; 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 { #chat__header {
text-align: center; padding: 0.5rem;
padding: 0.5rem 0;
border-bottom: var(--chat-border); 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 { #chat__messages {
position: relative; position: relative;
@ -147,6 +203,9 @@ noscript {
font-size: 9pt; font-size: 9pt;
cursor: default; cursor: default;
} }
#chat-users_nojs {
height: 100%;
}
#chat-form_js { #chat-form_js {
display: grid; display: grid;
grid-template-columns: 1fr min-content min-content 5rem; grid-template-columns: 1fr min-content min-content 5rem;
@ -181,6 +240,9 @@ noscript {
color: inherit; color: inherit;
font-size: 8pt; font-size: 8pt;
} }
#chat-form_js__captcha-image[data-reloadable] {
cursor: pointer;
}
#chat-form_js__captcha-answer { #chat-form_js__captcha-answer {
width: 8ch; width: 8ch;
} }
@ -224,13 +286,13 @@ noscript {
100% {filter: brightness(100%)} 100% {filter: brightness(100%)}
} }
#toggle { #nav {
grid-area: toggle; grid-area: nav;
border-top: var(--main-border); border-top: var(--main-border);
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
#toggle > a { #nav > a {
text-align: center; text-align: center;
padding: 1ch; padding: 1ch;
font-variant: all-small-caps; font-variant: all-small-caps;
@ -257,9 +319,9 @@ footer {
#chat:target, #both:target > #chat { #chat:target, #both:target > #chat {
display: grid; display: grid;
} }
#info:target ~ #toggle > [href="#info"], #info:target ~ #nav > [href="#info"],
#chat:target ~ #toggle > [href="#chat"], #chat:target ~ #nav > [href="#chat"],
#both:target > #toggle > [href="#both"] { #both:target > #nav > [href="#both"] {
background-color: #3065a6; background-color: #3065a6;
border-style: inset; border-style: inset;
} }
@ -282,7 +344,7 @@ footer {
"info chat" "info chat"
"footer chat"; "footer chat";
} }
#toggle { #nav {
display: none; display: none;
} }
#info { #info {

ファイルの表示

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

ファイルの表示

@ -5,9 +5,9 @@ from functools import wraps
from quart import current_app from quart import current_app
from anonstream.broadcast import broadcast, broadcast_users_update 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.wrappers import with_timestamp
from anonstream.helpers.user import is_visible
CONFIG = current_app.config CONFIG = current_app.config
MESSAGES = current_app.messages MESSAGES = current_app.messages
@ -42,25 +42,20 @@ def with_period(period):
@with_period(CONFIG['TASK_PERIOD_ROTATE_USERS']) @with_period(CONFIG['TASK_PERIOD_ROTATE_USERS'])
@with_timestamp @with_timestamp
async def t_sunset_users(iteration, timestamp): async def t_sunset_users(timestamp, iteration):
if iteration == 0: if iteration == 0:
return return
tokens = [] # Broadcast a users update, in case any users being
for token in USERS_BY_TOKEN: # removed have been mutated or are new.
user = USERS_BY_TOKEN[token] broadcast_users_update()
if not is_visible(timestamp, MESSAGES, user):
tokens.append(token)
token_hashes = [] token_hashes = []
while tokens: users = list(get_sunsettable_users(timestamp))
token = tokens.pop() while users:
token_hash = USERS_BY_TOKEN.pop(token)['token_hash'] user = users.pop()
token_hashes.append(token_hash) USERS_BY_TOKEN.pop(user['token'])
token_hashes.append(user['token_hash'])
# Broadcast a users update, in case any users being
# removed have been mutated or are new.
broadcast_users_update()
if token_hashes: if token_hashes:
broadcast( broadcast(
@ -98,8 +93,12 @@ async def t_broadcast_users_update(iteration):
@with_period(CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE']) @with_period(CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE'])
async def t_broadcast_stream_info_update(iteration): async def t_broadcast_stream_info_update(iteration):
if iteration == 0: if iteration == 0:
current_app.stream_title = await get_stream_title() title = await get_stream_title()
current_app.stream_uptime = get_stream_uptime() 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: else:
payload = {} payload = {}
@ -109,7 +108,7 @@ async def t_broadcast_stream_info_update(iteration):
current_app.stream_title = title current_app.stream_title = title
payload['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: if current_app.stream_uptime is None:
expected_uptime = None expected_uptime = None
else: else:
@ -126,6 +125,12 @@ async def t_broadcast_stream_info_update(iteration):
elif abs(uptime - expected_uptime) >= 0.0625: elif abs(uptime - expected_uptime) >= 0.0625:
payload['uptime'] = uptime 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: if payload:
broadcast(USERS, payload={'type': 'info', **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> <noscript><iframe id="info_nojs" src="{{ url_for('nojs_info', token=user.token) }}" data-js="false"></iframe></noscript>
</article> </article>
<aside id="chat"> <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"> <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> <noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token, _anchor='end') }}" data-js="false"></iframe></noscript>
</article> </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"> <section id="chat__form">
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_form', token=user.token) }}" data-js="false"></iframe></noscript> <noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_form', token=user.token) }}" data-js="false"></iframe></noscript>
</section> </section>
</aside> </aside>
<nav id="toggle"> <nav id="nav">
<a href="#info">info</a> <a href="#info">info</a>
<a href="#chat">chat</a> <a href="#chat">chat</a>
<a href="#both">both</a> <a href="#both">both</a>
</nav> </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> <script src="{{ url_for('static', filename='anonstream.js') }}" type="text/javascript"></script>
</body> </body>
</html> </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> <!doctype html>
<html> <html>
<head> <head>
@ -138,17 +139,7 @@
{% with user = users_by_token[message.token] %} {% 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> <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 -}} {{- '&nbsp;' | safe -}}
<span class="chat-message__name" style="color:{{ user.color }};"> {{ appearance(user, name_class='chat-message__name', tag_class='chat-message__name__tag') }}
{{- 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 -%}
{{- ':&nbsp;' | safe -}} {{- ':&nbsp;' | safe -}}
<span class="chat-message__markup">{{ message.markup }}</span> <span class="chat-message__markup">{{ message.markup }}</span>
{% endwith %} {% endwith %}

ファイルの表示

@ -2,16 +2,23 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="refresh" content="12">
<style> <style>
body { body {
overflow-y: auto; overflow-y: auto;
margin: 1ch 1.5ch; margin: 0.75ch 1.25ch;
font-family: sans-serif; font-family: sans-serif;
color: #ddd; color: #ddd;
} }
#uptime { #float {
float: right; float: right;
font-size: 11pt; font-size: 11pt;
display: grid;
grid-auto-flow: column;
grid-gap: 2.5ch;
}
#float__uptime {
font-variant-numeric: tabular-nums;
} }
#title > h1 { #title > h1 {
margin: 0; margin: 0;
@ -22,7 +29,12 @@
</style> </style>
</head> </head>
<body> <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> <header id="title"><h1>{{ title }}</h1></header>
</body> </body>
</html> </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 quart import current_app
from anonstream.wrappers import try_except_log, with_timestamp 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.captcha import check_captcha_digest, Answer
from anonstream.helpers.tripcode import generate_tripcode from anonstream.helpers.tripcode import generate_tripcode
from anonstream.utils.colour import color_to_colour, get_contrast, NotAColor 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 CONFIG = current_app.config
MESSAGES = current_app.messages MESSAGES = current_app.messages
@ -36,25 +36,30 @@ def pop_state(user, state_id):
state = None state = None
return state return state
def try_change_appearance(user, name, color, password, def try_change_appearance(user, name, color, password, want_tripcode):
want_delete_tripcode, want_change_tripcode):
errors = [] errors = []
def try_(f, *args, **kwargs): def try_(f, *args, **kwargs):
return try_except_log(errors, BadAppearance)(f)(*args, **kwargs) return try_except_log(errors, BadAppearance)(f)(*args, **kwargs)
try_(change_name, user, name, dry_run=True) try_(change_name, user, name, dry_run=True)
try_(change_color, user, color, dry_run=True) try_(change_color, user, color, dry_run=True)
if want_delete_tripcode: if want_tripcode:
pass
elif want_change_tripcode:
try_(change_tripcode, user, password, dry_run=True) try_(change_tripcode, user, password, dry_run=True)
if not errors: if not errors:
change_name(user, name) change_name(user, name)
change_color(user, color) 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) delete_tripcode(user)
elif want_change_tripcode:
# Change tripcode
elif want_tripcode:
change_tripcode(user, password) change_tripcode(user, password)
# Add to the users update buffer # Add to the users update buffer
@ -112,13 +117,9 @@ def watched(timestamp, user):
@with_timestamp @with_timestamp
def get_all_users_for_websocket(timestamp): def get_all_users_for_websocket(timestamp):
visible_users = filter(
lambda user: is_visible(timestamp, MESSAGES, user),
USERS,
)
return { return {
user['token_hash']: get_user_for_websocket(user) 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): def verify(user, digest, answer):
@ -153,3 +154,55 @@ def deverify(timestamp, user):
if n_user_messages >= CONFIG['FLOOD_THRESHOLD']: if n_user_messages >= CONFIG['FLOOD_THRESHOLD']:
user['verified'] = False 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 hashlib
import secrets import secrets
from collections import OrderedDict from collections import OrderedDict
from enum import Enum
from math import inf from math import inf
from quart import escape, Markup from quart import escape, Markup
Presence = Enum(
'Presence',
names=(
'WATCHING',
'NOTWATCHING',
'TENTATIVE',
'ABSENT',
)
)
def generate_token(): def generate_token():
return secrets.token_hex(16) 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): def concatenate_for_notice(string, *tuples):
if not tuples: if not tuples:
return string return string
@ -23,3 +30,19 @@ def concatenate_for_notice(string, *tuples):
) )
) )
return string + markup 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): class Malformed(Exception):
pass 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): def parse_websocket_data(receipt):
if not isinstance(receipt, dict): if not isinstance(receipt, dict):
raise Malformed('not a json object') raise Malformed('not a json object')
comment = receipt.get('comment') match receipt.get('type'):
if not isinstance(comment, str): case 'message':
raise Malformed('malformed comment') 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') case 'appearance':
if not isinstance(nonce, str): raise NotImplemented
raise Malformed('malformed nonce')
digest = receipt.get('captcha-digest', '') case 'captcha':
answer = receipt.get('captcha-answer', '') 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 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.captcha import get_random_captcha_digest_for
from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected 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 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 CONFIG = current_app.config
async def websocket_outbound(queue, user): async def websocket_outbound(queue, user):
uptime = get_stream_uptime()
viewership = get_stream_viewership_or_none(uptime)
payload = { payload = {
'type': 'init', 'type': 'init',
'nonce': generate_nonce(), 'nonce': generate_nonce(),
'title': await get_stream_title(), 'title': await get_stream_title(),
'uptime': get_stream_uptime(), 'uptime': uptime,
'viewership': viewership,
'messages': get_all_messages_for_websocket(), 'messages': get_all_messages_for_websocket(),
'users': get_all_users_for_websocket(), 'users': get_all_users_for_websocket(),
'default': { 'default': {
@ -41,7 +44,7 @@ async def websocket_inbound(queue, user):
finally: finally:
see(user) see(user)
try: try:
nonce, comment, digest, answer = parse_websocket_data(receipt) parsed = parse_websocket_data(receipt)
except Malformed as e: except Malformed as e:
error , *_ = e.args error , *_ = e.args
payload = { payload = {
@ -49,29 +52,47 @@ async def websocket_inbound(queue, user):
'because': error, 'because': error,
} }
else: else:
try: match parsed:
verification_happened = verify(user, digest, answer) case [nonce, comment, digest, answer]:
except BadCaptcha as e: payload = handle_inbound_message(user, *parsed)
notice, *_ = e.args
else: case None:
try: payload = handle_inbound_captcha(user)
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),
}
queue.put_nowait(payload) 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),
}