コミットを比較

...

16 コミット

作成者 SHA1 メッセージ 日付
n9k e345ef9e6d Reject whitespace-only comments 2022-06-09 01:36:31 +00:00
n9k c64ede18f6 Detect chat flooding by counting lines
Reject comments by line count. Ratelimit users by number of lines sent
in chat.
2022-06-09 01:36:31 +00:00
n9k 5a09ec1d4c m3u8 init_section may not exist 2022-06-09 01:36:30 +00:00
n9k 868c29cb94 Websocket: ping immediately 2022-06-09 01:36:09 +00:00
n9k 49d9b9e927 Chat insignia: solid orangered background 2022-06-09 01:34:59 +00:00
n9k bae8012f12 CSS: fullheight mobile chat, remove iframe margins 2022-06-09 01:34:59 +00:00
n9k 88dab15236 Send <!doctype html> in responses when auth fails 2022-06-09 01:34:59 +00:00
n9k 673ae7cf50 Add timeout for each ASGI http.response.body message
Ensures that if a client becomes idle the segment generator is exited
within a constant amount of time (probably more than this timeout
because of the ASGI server's write buffer and the OS's socket write
buffer, but still constant).
2022-06-09 01:34:59 +00:00
n9k f9b2b92d49 Remove redundant failsafe `websocket.close()`
Ping timeouts should do the same thing.
2022-06-09 01:34:59 +00:00
n9k 6e0fbc8029 Add websocket ping/pong
Client and server both close the connection if they don't hear from the
other party after a timeout period. This is a failsafe and should
improve reliability.
2022-06-09 01:34:59 +00:00
n9k 1e18ac033f Add offline screen 2022-06-09 01:34:59 +00:00
n9k 6a4e16eaf4 Use &times; instead of &cross;
Tor Browser on Linux prefers &times;
2022-06-09 01:34:59 +00:00
n9k 0aad555408 Info iframe: properly show uptime text-only fallback 2022-06-09 01:34:59 +00:00
n9k 0352358611 Compress some responses
Adds dependency `quart-compress`
2022-06-09 01:34:59 +00:00
n9k 73824f70d7 Lock js chat scroll when not at bottom 2022-06-09 01:34:59 +00:00
n9k dab389abcc More precise debug messages when segment generator exits 2022-06-09 01:34:45 +00:00
21個のファイルの変更376行の追加77行の削除

ファイルの表示

@ -6,13 +6,16 @@ import secrets
import toml
from collections import OrderedDict
from quart import Quart
from quart_compress import Compress
from werkzeug.security import generate_password_hash
from anonstream.quart import Quart
from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer
from anonstream.utils.colour import color_to_colour
from anonstream.utils.user import generate_token
compress = Compress()
def create_app(config_file):
with open(config_file) as fp:
config = toml.load(fp)
@ -50,6 +53,8 @@ def create_app(config_file):
'MAX_CHAT_SCROLLBACK': config['memory']['chat_scrollback'],
'TASK_PERIOD_ROTATE_USERS': config['tasks']['rotate_users'],
'TASK_PERIOD_ROTATE_CAPTCHAS': config['tasks']['rotate_captchas'],
'TASK_PERIOD_ROTATE_WEBSOCKETS': config['tasks']['rotate_websockets'],
'TASK_PERIOD_BROADCAST_PING': config['tasks']['broadcast_ping'],
'TASK_PERIOD_BROADCAST_USERS_UPDATE': config['tasks']['broadcast_users_update'],
'TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE': config['tasks']['broadcast_stream_info_update'],
'THRESHOLD_USER_NOTWATCHING': config['thresholds']['user_notwatching'],
@ -61,8 +66,10 @@ def create_app(config_file):
'CHAT_NAME_MIN_CONTRAST': config['chat']['min_name_contrast'],
'CHAT_BACKGROUND_COLOUR': color_to_colour(config['chat']['background_color']),
'CHAT_LEGACY_TRIPCODE_ALGORITHM': config['chat']['legacy_tripcode_algorithm'],
'FLOOD_DURATION': config['flood']['duration'],
'FLOOD_THRESHOLD': config['flood']['threshold'],
'FLOOD_MESSAGE_DURATION': config['flood']['messages']['duration'],
'FLOOD_MESSAGE_THRESHOLD': config['flood']['messages']['threshold'],
'FLOOD_LINE_DURATION': config['flood']['lines']['duration'],
'FLOOD_LINE_THRESHOLD': config['flood']['lines']['threshold'],
'CAPTCHA_LIFETIME': config['captcha']['lifetime'],
'CAPTCHA_FONTS': config['captcha']['fonts'],
'CAPTCHA_ALPHABET': config['captcha']['alphabet'],
@ -112,4 +119,11 @@ def create_app(config_file):
import anonstream.routes
import anonstream.tasks
# Compress some responses
compress.init_app(app)
app.config.update({
"COMPRESS_MIN_SIZE": 2048,
"COMPRESS_LEVEL": 9,
})
return app

