Nojs chat, store all user names/colors in js, forget about inactive users
Project structure evolving a bit
このコミットが含まれているのは:
コミット
e77862f4ff
|
@ -18,14 +18,25 @@ async def create_app():
|
|||
print('Broadcaster password:', auth_password)
|
||||
|
||||
app = Quart('anonstream')
|
||||
app.config['SECRET_KEY'] = config['secret_key'].encode()
|
||||
app.config['AUTH_USERNAME'] = config['auth']['username']
|
||||
app.config['AUTH_PWHASH'] = auth_pwhash
|
||||
app.config['AUTH_TOKEN'] = generate_token()
|
||||
app.config['DEFAULT_HOST_NAME'] = config['names']['broadcaster']
|
||||
app.config['DEFAULT_ANON_NAME'] = config['names']['anonymous']
|
||||
app.config['LIMIT_NOTICES'] = config['limits']['notices']
|
||||
app.chat = OrderedDict()
|
||||
app.config.update({
|
||||
'SECRET_KEY': config['secret_key'].encode(),
|
||||
'AUTH_USERNAME': config['auth']['username'],
|
||||
'AUTH_PWHASH': auth_pwhash,
|
||||
'AUTH_TOKEN': generate_token(),
|
||||
'DEFAULT_HOST_NAME': config['names']['broadcaster'],
|
||||
'DEFAULT_ANON_NAME': config['names']['anonymous'],
|
||||
'MAX_NOTICES': config['limits']['notices'],
|
||||
'MAX_CHAT_STORAGE': config['limits']['chat_storage'],
|
||||
'MAX_CHAT_SCROLLBACK': config['limits']['chat_scrollback'],
|
||||
'USER_CHECKUP_PERIOD': config['ratelimits']['user_absence'],
|
||||
'CAPTCHA_CHECKUP_PERIOD': config['ratelimits']['captcha_expiry'],
|
||||
'THRESHOLD_IDLE': config['thresholds']['idle'],
|
||||
'THRESHOLD_ABSENT': config['thresholds']['absent'],
|
||||
})
|
||||
|
||||
assert app.config['THRESHOLD_ABSENT'] >= app.config['THRESHOLD_IDLE']
|
||||
|
||||
app.chat = {'messages': OrderedDict(), 'nonce_hashes': set()}
|
||||
app.users = {}
|
||||
app.websockets = set()
|
||||
app.segments_directory_cache = DirectoryCache(config['stream']['segments_dir'])
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from quart import escape
|
||||
|
||||
from anonstream.users import users_for_websocket
|
||||
from anonstream.utils.chat import generate_nonce_hash
|
||||
|
||||
class Rejected(Exception):
|
||||
pass
|
||||
|
||||
|
@ -9,18 +13,31 @@ async def broadcast(websockets, payload):
|
|||
for queue in websockets:
|
||||
await queue.put(payload)
|
||||
|
||||
async def add_chat_message(chat, websockets, token, message_id, comment):
|
||||
async def add_chat_message(chat, users, websockets, secret, user, nonce, comment):
|
||||
# check message
|
||||
nonce_hash = generate_nonce_hash(secret, nonce)
|
||||
if nonce_hash in chat['nonce_hashes']:
|
||||
raise Rejected('Discarded suspected duplicate message')
|
||||
if len(comment) == 0:
|
||||
raise Rejected('Message was empty')
|
||||
|
||||
# add message
|
||||
dt = datetime.utcnow()
|
||||
timestamp_ms = time.time_ns() // 1_000_000
|
||||
timestamp = timestamp_ms // 1000
|
||||
try:
|
||||
last_message = next(reversed(chat['messages'].values()))
|
||||
except StopIteration:
|
||||
message_id = timestamp_ms
|
||||
else:
|
||||
if timestamp <= last_message['id']:
|
||||
message_id = last_message['id'] + 1
|
||||
dt = datetime.utcfromtimestamp(timestamp)
|
||||
markup = escape(comment)
|
||||
chat[message_id] = {
|
||||
chat['messages'][message_id] = {
|
||||
'id': message_id,
|
||||
'token': token,
|
||||
'timestamp': int(dt.timestamp()),
|
||||
'nonce_hash': nonce_hash,
|
||||
'token': user['token'],
|
||||
'timestamp': timestamp,
|
||||
'date': dt.strftime('%Y-%m-%d'),
|
||||
'time_minutes': dt.strftime('%H:%M'),
|
||||
'time_seconds': dt.strftime('%H:%M:%S'),
|
||||
|
@ -28,13 +45,16 @@ async def add_chat_message(chat, websockets, token, message_id, comment):
|
|||
'markup': markup,
|
||||
}
|
||||
|
||||
# collect nonce hash
|
||||
chat['nonce_hashes'].add(nonce_hash)
|
||||
|
||||
# broadcast message to websockets
|
||||
await broadcast(
|
||||
websockets,
|
||||
payload={
|
||||
'type': 'chat',
|
||||
'color': '#c7007f',
|
||||
'name': 'Anonymous',
|
||||
'id': message_id,
|
||||
'token_hash': user['token_hash'],
|
||||
'markup': markup,
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
import asyncio
|
||||
|
||||
from quart import current_app, request, render_template, make_response, redirect, websocket, url_for
|
||||
|
||||
from anonstream.stream import get_stream_title
|
||||
from anonstream.segments import CatSegments, Offline
|
||||
from anonstream.users import get_default_name, add_notice, pop_notice
|
||||
from anonstream.wrappers import with_user_from, auth_required
|
||||
from anonstream.websocket import websocket_outbound, websocket_inbound
|
||||
from anonstream.chat import add_chat_message, Rejected
|
||||
from anonstream.utils.chat import create_message, generate_nonce, NonceReuse
|
||||
|
||||
@current_app.route('/')
|
||||
@with_user_from(request)
|
||||
async def home(user):
|
||||
return await render_template('home.html', user=user)
|
||||
|
||||
@current_app.route('/stream.mp4')
|
||||
@with_user_from(request)
|
||||
async def stream(user):
|
||||
try:
|
||||
cat_segments = CatSegments(
|
||||
directory_cache=current_app.segments_directory_cache,
|
||||
token=user['token']
|
||||
)
|
||||
except Offline:
|
||||
return 'offline', 404
|
||||
response = await make_response(cat_segments.stream())
|
||||
response.headers['Content-Type'] = 'video/mp4'
|
||||
response.timeout = None
|
||||
return response
|
||||
|
||||
@current_app.route('/login')
|
||||
@auth_required
|
||||
async def login():
|
||||
return redirect('/')
|
||||
|
||||
@current_app.websocket('/live')
|
||||
@with_user_from(websocket)
|
||||
async def live(user):
|
||||
queue = asyncio.Queue()
|
||||
current_app.websockets.add(queue)
|
||||
|
||||
producer = websocket_outbound(queue)
|
||||
consumer = websocket_inbound(
|
||||
queue=queue,
|
||||
connected_websockets=current_app.websockets,
|
||||
token=user['token'],
|
||||
secret=current_app.config['SECRET_KEY'],
|
||||
chat=current_app.chat,
|
||||
)
|
||||
try:
|
||||
await asyncio.gather(producer, consumer)
|
||||
finally:
|
||||
current_app.websockets.remove(queue)
|
||||
|
||||
@current_app.route('/info.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_info(user):
|
||||
return await render_template(
|
||||
'nojs_info.html',
|
||||
user=user,
|
||||
title=get_stream_title(),
|
||||
)
|
||||
|
||||
@current_app.route('/chat/messages.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat(user):
|
||||
return await render_template('nojs_chat.html', user=user)
|
||||
|
||||
@current_app.route('/chat/form.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_form(user):
|
||||
notice_id = request.args.get('notice', type=int)
|
||||
prefer_chat_form = request.args.get('landing') != 'appearance'
|
||||
return await render_template(
|
||||
'nojs_form.html',
|
||||
user=user,
|
||||
notice=pop_notice(user, notice_id),
|
||||
prefer_chat_form=prefer_chat_form,
|
||||
nonce=generate_nonce(),
|
||||
default_name=get_default_name(user),
|
||||
)
|
||||
|
||||
@current_app.post('/chat/message')
|
||||
@with_user_from(request)
|
||||
async def nojs_submit_message(user):
|
||||
form = await request.form
|
||||
comment = form.get('comment', '')
|
||||
nonce = form.get('nonce', '')
|
||||
|
||||
try:
|
||||
message_id, _, _ = create_message(
|
||||
message_ids=current_app.chat.keys(),
|
||||
secret=current_app.config['SECRET_KEY'],
|
||||
nonce=nonce,
|
||||
comment=comment,
|
||||
)
|
||||
except NonceReuse:
|
||||
notice_id = add_notice(user, 'Discarded suspected duplicate message')
|
||||
else:
|
||||
try:
|
||||
await add_chat_message(
|
||||
current_app.chat,
|
||||
current_app.websockets,
|
||||
user['token'],
|
||||
message_id,
|
||||
comment
|
||||
)
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
notice_id = add_notice(user, notice)
|
||||
else:
|
||||
notice_id = None
|
||||
|
||||
return redirect(url_for('nojs_form', token=user['token'], notice=notice_id))
|
||||
|
||||
@current_app.post('/chat/appearance')
|
||||
@with_user_from(request)
|
||||
async def nojs_submit_appearance(user):
|
||||
pass
|
|
@ -0,0 +1,3 @@
|
|||
import anonstream.routes.core
|
||||
import anonstream.routes.websocket
|
||||
import anonstream.routes.nojs
|
|
@ -0,0 +1,29 @@
|
|||
from quart import current_app, request, render_template, redirect, url_for
|
||||
|
||||
from anonstream.segments import CatSegments, Offline
|
||||
from anonstream.routes.wrappers import with_user_from, auth_required
|
||||
|
||||
@current_app.route('/')
|
||||
@with_user_from(request)
|
||||
async def home(user):
|
||||
return await render_template('home.html', user=user)
|
||||
|
||||
@current_app.route('/stream.mp4')
|
||||
@with_user_from(request)
|
||||
async def stream(user):
|
||||
try:
|
||||
cat_segments = CatSegments(
|
||||
directory_cache=current_app.segments_directory_cache,
|
||||
token=user['token']
|
||||
)
|
||||
except Offline:
|
||||
return 'offline', 404
|
||||
response = await make_response(cat_segments.stream())
|
||||
response.headers['Content-Type'] = 'video/mp4'
|
||||
response.timeout = None
|
||||
return response
|
||||
|
||||
@current_app.route('/login')
|
||||
@auth_required
|
||||
async def login():
|
||||
return redirect(url_for('home'))
|
|
@ -0,0 +1,71 @@
|
|||
from quart import current_app, request, render_template, redirect, url_for
|
||||
|
||||
from anonstream.stream import get_stream_title
|
||||
from anonstream.users import get_default_name, add_notice, pop_notice
|
||||
from anonstream.chat import add_chat_message, Rejected
|
||||
from anonstream.routes.wrappers import with_user_from
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
|
||||
@current_app.route('/info.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_info(user):
|
||||
return await render_template(
|
||||
'nojs_info.html',
|
||||
user=user,
|
||||
title=get_stream_title(),
|
||||
)
|
||||
|
||||
@current_app.route('/chat/messages.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat(user):
|
||||
return await render_template(
|
||||
'nojs_chat.html',
|
||||
user=user,
|
||||
users=current_app.users,
|
||||
messages=current_app.chat['messages'].values(),
|
||||
get_default_name=get_default_name,
|
||||
)
|
||||
|
||||
@current_app.route('/chat/form.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_form(user):
|
||||
notice_id = request.args.get('notice', type=int)
|
||||
prefer_chat_form = request.args.get('landing') != 'appearance'
|
||||
return await render_template(
|
||||
'nojs_form.html',
|
||||
user=user,
|
||||
notice=pop_notice(user, notice_id),
|
||||
prefer_chat_form=prefer_chat_form,
|
||||
nonce=generate_nonce(),
|
||||
default_name=get_default_name(user),
|
||||
)
|
||||
|
||||
@current_app.post('/chat/message')
|
||||
@with_user_from(request)
|
||||
async def nojs_submit_message(user):
|
||||
form = await request.form
|
||||
comment = form.get('comment', '')
|
||||
nonce = form.get('nonce', '')
|
||||
|
||||
try:
|
||||
await add_chat_message(
|
||||
chat=current_app.chat,
|
||||
users=current_app.users,
|
||||
websockets=current_app.websockets,
|
||||
secret=current_app.config['SECRET_KEY'],
|
||||
user=user,
|
||||
nonce=nonce,
|
||||
comment=comment,
|
||||
)
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
notice_id = add_notice(user, notice)
|
||||
else:
|
||||
notice_id = None
|
||||
|
||||
return redirect(url_for('nojs_form', token=user['token'], notice=notice_id))
|
||||
|
||||
@current_app.post('/chat/appearance')
|
||||
@with_user_from(request)
|
||||
async def nojs_submit_appearance(user):
|
||||
pass
|
|
@ -0,0 +1,32 @@
|
|||
import asyncio
|
||||
|
||||
from quart import current_app, websocket
|
||||
|
||||
from anonstream.websocket import websocket_outbound, websocket_inbound
|
||||
from anonstream.routes.wrappers import with_user_from
|
||||
|
||||
@current_app.websocket('/live')
|
||||
@with_user_from(websocket)
|
||||
async def live(user):
|
||||
queue = asyncio.Queue()
|
||||
current_app.websockets.add(queue)
|
||||
|
||||
producer = websocket_outbound(
|
||||
queue=queue,
|
||||
messages=current_app.chat['messages'].values(),
|
||||
users=current_app.users,
|
||||
default_host_name=current_app.config['DEFAULT_HOST_NAME'],
|
||||
default_anon_name=current_app.config['DEFAULT_ANON_NAME'],
|
||||
)
|
||||
consumer = websocket_inbound(
|
||||
queue=queue,
|
||||
chat=current_app.chat,
|
||||
users=current_app.users,
|
||||
connected_websockets=current_app.websockets,
|
||||
user=user,
|
||||
secret=current_app.config['SECRET_KEY'],
|
||||
)
|
||||
try:
|
||||
await asyncio.gather(producer, consumer)
|
||||
finally:
|
||||
current_app.websockets.remove(queue)
|
|
@ -0,0 +1,93 @@
|
|||
import time
|
||||
from functools import wraps
|
||||
|
||||
from quart import current_app, request, abort, make_response
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from anonstream.users import sunset, user_for_websocket
|
||||
from anonstream.websocket import broadcast
|
||||
from anonstream.utils.users import generate_token, generate_user
|
||||
|
||||
def check_auth(context):
|
||||
auth = context.authorization
|
||||
return (
|
||||
auth is not None
|
||||
and auth.type == "basic"
|
||||
and auth.username == current_app.config["AUTH_USERNAME"]
|
||||
and check_password_hash(current_app.config["AUTH_PWHASH"], auth.password)
|
||||
)
|
||||
|
||||
def auth_required(f):
|
||||
@wraps(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>'
|
||||
)
|
||||
return body, 401, {'WWW-Authenticate': 'Basic'}
|
||||
|
||||
return wrapper
|
||||
|
||||
def with_user_from(context):
|
||||
def with_user_from_context(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
timestamp = int(time.time())
|
||||
|
||||
# Check if broadcaster
|
||||
broadcaster = check_auth(context)
|
||||
if broadcaster:
|
||||
token = current_app.config['AUTH_TOKEN']
|
||||
else:
|
||||
token = context.args.get('token') or context.cookies.get('token') or generate_token()
|
||||
|
||||
# Remove non-visible absent users
|
||||
token_hashes = sunset(
|
||||
messages=current_app.chat['messages'].values(),
|
||||
users=current_app.users,
|
||||
)
|
||||
if len(token_hashes) > 0:
|
||||
await broadcast(
|
||||
current_app.websockets,
|
||||
payload={
|
||||
'type': 'rem-users',
|
||||
'token_hashes': token_hashes,
|
||||
}
|
||||
)
|
||||
|
||||
# Update / create user
|
||||
user = current_app.users.get(token)
|
||||
if user is not None:
|
||||
user['seen']['last'] = timestamp
|
||||
else:
|
||||
user = generate_user(
|
||||
secret=current_app.config['SECRET_KEY'],
|
||||
token=token,
|
||||
broadcaster=broadcaster,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
current_app.users[token] = user
|
||||
await broadcast(
|
||||
current_app.websockets,
|
||||
payload={
|
||||
'type': 'add-user',
|
||||
'user': user_for_websocket(user),
|
||||
}
|
||||
)
|
||||
|
||||
# Set cookie
|
||||
response = await f(user, *args, **kwargs)
|
||||
if context.cookies.get('token') != token:
|
||||
response = await make_response(response)
|
||||
response.headers['Set-Cookie'] = f'token={token}; path=/'
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
return with_user_from_context
|
|
@ -2,6 +2,7 @@
|
|||
const token = document.querySelector("body").dataset.token;
|
||||
|
||||
/* insert js-only markup */
|
||||
const jsmarkup_style = '<style id="style_js"></style>'
|
||||
const jsmarkup_info = '<div id="info_js"></div>';
|
||||
const jsmarkup_info_title = '<header id="info_js__title" data-js="true"></header>';
|
||||
const jsmarkup_chat_messages = '<ul id="chat-messages_js" data-js="true"></ul>';
|
||||
|
@ -13,10 +14,14 @@ const jsmarkup_chat_form = `\
|
|||
<span id="chat-live__ball"></span>
|
||||
<span id="chat-live__status">Not connected to chat</span>
|
||||
</div>
|
||||
<input id="chat-form_js__submit" type="submit" value="Chat" disabled>
|
||||
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
|
||||
</form>`;
|
||||
|
||||
const insert_jsmarkup = () => {
|
||||
if (document.getElementById("style_js") === null) {
|
||||
const parent = document.head;
|
||||
parent.insertAdjacentHTML("beforeend", jsmarkup_style);
|
||||
}
|
||||
if (document.getElementById("info_js") === null) {
|
||||
const parent = document.getElementById("info");
|
||||
parent.insertAdjacentHTML("beforeend", jsmarkup_info);
|
||||
|
@ -36,12 +41,75 @@ const insert_jsmarkup = () => {
|
|||
}
|
||||
|
||||
insert_jsmarkup();
|
||||
const stylesheet = document.styleSheets[1];
|
||||
|
||||
/* create websocket */
|
||||
const info_title = document.getElementById("info_js__title");
|
||||
const chat_messages_parent = document.getElementById("chat__messages");
|
||||
const chat_messages = document.getElementById("chat-messages_js");
|
||||
|
||||
const create_chat_message = (object) => {
|
||||
const user = users[object.token_hash];
|
||||
|
||||
const chat_message = document.createElement("li");
|
||||
chat_message.classList.add("chat-message");
|
||||
chat_message.dataset.id = object.id;
|
||||
chat_message.dataset.tokenHash = object.token_hash;
|
||||
|
||||
const chat_message_name = document.createElement("span");
|
||||
chat_message_name.classList.add("chat-message__name");
|
||||
chat_message_name.innerText = user.name || default_name[user.broadcaster];
|
||||
//chat_message_name.dataset.color = user.color; // not working in any browser
|
||||
|
||||
const chat_message_markup = document.createElement("span");
|
||||
chat_message_markup.classList.add("chat-message__markup");
|
||||
chat_message_markup.innerHTML = object.markup;
|
||||
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_name);
|
||||
chat_message.insertAdjacentHTML("beforeend", ": ");
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_markup);
|
||||
|
||||
return chat_message
|
||||
}
|
||||
|
||||
let users = {};
|
||||
let default_name = {true: "Broadcaster", false: "Anonymous"};
|
||||
const equal = (color1, color2) => {
|
||||
/* comparing css colors is annoying */
|
||||
return false;
|
||||
}
|
||||
const update_user_styles = () => {
|
||||
const to_delete = [];
|
||||
const to_ignore = new Set();
|
||||
for (let index = 0; index < stylesheet.cssRules.length; index++) {
|
||||
const css_rule = stylesheet.cssRules[index];
|
||||
const match = css_rule.selectorText.match(/.chat-message\[data-token-hash="([a-z2-7]{26})"\] > .chat-message__name/);
|
||||
const token_hash = match === null ? null : match[1];
|
||||
const user = token_hash === null ? null : users[token_hash];
|
||||
if (user === null || user === undefined) {
|
||||
to_delete.push(index);
|
||||
} else if (!equal(css_rule.style.color, user.color)) {
|
||||
to_delete.push(index);
|
||||
} else {
|
||||
to_ignore.add(token_hash);
|
||||
}
|
||||
}
|
||||
|
||||
for (const token_hash of Object.keys(users)) {
|
||||
if (!to_ignore.has(token_hash)) {
|
||||
const user = users[token_hash];
|
||||
stylesheet.insertRule(
|
||||
`.chat-message[data-token-hash="${token_hash}"] > .chat-message__name { color: ${user.color}; }`,
|
||||
stylesheet.cssRules.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const index of to_delete.reverse()) {
|
||||
stylesheet.deleteRule(index);
|
||||
}
|
||||
}
|
||||
|
||||
const on_websocket_message = (event) => {
|
||||
console.log("websocket message", event);
|
||||
const receipt = JSON.parse(event.data);
|
||||
switch (receipt.type) {
|
||||
case "error":
|
||||
|
@ -50,8 +118,29 @@ const on_websocket_message = (event) => {
|
|||
|
||||
case "init":
|
||||
console.log("ws init", receipt);
|
||||
|
||||
chat_form_nonce.value = receipt.nonce;
|
||||
info_title.innerText = receipt.title;
|
||||
|
||||
default_name = receipt.default;
|
||||
users = receipt.users;
|
||||
update_user_styles();
|
||||
|
||||
const ids = new Set(receipt.chat.map((message) => {return message.id;}));
|
||||
for (const chat_message of chat_messages.children) {
|
||||
if (!ids.has(parseInt(chat_message.dataset.id))) {
|
||||
console.log('removing', chat_message);
|
||||
chat_message.remove();
|
||||
}
|
||||
}
|
||||
const last_id = Math.max(...[...chat_messages.children].map((element) => parseInt(element.dataset.id)));
|
||||
for (const message of receipt.chat) {
|
||||
if (message.id > last_id) {
|
||||
const chat_message = create_chat_message(message);
|
||||
chat_messages.insertAdjacentElement("beforeend", chat_message);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "title":
|
||||
|
@ -79,33 +168,29 @@ const on_websocket_message = (event) => {
|
|||
|
||||
case "chat":
|
||||
console.log("ws chat", receipt);
|
||||
|
||||
const chat_message = document.createElement("li");
|
||||
chat_message.classList.add("chat-message");
|
||||
|
||||
const chat_message_name = document.createElement("span");
|
||||
chat_message_name.classList.add("chat-message__name");
|
||||
chat_message_name.innerText = receipt.name;
|
||||
//chat_message_name.dataset.color = receipt.color; // not working in any browser
|
||||
chat_message_name.style.color = receipt.color;
|
||||
|
||||
const chat_message_markup = document.createElement("span");
|
||||
chat_message_markup.classList.add("chat-message__markup");
|
||||
chat_message_markup.innerHTML = receipt.markup;
|
||||
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_name);
|
||||
chat_message.insertAdjacentHTML("beforeend", ": ");
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_markup);
|
||||
|
||||
const chat_message = create_chat_message(receipt);
|
||||
chat_messages.insertAdjacentElement("beforeend", chat_message);
|
||||
chat_messages_parent.scrollTo({
|
||||
chat_messages.scrollTo({
|
||||
left: 0,
|
||||
top: chat_messages_parent.scrollTopMax,
|
||||
top: chat_messages.scrollTopMax,
|
||||
behavior: "smooth",
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case "add-user":
|
||||
console.log("ws add-user", receipt);
|
||||
users[receipt.user.token_hash] = receipt.user;
|
||||
update_user_styles();
|
||||
break;
|
||||
|
||||
case "rem-users":
|
||||
console.log("ws rem-users", receipt);
|
||||
for (const token_hash of receipt.token_hashes) {
|
||||
delete users[token_hash];
|
||||
}
|
||||
update_user_styles();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("incomprehensible websocket message", receipt);
|
||||
}
|
||||
|
@ -123,13 +208,23 @@ const connect_websocket = () => {
|
|||
chat_live_status.innerText = "Connecting to chat...";
|
||||
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(token)}`);
|
||||
ws.addEventListener("open", (event) => {
|
||||
console.log("websocket open", event);
|
||||
chat_form_submit.disabled = false;
|
||||
chat_live_ball.style.borderColor = "green";
|
||||
chat_live_status.innerText = "Connected to chat";
|
||||
websocket_backoff = 2000; // 2 seconds
|
||||
// When the server is offline, a newly opened websocket can take a second
|
||||
// to close. This timeout tries to ensure the backoff doesn't instantly
|
||||
// (erroneously) reset to 2 seconds in that case.
|
||||
setTimeout(() => {
|
||||
if (event.target === ws) {
|
||||
websocket_backoff = 2000; // 2 seconds
|
||||
}
|
||||
},
|
||||
websocket_backoff + 4000,
|
||||
);
|
||||
});
|
||||
ws.addEventListener("close", (event) => {
|
||||
console.log("websocket closed", event);
|
||||
console.log("websocket close", event);
|
||||
chat_form_submit.disabled = true;
|
||||
chat_live_ball.style.borderColor = "maroon";
|
||||
chat_live_status.innerText = "Disconnected from chat";
|
||||
|
@ -161,3 +256,23 @@ chat_form.addEventListener("submit", (event) => {
|
|||
chat_form_submit.disabled = true;
|
||||
ws.send(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
/* when chat is being resized, peg its bottom in place (instead of its top) */
|
||||
const track_scroll = (element) => {
|
||||
chat_messages.dataset.scrollTop = chat_messages.scrollTop;
|
||||
chat_messages.dataset.scrollTopMax = chat_messages.scrollTopMax;
|
||||
}
|
||||
const peg_bottom = (entries) => {
|
||||
for (const entry of entries) {
|
||||
const element = entry.target;
|
||||
const bottom = chat_messages.dataset.scrollTopMax - chat_messages.dataset.scrollTop;
|
||||
element.scrollTop = chat_messages.scrollTopMax - bottom;
|
||||
track_scroll(element);
|
||||
}
|
||||
}
|
||||
const resize = new ResizeObserver(peg_bottom);
|
||||
resize.observe(chat_messages);
|
||||
chat_messages.addEventListener("scroll", (event) => {
|
||||
track_scroll(chat_messages);
|
||||
});
|
||||
track_scroll(chat_messages);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
:root {
|
||||
--text-color: white;
|
||||
--text-color: #ddd;
|
||||
|
||||
--main-bg-color: #18181a;
|
||||
--chat-bg-color: #232327;
|
||||
|
@ -40,7 +40,7 @@ a {
|
|||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
border: 1px solid red;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
noscript {
|
||||
|
@ -83,14 +83,13 @@ noscript {
|
|||
#chat__header {
|
||||
text-align: center;
|
||||
padding: 1ch 0;
|
||||
margin-bottom: 1ch;
|
||||
border-bottom: var(--chat-border);
|
||||
}
|
||||
#chat-form_js {
|
||||
display: grid;
|
||||
grid-template: auto var(--button-height) / auto 8ch;
|
||||
grid-gap: 0.75ch;
|
||||
margin: 1ch;
|
||||
grid-template: auto var(--button-height) / auto 5rem;
|
||||
grid-gap: 0.375rem;
|
||||
margin: 0 1ch 1ch 1ch;
|
||||
}
|
||||
#chat-form_js__submit {
|
||||
grid-column: 2 / span 1;
|
||||
|
@ -101,9 +100,10 @@ noscript {
|
|||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
transition: 0.25s;
|
||||
max-height: 16ch;
|
||||
max-height: max(37.5vh, 16ch);
|
||||
min-height: 1.75ch;
|
||||
padding: 1.5ch;
|
||||
height: 6ch;
|
||||
padding: 0.675rem;
|
||||
color: #c3c3c7;
|
||||
resize: vertical;
|
||||
}
|
||||
|
@ -118,13 +118,13 @@ noscript {
|
|||
height: 13ch;
|
||||
}
|
||||
#chat__messages {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
#chat-messages_js {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 1ch;
|
||||
padding: 0 1ch 1ch;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-height: 100%;
|
||||
|
@ -145,8 +145,9 @@ noscript {
|
|||
background-color: #434347;
|
||||
}
|
||||
.chat-message__name {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: bold;
|
||||
color: attr("data-color");
|
||||
/* color: attr("data-color"); */
|
||||
cursor: default;
|
||||
}
|
||||
.chat-message__markup {
|
||||
|
@ -238,7 +239,7 @@ footer {
|
|||
display: grid;
|
||||
border: none;
|
||||
border-left: var(--chat-border);
|
||||
min-height: 100vh;
|
||||
min-height: 100%;
|
||||
}
|
||||
#both:target #info_nojs {
|
||||
height: var(--nojs-info-height);
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<aside id="chat">
|
||||
<header id="chat__header">Stream chat</header>
|
||||
<article id="chat__messages">
|
||||
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token) }}" data-js="false"></iframe></noscript>
|
||||
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token, _anchor='bottom') }}" data-js="false"></iframe></noscript>
|
||||
</article>
|
||||
<section id="chat__form">
|
||||
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_form', token=user.token, _anchor='notice') }}" data-js="false"></iframe></noscript>
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="4">
|
||||
<style>
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
color: #ddd;
|
||||
font-family: sans-serif;
|
||||
font-size: 11pt;
|
||||
}
|
||||
#chat-messages {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 1ch 1ch 0 1ch;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.chat-message {
|
||||
padding: 0.5ch 0.75ch;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
.chat-message:hover {
|
||||
background-color: #434347;
|
||||
}
|
||||
.chat-message__name {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: bold;
|
||||
/* color: attr("data-color"); */
|
||||
cursor: normal;
|
||||
}
|
||||
.chat-message__markup {
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.3125;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<ul id="chat-messages">
|
||||
{% for message in messages | reverse %}
|
||||
<li class="chat-message">
|
||||
{% with user = users[message.token] %}
|
||||
<span class="chat-message__name" style="color:{{ user.color }};" data-id="{{ message.id }}">{{ user.name or get_default_name(user) }}</span>: <span class="chat-message__markup">{{ message.markup }}</span>
|
||||
{% endwith %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div id="bottom"></div>
|
||||
</body>
|
||||
</html>
|
|
@ -5,12 +5,16 @@
|
|||
<style>
|
||||
:root {
|
||||
--link-color: #42a5d7;
|
||||
--padding: 1ch;
|
||||
--padding-size: 1ch;
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
height: 13ch;
|
||||
color: white;
|
||||
height: 100%;
|
||||
color: #ddd;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
a {
|
||||
color: var(--link-color);
|
||||
|
@ -32,10 +36,13 @@
|
|||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.x {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
#notice {
|
||||
display: grid;
|
||||
padding: var(--padding);
|
||||
padding: 0 var(--padding-size);
|
||||
text-align: center;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
|
@ -44,21 +51,28 @@
|
|||
align-items: center;
|
||||
}
|
||||
#notice h1 {
|
||||
font-size: 18pt;
|
||||
margin: 0;
|
||||
font-size: 18pt;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
#chat-form, #appearance-form {
|
||||
padding: var(--padding);
|
||||
padding: 0 var(--padding-size) var(--padding-size) var(--padding-size);
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
grid-gap: 0.75ch;
|
||||
grid-gap: 0.375rem;
|
||||
}
|
||||
#chat-form__exit,
|
||||
#appearance-form__exit,
|
||||
#appearance-form__label-name,
|
||||
#appearance-form__label-password {
|
||||
font-size: 11pt;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
#chat-form {
|
||||
display: grid;
|
||||
grid: auto 2rem / auto 8ch;
|
||||
grid-gap: 0.75ch;
|
||||
grid: auto 2rem / auto 5rem;
|
||||
}
|
||||
#chat-form__comment {
|
||||
resize: none;
|
||||
|
@ -67,7 +81,7 @@
|
|||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
transition: 0.25s;
|
||||
padding: 1.5ch;
|
||||
padding: 0.675rem;
|
||||
color: #c3c3c7;
|
||||
}
|
||||
#chat-form__comment:not(:focus):hover {
|
||||
|
@ -91,7 +105,16 @@
|
|||
#appearance-form__buttons {
|
||||
grid-column: 1 / span 3;
|
||||
display: grid;
|
||||
grid-template-columns: auto 8ch;
|
||||
grid-template-columns: auto 5rem;
|
||||
}
|
||||
#password-column {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
grid-gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
#appearance-form label:not(.tripcode):not(.x) {
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
#password-toggle,
|
||||
|
@ -164,29 +187,29 @@
|
|||
<input type="hidden" name="nonce" value="{{ nonce }}">
|
||||
<textarea id="chat-form__comment" name="comment" placeholder="Send a message..." required rows="1" tabindex="1"></textarea>
|
||||
<div id="chat-form__exit"><a href="#appearance">Settings</a></div>
|
||||
<input id="chat-form__submit" type="submit" value="Chat" tabindex="2">
|
||||
<input id="chat-form__submit" type="submit" value="Chat" tabindex="2" accesskey="p">
|
||||
</form>
|
||||
<form id="appearance-form" action="/{{ url_for('nojs_submit_appearance', token=user.token) }}" method="post">
|
||||
<label for="appearance-form__name">Name:</label>
|
||||
<label id="appearance-form__label-name" for="appearance-form__name">Name:</label>
|
||||
<input id="appearance-form__name" name="name" value="{{ user.name or '' }}" placeholder="{{ user.name or default_name }}">
|
||||
<label id="appearance-form__label-password" for="appearance-form__password">Tripcode:</label>
|
||||
<input id="password-toggle" name="set-tripcode" type="checkbox" accesskey="s">
|
||||
<input id="cleared-toggle" name="clear-tripcode" type="checkbox" accesskey="c">
|
||||
<input id="cleared-toggle" name="clear-tripcode" type="checkbox"{% if user.tripcode != none %} accesskey="c"{% endif %}>
|
||||
<div id="password-column">
|
||||
{% if user.tripcode == none %}
|
||||
<span class="tripcode">(no tripcode)</span>
|
||||
<label for="password-toggle" class="show-password pseudolink">set</label>
|
||||
{% else %}
|
||||
<label id="tripcode" for="password-toggle" class="show-password tripcode" style="background-color:{{ user.tripcode.background }};color:{{ user.tripcode.foreground }};">{{ user.tripcode.digest }}digest</label>
|
||||
<label id="show-cleared" for="cleared-toggle" class="pseudolink">✗</label>
|
||||
<label id="show-cleared" for="cleared-toggle" class="pseudolink x">✗</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)">
|
||||
<label id="hide-password" for="password-toggle" class="pseudolink">✗</label>
|
||||
<label id="hide-password" for="password-toggle" class="pseudolink x">✗</label>
|
||||
<div id="appearance-form__buttons">
|
||||
<div><a href="#chat">Return to chat</a></div>
|
||||
<div id="appearance-form__exit"><a href="#chat">Return to chat</a></div>
|
||||
<input type="submit" value="Update">
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -6,9 +6,10 @@
|
|||
body {
|
||||
overflow-y: scroll;
|
||||
margin: 1ch 1.5ch;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
#title {
|
||||
color: white;
|
||||
color: #ddd;
|
||||
font-size: 18pt;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import time
|
||||
from math import inf
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.wrappers import with_timestamp, with_first_argument
|
||||
from anonstream.utils.users import user_for_websocket
|
||||
from anonstream.utils import listmap
|
||||
|
||||
def get_default_name(user):
|
||||
return (
|
||||
current_app.config['DEFAULT_HOST_NAME']
|
||||
|
@ -22,3 +27,56 @@ def pop_notice(user, notice_id):
|
|||
except KeyError:
|
||||
notice = None
|
||||
return notice
|
||||
|
||||
def see(user):
|
||||
user['seen']['last'] = int(time.time())
|
||||
|
||||
@with_timestamp
|
||||
def users_for_websocket(timestamp, messages, users):
|
||||
visible_users = filter(
|
||||
lambda user: is_visible(timestamp, messages, user),
|
||||
users.values(),
|
||||
)
|
||||
return {
|
||||
user['token_hash']: user_for_websocket(user, include_token_hash=False)
|
||||
for user in visible_users
|
||||
}
|
||||
|
||||
def is_watching(timestamp, user):
|
||||
return user['watching_last'] >= timestamp - current_app.config['THRESHOLD_IDLE']
|
||||
|
||||
def is_idle(timestamp, user):
|
||||
return is_present(timestamp, user) and not is_watching(timestamp, user)
|
||||
|
||||
def is_present(timestamp, user):
|
||||
return user['seen']['last'] >= timestamp - current_app.config['THRESHOLD_ABSENT']
|
||||
|
||||
def is_absent(timestamp, user):
|
||||
return not is_present(timestamp, user)
|
||||
|
||||
def is_visible(timestamp, messages, user):
|
||||
has_visible_messages = any(
|
||||
message['token'] == user['token'] for message in messages
|
||||
)
|
||||
return has_visible_messages or is_present(timestamp, user)
|
||||
|
||||
last_checkup = -inf
|
||||
|
||||
def sunset(messages, users):
|
||||
global last_checkup
|
||||
|
||||
timestamp = int(time.time())
|
||||
if timestamp - last_checkup < current_app.config['USER_CHECKUP_PERIOD']:
|
||||
return []
|
||||
|
||||
to_delete = []
|
||||
for token in users:
|
||||
user = users[token]
|
||||
if not is_visible(timestamp, messages, user):
|
||||
to_delete.append(token)
|
||||
|
||||
for index, token in enumerate(to_delete):
|
||||
to_delete[index] = users.pop(token)['token_hash']
|
||||
|
||||
last_checkup = timestamp
|
||||
return to_delete
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
def listmap(*args, **kwargs):
|
||||
return list(map(*args, **kwargs))
|
|
@ -8,13 +8,16 @@ class NonceReuse(Exception):
|
|||
def generate_nonce():
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
def generate_message_id(secret, nonce):
|
||||
parts = secret + b'message-id\0' + nonce.encode()
|
||||
digest = hashlib.sha256(parts).digest()
|
||||
return base64.urlsafe_b64encode(digest)[:22].decode()
|
||||
def generate_nonce_hash(secret, nonce):
|
||||
parts = secret + b'nonce-hash\0' + nonce.encode()
|
||||
return hashlib.sha256(parts).digest()
|
||||
|
||||
def create_message(message_ids, secret, nonce, comment):
|
||||
message_id = generate_message_id(secret, nonce)
|
||||
if message_id in message_ids:
|
||||
raise NonceReuse
|
||||
return message_id, nonce, comment
|
||||
def message_for_websocket(users, message):
|
||||
message_keys = ('id', 'date', 'time_minutes', 'time_seconds', 'markup')
|
||||
user_keys = ('token_hash',)
|
||||
|
||||
user = users[message['token']]
|
||||
return {
|
||||
**{key: message[key] for key in message_keys},
|
||||
**{key: user[key] for key in user_keys},
|
||||
}
|
||||
|
|
|
@ -1,18 +1,35 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
from collections import OrderedDict
|
||||
from math import inf
|
||||
|
||||
def generate_token():
|
||||
return secrets.token_hex(16)
|
||||
|
||||
def generate_user(token, broadcaster, timestamp):
|
||||
def generate_token_hash(secret, token):
|
||||
parts = secret + b'token-hash\0' + token.encode()
|
||||
digest = hashlib.sha256(parts).digest()
|
||||
return base64.b32encode(digest)[:26].lower().decode()
|
||||
|
||||
def generate_user(secret, token, broadcaster, timestamp):
|
||||
return {
|
||||
'token': token,
|
||||
'token_hash': generate_token_hash(secret, token),
|
||||
'broadcaster': broadcaster,
|
||||
'name': None,
|
||||
'color': '#c7007f',
|
||||
'tripcode': None,
|
||||
'notices': OrderedDict(),
|
||||
'seen': {
|
||||
'first': timestamp,
|
||||
'last': timestamp,
|
||||
},
|
||||
'watching_last': -inf,
|
||||
}
|
||||
|
||||
def user_for_websocket(user, include_token_hash=True):
|
||||
keys = ['broadcaster', 'name', 'color', 'tripcode']
|
||||
if include_token_hash:
|
||||
keys.append('token_hash')
|
||||
return {key: user[key] for key in keys}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
from anonstream.utils.chat import create_message, NonceReuse
|
||||
|
||||
class Malformed(Exception):
|
||||
pass
|
||||
|
||||
def parse_websocket_data(message_ids, secret, receipt):
|
||||
def parse_websocket_data(receipt):
|
||||
if not isinstance(receipt, dict):
|
||||
raise Malformed('not a json object')
|
||||
|
||||
|
@ -15,9 +13,4 @@ def parse_websocket_data(message_ids, secret, receipt):
|
|||
if not isinstance(nonce, str):
|
||||
raise Malformed('malformed nonce')
|
||||
|
||||
try:
|
||||
message = create_message(message_ids, secret, nonce, comment)
|
||||
except NonceReuse:
|
||||
raise Malformed('nonce already used')
|
||||
|
||||
return message
|
||||
return nonce, comment
|
||||
|
|
|
@ -4,27 +4,36 @@ from quart import websocket
|
|||
|
||||
from anonstream.stream import get_stream_title, get_stream_uptime
|
||||
from anonstream.chat import broadcast, add_chat_message, Rejected
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
from anonstream.utils.websocket import parse_websocket_data
|
||||
from anonstream.users import is_present, users_for_websocket, see
|
||||
from anonstream.wrappers import with_first_argument
|
||||
from anonstream.utils import listmap
|
||||
from anonstream.utils.chat import generate_nonce, message_for_websocket
|
||||
from anonstream.utils.websocket import parse_websocket_data, Malformed
|
||||
|
||||
async def websocket_outbound(queue):
|
||||
async def websocket_outbound(queue, messages, users, default_host_name, default_anon_name):
|
||||
payload = {
|
||||
'type': 'init',
|
||||
'nonce': generate_nonce(),
|
||||
'title': get_stream_title(),
|
||||
'uptime': get_stream_uptime(),
|
||||
'chat': [],
|
||||
'chat': listmap(
|
||||
with_first_argument(users)(message_for_websocket),
|
||||
messages,
|
||||
),
|
||||
'users': users_for_websocket(messages, users),
|
||||
'default': {True: default_host_name, False: default_anon_name},
|
||||
}
|
||||
await websocket.send_json(payload)
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
await websocket.send_json(payload)
|
||||
|
||||
async def websocket_inbound(queue, connected_websockets, token, secret, chat):
|
||||
async def websocket_inbound(queue, chat, users, connected_websockets, user, secret):
|
||||
while True:
|
||||
receipt = await websocket.receive_json()
|
||||
see(user)
|
||||
try:
|
||||
message_id, nonce, comment = parse_websocket_data(chat.keys(), secret, receipt)
|
||||
nonce, comment = parse_websocket_data(receipt)
|
||||
except Malformed as e:
|
||||
error , *_ = e.args
|
||||
payload = {
|
||||
|
@ -35,10 +44,12 @@ async def websocket_inbound(queue, connected_websockets, token, secret, chat):
|
|||
try:
|
||||
markup = await add_chat_message(
|
||||
chat,
|
||||
users,
|
||||
connected_websockets,
|
||||
token,
|
||||
message_id,
|
||||
comment
|
||||
secret,
|
||||
user,
|
||||
nonce,
|
||||
comment,
|
||||
)
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
|
|
|
@ -1,55 +1,20 @@
|
|||
import time
|
||||
from functools import wraps
|
||||
|
||||
from quart import current_app, request, abort, make_response
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from anonstream.utils.users import generate_token, generate_user
|
||||
|
||||
def check_auth(context):
|
||||
auth = context.authorization
|
||||
return (
|
||||
auth is not None
|
||||
and auth.type == "basic"
|
||||
and auth.username == current_app.config["AUTH_USERNAME"]
|
||||
and check_password_hash(current_app.config["AUTH_PWHASH"], auth.password)
|
||||
)
|
||||
|
||||
def auth_required(f):
|
||||
def with_timestamp(f):
|
||||
@wraps(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>'
|
||||
)
|
||||
return body, 401, {'WWW-Authenticate': 'Basic'}
|
||||
def wrapper(*args, **kwargs):
|
||||
timestamp = int(time.time())
|
||||
return f(timestamp, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
def with_user_from(context):
|
||||
def with_user_from_context(f):
|
||||
def with_first_argument(x):
|
||||
def with_x(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
broadcaster = check_auth(context)
|
||||
if broadcaster:
|
||||
token = current_app.config['AUTH_TOKEN']
|
||||
else:
|
||||
token = context.args.get('token') or generate_token()
|
||||
timestamp = int(time.time())
|
||||
user = current_app.users.get(token)
|
||||
if user is not None:
|
||||
user['seen']['last'] = timestamp
|
||||
else:
|
||||
user = generate_user(token, broadcaster, timestamp)
|
||||
current_app.users[token] = user
|
||||
return await f(user, *args, **kwargs)
|
||||
def wrapper(*args, **kwargs):
|
||||
return f(x, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return with_user_from_context
|
||||
return with_x
|
||||
|
|
10
config.toml
10
config.toml
|
@ -12,3 +12,13 @@ anonymous = "Anonymous"
|
|||
|
||||
[limits]
|
||||
notices = 32
|
||||
chat_storage = 8192
|
||||
chat_scrollback = 256
|
||||
|
||||
[ratelimits]
|
||||
user_absence = 8
|
||||
captcha_expiry = 8
|
||||
|
||||
[thresholds]
|
||||
idle = 12
|
||||
absent = 1800
|
||||
|
|
読み込み中…
新しいイシューから参照