コミットを比較
6 コミット
aaa0dbb5a5
...
886c360e26
作成者 | SHA1 | 日付 |
---|---|---|
n9k | 886c360e26 | |
n9k | 6b26e4fd02 | |
n9k | 9e08a1c386 | |
n9k | 384942ad15 | |
n9k | fa7ab5d3ed | |
n9k | 515f19f904 |
12
README.md
12
README.md
|
@ -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 — <a href="#" target="_blank">source</a></footer>
|
<footer>anonstream pre-1.0.0 — <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>
|
||||||
|
|
|
@ -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 }}"> </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>
|
||||||
{{- ' ' | safe -}}
|
{{- ' ' | 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"> </span>
|
|
||||||
{{- '' -}}
|
|
||||||
<span class="tripcode for-tripcode" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>
|
|
||||||
{%- endif -%}
|
|
||||||
{{- ': ' | safe -}}
|
{{- ': ' | 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>
|
||||||
|
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
読み込み中…
新しいイシューから参照