ファイルの表示

@ -8,7 +8,7 @@ from quart import current_app, escape
from anonstream.broadcast import broadcast, broadcast_users_update
from anonstream.helpers.chat import generate_nonce_hash, get_scrollback
from anonstream.utils.chat import get_message_for_websocket
from anonstream.utils.chat import get_message_for_websocket, get_approx_linespan
CONFIG = current_app.config
MESSAGES_BY_ID = current_app.messages_by_id
@ -33,18 +33,48 @@ def add_chat_message(user, nonce, comment, ignore_empty=False):
if ignore_empty and len(comment) == 0:
return False
timestamp_ms = time.time_ns() // 1_000_000
timestamp = timestamp_ms // 1000
# Check user
while user['linespan']:
linespan_timestamp, _ = user['linespan'][0]
if timestamp - linespan_timestamp >= CONFIG['FLOOD_LINE_DURATION']:
user['linespan'].popleft()
else:
break
total_recent_linespan = sum(map(
lambda linespan_tuple: linespan_tuple[1],
user['linespan'],
))
if total_recent_linespan > CONFIG['FLOOD_LINE_THRESHOLD']:
raise Rejected(
f'Chat overuse in the last '
f'{CONFIG["FLOOD_LINE_DURATION"]:.0f} seconds'
)
# Check message
message_id = generate_nonce_hash(nonce)
if message_id in MESSAGES_BY_ID:
raise Rejected('Discarded suspected duplicate message')
if len(comment) == 0:
raise Rejected('Message was empty')
if len(comment.strip()) == 0:
raise Rejected('Message was practically empty')
if len(comment) > 512:
raise Rejected('Message exceeded 512 chars')
if comment.count('\n') + 1 > 12:
raise Rejected('Message exceeded 12 lines')
linespan = get_approx_linespan(comment)
if linespan > 12:
raise Rejected('Message would span too many lines')
# Record linespan
linespan_tuple = (timestamp, linespan)
user['linespan'].append(linespan_tuple)
# Create and add message
timestamp_ms = time.time_ns() // 1_000_000
timestamp = timestamp_ms // 1000
try:
last_message = next(reversed(MESSAGES))
except StopIteration:

ファイルの表示

@ -3,7 +3,7 @@
import hashlib
import base64
from collections import OrderedDict
from collections import deque, OrderedDict
from math import inf
from quart import current_app
@ -35,7 +35,7 @@ def generate_user(timestamp, token, broadcaster, presence):
'tag': tag,
'broadcaster': broadcaster,
'verified': broadcaster,
'websockets': set(),
'websockets': {},
'name': None,
'color': colour_to_color(colour),
'tripcode': None,
@ -45,6 +45,7 @@ def generate_user(timestamp, token, broadcaster, presence):
'watching': -inf,
},
'presence': presence,
'linespan': deque(),
}
def get_default_name(user):

51
anonstream/quart.py ノーマルファイル
ファイルの表示

@ -0,0 +1,51 @@
import asyncio
from werkzeug.wrappers import Response as WerkzeugResponse
from quart.app import Quart as Quart_
from quart.asgi import ASGIHTTPConnection as ASGIHTTPConnection_
from quart.utils import encode_headers
RESPONSE_ITERATOR_TIMEOUT = 10
class ASGIHTTPConnection(ASGIHTTPConnection_):
async def _send_response(self, send, response):
await send({
"type": "http.response.start",
"status": response.status_code,
"headers": encode_headers(response.headers),
})
if isinstance(response, WerkzeugResponse):
for data in response.response:
body = data.encode(response.charset) if isinstance(data, str) else data
await asyncio.wait_for(
send({
"type": "http.response.body",
"body": body,
"more_body": True,
}),
timeout=RESPONSE_ITERATOR_TIMEOUT,
)
else:
async with response.response as response_body:
async for data in response_body:
body = data.encode(response.charset) if isinstance(data, str) else data
await asyncio.wait_for(
send({
"type": "http.response.body",
"body": body,
"more_body": True,
}),
timeout=RESPONSE_ITERATOR_TIMEOUT,
)
await send({
"type": "http.response.body",
"body": b"",
"more_body": False,
})
class Quart(Quart_):
asgi_http_class = ASGIHTTPConnection

ファイルの表示

@ -5,7 +5,7 @@ from quart import current_app, request, render_template, redirect, url_for, esca
from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import add_chat_message, Rejected
from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership
from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha
from anonstream.routes.wrappers import with_user_from, render_template_with_etag
from anonstream.helpers.chat import get_scrollback
@ -24,6 +24,7 @@ async def nojs_stream(user):
'nojs_stream.html',
csp=generate_csp(),
user=user,
online=is_online(),
)
@current_app.route('/info.html')

ファイルの表示

@ -3,6 +3,8 @@
import asyncio
from math import inf
from quart import current_app, websocket
from anonstream.user import see
@ -13,7 +15,7 @@ from anonstream.routes.wrappers import with_user_from
@with_user_from(websocket)
async def live(user):
queue = asyncio.Queue(maxsize=0)
user['websockets'].add(queue)
user['websockets'][queue] = -inf
producer = websocket_outbound(queue, user)
consumer = websocket_inbound(queue, user)
@ -21,8 +23,4 @@ async def live(user):
await asyncio.gather(producer, consumer)
finally:
see(user)
user['websockets'].remove(queue)
try:
await websocket.close(1000)
except RuntimeError:
pass
user['websockets'].pop(queue)

ファイルの表示

@ -33,14 +33,21 @@ def auth_required(f):
async def wrapper(*args, **kwargs):
if check_auth(request):
return await f(*args, **kwargs)
hint = 'The broadcaster should log in with the credentials printed ' \
'in their terminal.'
body = (
f'<p>{hint}</p>'
if request.authorization is None else
'<p>Wrong username or password. Refresh the page to try again.</p>'
f'<p>{hint}</p>'
hint = (
'The broadcaster should log in with the credentials printed in '
'their terminal.'
)
if request.authorization is None:
body = (
f'<!doctype html>\n'
f'<p>{hint}</p>\n'
)
else:
body = (
f'<!doctype html>\n'
f'<p>Wrong username or password. Refresh the page to try again.</p>\n'
f'<p>{hint}</p>\n'
)
return body, 401, {'WWW-Authenticate': 'Basic'}
return wrapper

ファイルの表示

@ -76,12 +76,17 @@ def get_next_segment(uri):
segment = None
return segment
async def get_segment_uris():
async def get_segment_uris(token):
try:
segment = get_starting_segment()
except Offline:
print(
f'[debug @ {time.time():.3f}: {token=}] '
f'stream went offline before we could find any segments'
)
return
else:
if segment.init_section is not None:
yield segment.init_section.uri
while True:
@ -92,12 +97,22 @@ async def get_segment_uris():
try:
next_segment = get_next_segment(segment.uri)
except Offline:
print(
f'[debug @ {time.time():.3f}: {token=}] '
f'stream went offline while looking for the segment '
f'following {uri=}'
)
return
else:
if next_segment is not None:
segment = next_segment
break
elif time.monotonic() - t0 >= CONFIG['SEGMENT_SEARCH_TIMEOUT']:
print(
f'[debug @ {time.time():.3f}: {token=}] '
f'timed out looking for the segment following {uri=}'
f'(timeout={CONFIG["SEGMENT_SEARCH_TIMEOUT"]}s)'
)
return
else:
await asyncio.sleep(CONFIG['SEGMENT_SEARCH_COOLDOWN'])
@ -112,8 +127,7 @@ def path_for(uri):
async def segments(segment_read_hook=lambda uri: None, token=None):
print(f'[debug @ {time.time():.3f}: {token=}] entering segment generator')
uri = None
async for uri in get_segment_uris():
async for uri in get_segment_uris(token):
#print(f'[debug @ {time.time():.3f}: {token=}] {uri=}')
try:
path = path_for(uri)
@ -136,10 +150,4 @@ async def segments(segment_read_hook=lambda uri: None, token=None):
f'segment {uri=} at {path=} unexpectedly does not exist'
)
break
else:
print(
f'[debug @ {time.time():.3f}: {token=}] '
f'could not find segment following {uri=} after at least '
f'{CONFIG["SEGMENT_SEARCH_TIMEOUT"]} seconds'
)
print(f'[debug @ {time.time():.3f}: {token=}] exiting segment generator')

ファイルの表示

@ -11,14 +11,17 @@ const TOKEN_HASH = document.body.dataset.tokenHash;
const CSP = document.body.dataset.csp;
/* insert js-only markup */
const jsmarkup_stream = `<video id="stream_js" src="/stream.mp4?token=${encodeURIComponent(TOKEN)}" autoplay controls></video>`
const jsmarkup_stream_video = '<video id="stream__video" autoplay controls></video>'
const jsmarkup_stream_offline = '<header id="stream__offline"><h1>[offline]</h1></header>'
const jsmarkup_info = '<div id="info_js" data-js="true"></div>';
const jsmarkup_info_float = '<aside id="info_js__float"></aside>';
const jsmarkup_info_float_button = '<button id="info_js__float__button">Reload stream</button>';
const jsmarkup_info_float_viewership = '<div id="info_js__float__viewership"></div>';
const jsmarkup_info_float_uptime = '<div id="info_js__float__uptime"></div>';
const jsmarkup_info_title = '<header id="info_js__title"></header>';
const jsmarkup_chat_messages = '<ol id="chat-messages_js" data-js="true"></ol>';
const jsmarkup_chat_messages = `\
<ol id="chat-messages_js" data-js="true"></ol>
<button id="chat-messages-unlock">Chat scroll paused. Click to resume.</button>`;
const jsmarkup_chat_users = `\
<article id="chat-users_js">
<h5 id="chat-users_js__watching-header"></h5>
@ -81,9 +84,13 @@ const insert_jsmarkup = () => {
style_tripcode_colors.nonce = CSP;
document.head.insertAdjacentElement("beforeend", style_tripcode_colors);
}
if (document.getElementById("stream_js") === null) {
if (document.getElementById("stream__video") === null) {
const parent = document.getElementById("stream");
parent.insertAdjacentHTML("beforeend", jsmarkup_stream);
parent.insertAdjacentHTML("beforeend", jsmarkup_stream_video);
}
if (document.getElementById("stream__offline") === null) {
const parent = document.getElementById("stream");
parent.insertAdjacentHTML("beforeend", jsmarkup_stream_offline);
}
if (document.getElementById("info_js") === null) {
const parent = document.getElementById("info");
@ -262,6 +269,9 @@ let stats = null;
let stats_received = null;
let default_name = {true: "Broadcaster", false: "Anonymous"};
let max_chat_scrollback = 256;
let pingpong_period = 8.0;
let ping = null;
const pingpong_timeout = () => pingpong_period * 1.5 + 4.0;
const tidy_stylesheet = ({stylesheet, selector_regex, ignore_condition}) => {
const to_delete = [];
const to_ignore = new Set();
@ -566,6 +576,12 @@ const update_users_list = () => {
chat_users_notwatching_header.innerText = `Not watching (${notwatching})`;
}
const show_offline_screen = () => {
video.removeAttribute("src");
video.load();
stream.dataset.offline = "";
}
const on_websocket_message = (event) => {
//console.log("websocket message", event);
const receipt = JSON.parse(event.data);
@ -579,7 +595,7 @@ const on_websocket_message = (event) => {
case "init":
console.log("ws init", receipt);
// set title
pingpong_period = receipt.pingpong;
set_title(receipt.title);
// update stats (uptime/viewership)
@ -662,7 +678,7 @@ const on_websocket_message = (event) => {
}
// stream reload button
if (stats === null || stream.networkState === stream.NETWORK_LOADING) {
if (stats === null || video.networkState === video.NETWORK_LOADING) {
info_button.removeAttribute("data-visible");
} else {
info_button.dataset.visible = "";
@ -692,11 +708,13 @@ const on_websocket_message = (event) => {
case "message":
console.log("ws message", receipt);
create_and_add_chat_message(receipt.message);
chat_messages.scrollTo({
left: 0,
top: chat_messages.scrollTopMax,
behavior: "smooth",
});
if (chat_messages.dataset.scrollLock === undefined) {
chat_messages.scrollTo({
left: 0,
top: chat_messages.scrollTopMax,
behavior: "smooth",
});
}
break;
case "set-users":
@ -760,6 +778,13 @@ const on_websocket_message = (event) => {
break;
case "ping":
console.log("ws ping");
ping = new Date();
const payload = {type: "pong"};
ws.send(JSON.stringify(payload));
break;
default:
console.log("incomprehensible websocket message", receipt);
}
@ -815,18 +840,26 @@ const connect_websocket = () => {
connect_websocket();
/* stream reload button */
const stream = document.getElementById("stream_js");
const video = document.getElementById("stream__video");
const info_button = document.getElementById("info_js__float__button");
info_button.addEventListener("click", (event) => {
stream.load();
stream.removeAttribute("data-offline");
video.src = `/stream.mp4?token=${encodeURIComponent(TOKEN)}`;
video.load();
info_button.removeAttribute("data-visible");
});
stream.addEventListener("error", (event) => {
video.addEventListener("error", (event) => {
if (video.error !== null && video.error.message === "404: Not Found") {
show_offline_screen();
}
if (stats !== null) {
info_button.dataset.visible = "";
}
});
/* load stream */
video.src = `/stream.mp4?token=${encodeURIComponent(TOKEN)}`;
/* override js-only chat form */
const chat_form_nonce = document.getElementById("chat-form_js__nonce");
const chat_form_comment = document.getElementById("chat-form_js__comment");
@ -866,7 +899,31 @@ const peg_bottom = (entries) => {
}
const resize = new ResizeObserver(peg_bottom);
resize.observe(chat_messages);
track_scroll(chat_messages);
/* chat scroll lock */
chat_messages.addEventListener("scroll", (event) => {
track_scroll(chat_messages);
const scroll = chat_messages.scrollTopMax - chat_messages.scrollTop;
const locked = chat_messages.dataset.scrollLock !== undefined
if (scroll >= 160 && !locked) {
chat_messages.dataset.scrollLock = "";
} else if (scroll == 0 && locked) {
chat_messages.removeAttribute("data-scroll-lock");
}
});
track_scroll(chat_messages);
const chat_messages_unlock = document.getElementById("chat-messages-unlock");
chat_messages_unlock.addEventListener("click", (event) => {
chat_messages.scrollTop = chat_messages.scrollTopMax;
});
/* close websocket after prolonged absence of pings */
const rotate_websocket = () => {
const this_pingpong_timeout = pingpong_timeout();
if (ping === null || (new Date() - ping) / 1000 > this_pingpong_timeout) {
console.log(`no pings heard in ${this_pingpong_timeout} seconds, closing websocket...`);
ws.close();
}
setTimeout(rotate_websocket, this_pingpong_timeout * 1000);
};
setTimeout(rotate_websocket, pingpong_timeout() * 1000);

ファイルの表示

@ -44,6 +44,7 @@ a {
color: #42a5d7;
}
iframe {
display: grid;
width: 100%;
border: none;
box-sizing: border-box;
@ -56,11 +57,30 @@ noscript {
#stream {
background: black;
grid-area: stream;
position: relative;
}
#stream_js {
#stream__video {
width: 100%;
height: 100%;
}
#stream__offline {
position: absolute;
top: 0;
width: 100%;
height: 100%;
text-align: center;
display: grid;
align-content: center;
font-size: 20pt;
background-color: black;
user-select: none;
}
#stream__offline > h1 {
margin: 0;
}
#stream:not([data-offline]) > #stream__offline {
display: none;
}
#stream_nojs {
height: 100%;
}
@ -100,13 +120,11 @@ noscript {
#chat {
display: grid;
grid-auto-rows: auto 1fr auto;
grid-auto-rows: auto minmax(150px, 1fr) auto;
background-color: var(--chat-bg-color);
border-top: var(--chat-border);
border-bottom: var(--chat-border);
grid-area: chat;
height: 50vh;
min-height: 24ch;
position: relative;
}
#chat__toggle {
@ -180,6 +198,28 @@ noscript {
bottom: 0;
font-size: 11pt;
}
#chat-messages_js:not([data-scroll-lock]) + #chat-messages-unlock {
display: none;
}
#chat-messages-unlock {
position: absolute;
bottom: 1rem;
color: inherit;
font-size: 10pt;
text-align: center;
width: calc(100% - 4rem);
margin: 0 2rem;
padding: 0.5rem 0;
box-sizing: border-box;
background-color: #316aaf;
border-radius: 4px;
border: 1px outset #4c91e6;
box-shadow: 0 0 3px #4c91e6;
cursor: pointer;
}
#chat-messages-unlock:hover {
background-color: #3674bf;
}
#chat-messages_nojs {
height: 100%;
}
@ -199,7 +239,12 @@ noscript {
cursor: default;
}
.chat-insignia {
text-shadow: 0 0 2px orangered;
color: var(--chat-bg);
border: 1px outset #0000007f;
text-shadow: 0 0 1px var(--chat-bg-color);
background-color: orangered;
padding: 0 2px;
border-radius: 3px;
cursor: help;
}
.chat-name {
@ -470,7 +515,7 @@ footer {
border-style: inset;
}
#both:target #info_nojs {
height: 9ch;
height: 11ch;
}
#info:target {
grid-row-end: chat-end;

ファイルの表示

@ -5,7 +5,7 @@ import asyncio
import itertools
from functools import wraps
from quart import current_app
from quart import current_app, websocket
from anonstream.broadcast import broadcast, broadcast_users_update
from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership
@ -86,6 +86,27 @@ async def t_expire_captchas(iteration):
for digest in to_delete:
CAPTCHAS.pop(digest)
@with_period(CONFIG['TASK_PERIOD_ROTATE_WEBSOCKETS'])
@with_timestamp
async def t_close_websockets(timestamp, iteration):
THRESHOLD = CONFIG['TASK_PERIOD_BROADCAST_PING'] * 1.5 + 4.0
if iteration == 0:
return
else:
for user in USERS:
for queue in user['websockets']:
last_pong = user['websockets'][queue]
last_pong_ago = timestamp - last_pong
if last_pong_ago > THRESHOLD:
queue.put_nowait({'type': 'close'})
@with_period(CONFIG['TASK_PERIOD_BROADCAST_PING'])
async def t_broadcast_ping(iteration):
if iteration == 0:
return
else:
broadcast(USERS, payload={'type': 'ping'})
@with_period(CONFIG['TASK_PERIOD_BROADCAST_USERS_UPDATE'])
async def t_broadcast_users_update(iteration):
if iteration == 0:
@ -147,5 +168,7 @@ async def t_broadcast_stream_info_update(iteration):
current_app.add_background_task(t_sunset_users)
current_app.add_background_task(t_expire_captchas)
current_app.add_background_task(t_close_websockets)
current_app.add_background_task(t_broadcast_ping)
current_app.add_background_task(t_broadcast_users_update)
current_app.add_background_task(t_broadcast_stream_info_update)

ファイルの表示

@ -242,13 +242,13 @@
<label for="password-toggle" class="show-password pseudolink">set</label>
{% else %}
<label id="tripcode" for="password-toggle" class="show-password tripcode">{{ user.tripcode.digest }}</label>
<label id="show-cleared" for="cleared-toggle" class="pseudolink x">&cross;</label>
<label id="show-cleared" for="cleared-toggle" class="pseudolink x">&times;</label>
<div id="cleared" class="tripcode">(cleared)</div>
<label id="hide-cleared" for="cleared-toggle" class="pseudolink">undo</label>
{% endif %}
</div>
<input id="appearance-form__password" name="password" type="password" placeholder="(tripcode password)" maxlength="1024">
<div id="hide-password"><label for="password-toggle" class="pseudolink x">&cross;</label></div>
<div id="hide-password"><label for="password-toggle" class="pseudolink x">&times;</label></div>
<div id="appearance-form__buttons">
<div id="appearance-form__buttons__exit"><label for="toggle" class="pseudolink">Return to chat</label></div>
<input type="submit" value="Update">

ファイルの表示

@ -109,7 +109,12 @@
cursor: default;
}
.chat-message__insignia {
text-shadow: 0 0 2px orangered;
color: var(--chat-bg);
border: 1px outset #0000007f;
text-shadow: 0 0 1px var(--chat-bg-color);
background-color: orangered;
padding: 0 2px;
border-radius: 3px;
cursor: help;
}
.chat-message__name {

ファイルの表示

@ -83,11 +83,14 @@
#h1 {
animation-duration: {{ 36000 - uptime }}s;
}
#uptime-dynamic {
animation: disappear step-end {{ 360000 - uptime }}s forwards;
}
#uptime-dynamic-overflow {
animation: appear step-end {{ 360000 - uptime }}s backwards;
}
#uptime-dynamic {
animation: disappear step-end {{ 360000 - uptime }}s forwards;
#uptime-dynamic-overflow::after {
content: "100+ hours";
}
@keyframes appear {
from {
@ -146,11 +149,11 @@
<span id="uptime-static__label">Uptime:</span>
<span>
{%- if uptime >= 3600 -%}
{{- (uptime // 3600) | int -}}
{{- uptime | int // 3600 -}}
{{- ':' -}}
{{- '%02.0f' | format(uptime % 3600 // 60) -}}
{%- else -%}
{{- uptime % 3600 // 60 | int -}}
{{- uptime | int % 3600 // 60 -}}
{%- endif -%}
{{- ':' -}}
{{- '%02.0f' | format(uptime % 60) -}}
@ -170,7 +173,7 @@
{{- '' -}}
<span id="s0"></span>
</div>
<div id="uptime-dynamic-overflow">100+ hours</div>
<div id="uptime-dynamic-overflow"></div>
{% endif %}
</div>
</aside>

ファイルの表示

@ -16,14 +16,35 @@
height: 100%;
margin: 0;
overflow: hidden;
color: #ddd;
font-family: sans-serif;
}
#stream {
width: 100%;
height: 100%;
}
{% if online %}
#video {
width: 100%;
height: 100%;
}
{% else %}
#offline {
width: 100%;
height: 100%;
text-align: center;
display: grid;
align-content: center;
font-size: 20pt;
user-select: none;
}
#offline > h1 {
margin: 0;
}
{% endif %}
</style>
</head>
<body>
<video id="stream" src="{{ url_for('stream', token=user.token) }}" autoplay controls></video>
{% if online %}
<video id="video" src="{{ url_for('stream', token=user.token) }}" autoplay controls></video>
{% else %}
<header id="offline"><h1>[offline]</h1></header>
{% endif %}
</body>
</html>

ファイルの表示

@ -152,12 +152,12 @@ def deverify(timestamp, user):
n_user_messages = 0
for message in reversed(MESSAGES):
message_sent_ago = timestamp - message['timestamp']
if message_sent_ago >= CONFIG['FLOOD_DURATION']:
if message_sent_ago >= CONFIG['FLOOD_MESSAGE_DURATION']:
break
elif message['token'] == user['token']:
n_user_messages += 1
if n_user_messages >= CONFIG['FLOOD_THRESHOLD']:
if n_user_messages >= CONFIG['FLOOD_MESSAGE_THRESHOLD']:
user['verified'] = False
def _update_presence(timestamp, user):

ファイルの表示

@ -3,6 +3,7 @@
import base64
import hashlib
import math
import secrets
class NonceReuse(Exception):
@ -18,3 +19,10 @@ def get_message_for_websocket(user, message):
**{key: message[key] for key in message_keys},
**{key: user[key] for key in user_keys},
}
def get_approx_linespan(text):
def height(line):
return math.ceil(len(line) / 48)
linespan = sum(map(height, text.splitlines()))
linespan = linespan if linespan > 0 else 1
return linespan

ファイルの表示

@ -3,7 +3,7 @@
from enum import Enum
WS = Enum('WS', names=('MESSAGE, CAPTCHA, APPEARANCE'))
WS = Enum('WS', names=('PONG', 'MESSAGE', 'CAPTCHA', 'APPEARANCE'))
class Malformed(Exception):
pass
@ -48,5 +48,8 @@ def parse_websocket_data(receipt):
case 'captcha':
return WS.CAPTCHA, ()
case 'pong':
return WS.PONG, ()
case _:
raise Malformed('malformed type')

ファイルの表示

@ -10,6 +10,7 @@ from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected
from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha, try_change_appearance
from anonstream.wrappers import with_timestamp
from anonstream.utils.chat import generate_nonce
from anonstream.utils.websocket import parse_websocket_data, Malformed, WS
@ -29,11 +30,17 @@ async def websocket_outbound(queue, user):
},
'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'],
'digest': get_random_captcha_digest_for(user),
'pingpong': CONFIG['TASK_PERIOD_BROADCAST_PING'],
}
await websocket.send_json(payload)
await websocket.send_json({'type': 'ping'})
while True:
payload = await queue.get()
await websocket.send_json(payload)
if payload['type'] == 'close':
await websocket.close(1011)
break
else:
await websocket.send_json(payload)
async def websocket_inbound(queue, user):
while True:
@ -59,17 +66,26 @@ async def websocket_inbound(queue, user):
handle = handle_inbound_appearance
case WS.CAPTCHA:
handle = handle_inbound_captcha
payload = handle(user, *parsed)
case WS.PONG:
handle = handle_inbound_pong
payload = handle(queue, user, *parsed)
queue.put_nowait(payload)
if payload is not None:
queue.put_nowait(payload)
def handle_inbound_captcha(user):
@with_timestamp
def handle_inbound_pong(timestamp, queue, user):
print(f'[pong] {user["token"]}')
user['websockets'][queue] = timestamp
return None
def handle_inbound_captcha(queue, user):
return {
'type': 'captcha',
'digest': get_random_captcha_digest_for(user),
}
def handle_inbound_appearance(user, name, color, password, want_tripcode):
def handle_inbound_appearance(queue, user, name, color, password, want_tripcode):
errors = try_change_appearance(user, name, color, password, want_tripcode)
if errors:
return {
@ -85,7 +101,7 @@ def handle_inbound_appearance(user, name, color, password, want_tripcode):
#'tripcode': user['tripcode'],
}
def handle_inbound_message(user, nonce, comment, digest, answer):
def handle_inbound_message(queue, user, nonce, comment, digest, answer):
try:
verification_happened = verify(user, digest, answer)
except BadCaptcha as e:

ファイルの表示

@ -33,6 +33,8 @@ chat_scrollback = 256
[tasks]
rotate_users = 60.0
rotate_captchas = 60.0
rotate_websockets = 2.0
broadcast_ping = 8.0
broadcast_users_update = 4.0
broadcast_stream_info_update = 3.0
@ -47,10 +49,14 @@ min_name_contrast = 3.0
background_color = "#232327"
legacy_tripcode_algorithm = false
[flood]
[flood.messages]
duration = 20.0
threshold = 4
[flood.lines]
duration = 20.0
threshold = 20
[thresholds]
user_notwatching = 8.0
user_tentative = 20.0

ファイルの表示

@ -1,6 +1,7 @@
aiofiles==0.8.0
asgiref==3.5.0
blinker==1.4
Brotli==1.0.9
git+https://github.com/lepture/captcha@27920681b86c27c990da484984f673dba1dd47e5#egg=captcha
click==8.0.4
h11==0.13.0
@ -16,6 +17,7 @@ MarkupSafe==2.1.0
Pillow==9.0.1
priority==2.0.0
quart==0.16.3
quart-compress==0.2.1
toml==0.10.2
uvicorn==0.17.5
Werkzeug==2.0.3