コミットを比較

...

10 コミット

作成者 SHA1 メッセージ 日付
n9k 2b07354e5c Add Content Security Policy meta tags 2022-03-07 20:12:12 +13:00
n9k 9b13526f7c Nojs chat form: more accesskeys 2022-03-07 20:07:32 +13:00
n9k 60021851c6 Add js appearance form (not complete c.f. nojs) 2022-03-07 19:43:33 +13:00
n9k 50578e2eb3 Fix js memory leak
Already existing tripcode css rules were being re-inserted because of a typo.
2022-03-07 19:43:33 +13:00
n9k 984ae8ca5f Nojs chat form: use `:checked` instead of `:target`
This works around a bug in mobile Firefox where under certain cirucmstances two
elements inside an iframe both become the iframe's target elment at the same
time, which breaks the CSS logic so instead of exactly one form being displayed,
nothing is displayed.
2022-03-07 19:43:33 +13:00
n9k c904c51d03 Keyboard accessible js captcha 2022-03-07 19:43:33 +13:00
n9k 30d95a0c7d Autofocus chat form textarea 2022-03-07 19:43:33 +13:00
n9k 48afc6d6fa Show notice from websocket in js chat form 2022-03-07 19:43:33 +13:00
n9k a0fd481502 Add config option for old tripcode algorithm 2022-03-07 19:43:33 +13:00
n9k bb2bc44629 Add broadcaster insignia
Also surrounded users' name tags in <b> (HTML) tags.
2022-03-07 19:43:31 +13:00
21個のファイルの変更469行の追加118行の削除

ファイルの表示

@ -9,3 +9,7 @@ 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
## Credits
* [/anonstream/static/settings.svg](https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/anonstream/static/settings.svg): [setting](https://thenounproject.com/icon/setting-685325/) by [ulimicon](https://thenounproject.com/unlimicon/) is licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).

ファイルの表示

@ -25,6 +25,7 @@ def create_app(config_file):
'lstrip_blocks': True,
})
app.config.update({
'SECRET_KEY_STRING': config['secret_key'],
'SECRET_KEY': config['secret_key'].encode(),
'AUTH_USERNAME': config['auth']['username'],
'AUTH_PWHASH': auth_pwhash,
@ -56,6 +57,7 @@ def create_app(config_file):
'CHAT_NAME_MAX_LENGTH': config['chat']['max_name_length'],
'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'],
'CAPTCHA_LIFETIME': config['captcha']['lifetime'],

ファイルの表示

@ -11,19 +11,27 @@ CONFIG = current_app.config
def _generate_tripcode_digest_legacy(password):
hexdigest, _ = werkzeug.security._hash_internal(
'pbkdf2:sha256:150000',
CONFIG['SECRET_KEY'],
CONFIG['SECRET_KEY_STRING'],
password,
)
digest = bytes.fromhex(hexdigest)
return base64.b64encode(digest)[:8].decode()
def generate_tripcode_digest(password):
def _generate_tripcode_digest(password):
parts = CONFIG['SECRET_KEY'] + b'tripcode\0' + password.encode()
digest = hashlib.sha256(parts).digest()
return base64.b64encode(digest)[:8].decode()
def generate_tripcode(password, generate_digest=generate_tripcode_digest):
digest = generate_digest(password)
def generate_tripcode_digest(password):
algorithm = (
_generate_tripcode_digest_legacy
if CONFIG['CHAT_LEGACY_TRIPCODE_ALGORITHM'] else
_generate_tripcode_digest
)
return algorithm(password)
def generate_tripcode(password):
digest = generate_tripcode_digest(password)
background_colour = generate_colour(
seed='tripcode-background\0' + digest,
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],

ファイルの表示

@ -5,11 +5,16 @@ from anonstream.segments import segments
from anonstream.stream import is_online, get_stream_uptime
from anonstream.user import watched
from anonstream.routes.wrappers import with_user_from, auth_required
from anonstream.utils.security import generate_csp
@current_app.route('/')
@with_user_from(request)
async def home(user):
return await render_template('home.html', user=user)
return await render_template(
'home.html',
csp=generate_csp(),
user=user,
)
@current_app.route('/stream.mp4')
@with_user_from(request)

ファイルの表示

@ -8,6 +8,7 @@ from anonstream.routes.wrappers import with_user_from, render_template_with_etag
from anonstream.helpers.chat import get_scrollback
from anonstream.helpers.user import get_default_name
from anonstream.utils.chat import generate_nonce
from anonstream.utils.security import generate_csp
from anonstream.utils.user import concatenate_for_notice
CONFIG = current_app.config
@ -18,6 +19,7 @@ USERS_BY_TOKEN = current_app.users_by_token
async def nojs_stream(user):
return await render_template(
'nojs_stream.html',
csp=generate_csp(),
user=user,
)
@ -28,6 +30,7 @@ async def nojs_info(user):
uptime, viewership = get_stream_uptime_and_viewership()
return await render_template(
'nojs_info.html',
csp=generate_csp(),
user=user,
viewership=viewership,
uptime=uptime,
@ -40,6 +43,7 @@ async def nojs_info(user):
async def nojs_chat_messages(user):
return await render_template_with_etag(
'nojs_chat_messages.html',
{'csp': generate_csp()},
user=user,
users_by_token=USERS_BY_TOKEN,
messages=get_scrollback(current_app.messages),
@ -58,6 +62,7 @@ async def nojs_chat_users(user):
users_by_presence = get_users_by_presence()
return await render_template_with_etag(
'nojs_chat_users.html',
{'csp': generate_csp()},
user=user,
get_default_name=get_default_name,
users_watching=users_by_presence[Presence.WATCHING],
@ -73,6 +78,7 @@ async def nojs_chat_form(user):
prefer_chat_form = request.args.get('landing') != 'appearance'
return await render_template(
'nojs_chat_form.html',
csp=generate_csp(),
user=user,
state=state,
prefer_chat_form=prefer_chat_form,
@ -141,7 +147,7 @@ async def nojs_submit_appearance(user):
# Collect form data
name = form.get('name', '').strip()
if len(name) == 0 or name == get_default_name(user):
if len(name) == 0:
name = None
color = form.get('color', '')

ファイルの表示

@ -86,11 +86,16 @@ def with_user_from(context):
return with_user_from_context
async def render_template_with_etag(*args, **kwargs):
rendered_template = await render_template(*args, **kwargs)
tag = hashlib.sha256(rendered_template.encode()).hexdigest()
async def render_template_with_etag(template, deferred_kwargs, **kwargs):
render = await render_template(template, **kwargs)
tag = hashlib.sha256(render.encode()).hexdigest()
etag = f'W/"{tag}"'
if request.if_none_match.contains_weak(tag):
return '', 304, {'ETag': etag}
else:
rendered_template = await render_template(
template,
**deferred_kwargs,
**kwargs,
)
return rendered_template, {'ETag': etag}

ファイルの表示

@ -2,10 +2,10 @@
const TOKEN = document.body.dataset.token;
const TOKEN_HASH = document.body.dataset.tokenHash;
/* Content Security Policy nonce */
const CSP = document.body.dataset.csp;
/* insert js-only markup */
const jsmarkup_style_color = '<style id="style-color"></style>'
const jsmarkup_style_tripcode_display = '<style id="style-tripcode-display"></style>'
const jsmarkup_style_tripcode_colors = '<style id="style-tripcode-colors"></style>'
const jsmarkup_stream = `<video id="stream_js" src="/stream.mp4?token=${encodeURIComponent(TOKEN)}" autoplay controls></video>`
const jsmarkup_info = '<div id="info_js" data-js="true"></div>';
const jsmarkup_info_float = '<aside id="info_js__float"></aside>';
@ -25,29 +25,56 @@ const jsmarkup_chat_users = `\
const jsmarkup_chat_form = `\
<form id="chat-form_js" data-js="true" action="/chat" method="post">
<input id="chat-form_js__nonce" type="hidden" name="nonce" value="">
<textarea id="chat-form_js__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1"></textarea>
<textarea id="chat-form_js__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1" autofocus></textarea>
<div id="chat-live">
<span id="chat-live__ball"></span>
<span id="chat-live__status"><span>Not connected<span data-verbose='true'> to chat</span></span></span>
<span id="chat-live__status">
<span data-verbose="true">Not connected to chat</span>
<span data-verbose="false">&times;</span>
</span>
</div>
<input id="chat-form_js__captcha-digest" type="hidden" name="captcha-digest" disabled>
<img id="chat-form_js__captcha-image" width="72" height="30">
<input id="chat-form_js__captcha-image" type="image" width="72" height="30">
<input id="chat-form_js__captcha-answer" name="captcha-answer" placeholder="Captcha" disabled>
<input id="chat-form_js__settings" type="image" src="/static/settings.svg" width="28" height="28" alt="Settings">
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
<article id="chat-form_js__notice">
<button id="chat-form_js__notice__button" type="button">
<header id="chat-form_js__notice__button__header"></header>
<small>Click to dismiss</small>
</button>
</article>
</form>
<form id="appearance-form_js" data-hidden="">
<span id="appearance-form_js__label-name">Name:</span>
<input id="appearance-form_js__name" name="name" maxlength="24">
<input id="appearance-form_js__color" type="color" name="color">
<span id="appearance-form_js__label-tripcode">Tripcode:</span>
<input id="appearance-form_js__password" type="password" name="password" placeholder="(tripcode password)" maxlength="1024">
<div id="appearance-form_js__row">
<article id="appearance-form_js__row__result"></article>
<input id="appearance-form_js__row__submit" type="submit" value="Update">
</div>
</form>`;
const insert_jsmarkup = () => {jsmarkup_info_float_viewership
const insert_jsmarkup = () => {
if (document.getElementById("style-color") === null) {
const parent = document.head;
parent.insertAdjacentHTML("beforeend", jsmarkup_style_color);
const style_color = document.createElement("style");
style_color.id = "style-color";
style_color.nonce = CSP;
document.head.insertAdjacentElement("beforeend", style_color);
}
if (document.getElementById("style-tripcode-display") === null) {
const parent = document.head;
parent.insertAdjacentHTML("beforeend", jsmarkup_style_tripcode_display);
const style_tripcode_display = document.createElement("style");
style_tripcode_display.id = "style-tripcode-display";
style_tripcode_display.nonce = CSP;
document.head.insertAdjacentElement("beforeend", style_tripcode_display);
}
if (document.getElementById("style-tripcode-colors") === null) {
const parent = document.head;
parent.insertAdjacentHTML("beforeend", jsmarkup_style_tripcode_colors);
const style_tripcode_colors = document.createElement("style");
style_tripcode_colors.id = "style-tripcode-colors";
style_tripcode_colors.nonce = CSP;
document.head.insertAdjacentElement("beforeend", style_tripcode_colors);
}
if (document.getElementById("stream_js") === null) {
const parent = document.getElementById("stream");
@ -96,6 +123,43 @@ const stylesheet_color = document.styleSheets[1];
const stylesheet_tripcode_display = document.styleSheets[2];
const stylesheet_tripcode_colors = document.styleSheets[3];
/* override chat form notice button */
const chat_form = document.getElementById("chat-form_js");
const chat_form_notice_button = document.getElementById("chat-form_js__notice__button");
const chat_form_notice_header = document.getElementById("chat-form_js__notice__button__header");
chat_form_notice_button.addEventListener("click", (event) => {
chat_form.removeAttribute("data-notice");
chat_form_notice_header.innerText = "";
});
const show_notice = (text) => {
chat_form_notice_header.innerText = text;
chat_form.dataset.notice = "";
}
/* override chat form settings input */
const chat_appearance_form = document.getElementById("appearance-form_js");
const chat_appearance_form_result = document.getElementById("appearance-form_js__row__result");
const chat_form_settings = document.getElementById("chat-form_js__settings");
chat_form_settings.addEventListener("click", (event) => {
event.preventDefault();
if (chat_appearance_form.dataset.hidden === undefined) {
chat_appearance_form.dataset.hidden = "";
chat_form_settings.style.backgroundColor = "";
chat_appearance_form_result.innerText = "";
if (!chat_appearance_form_submit.disabled) {
chat_appearance_form.reset();
}
} else {
chat_appearance_form.removeAttribute("data-hidden");
chat_form_settings.style.backgroundColor = "#4f4f53";
}
});
/* appearance form */
const chat_appearance_form_name = document.getElementById("appearance-form_js__name");
const chat_appearance_form_color = document.getElementById("appearance-form_js__color");
const chat_appearance_form_password = document.getElementById("appearance-form_js__password");
/* create websocket */
const info_title = document.getElementById("info_js__title");
const info_viewership = document.getElementById("info_js__float__viewership");
@ -120,11 +184,7 @@ const create_chat_message = (object) => {
chat_message_time.title = `${object.date} ${object.time_seconds}`;
chat_message_time.innerText = object.time_minutes;
const [
chat_message_name,
chat_message_tripcode_nbsp,
chat_message_tripcode,
] = create_chat_user_components(user);
const chat_message_user_components = create_chat_user_components(user);
const chat_message_markup = document.createElement("span");
chat_message_markup.classList.add("chat-message__markup");
@ -132,9 +192,9 @@ const create_chat_message = (object) => {
chat_message.insertAdjacentElement("beforeend", chat_message_time);
chat_message.insertAdjacentHTML("beforeend", "&nbsp;");
chat_message.insertAdjacentElement("beforeend", chat_message_name);
chat_message.insertAdjacentElement("beforeend", chat_message_tripcode_nbsp);
chat_message.insertAdjacentElement("beforeend", chat_message_tripcode);
for (const chat_message_user_component of chat_message_user_components) {
chat_message.insertAdjacentElement("beforeend", chat_message_user_component);
}
chat_message.insertAdjacentHTML("beforeend", ": ");
chat_message.insertAdjacentElement("beforeend", chat_message_markup);
@ -146,9 +206,11 @@ const create_chat_user_name = (user) => {
chat_user_name.innerText = get_user_name({user});
//chat_user_name.dataset.color = user.color; // not working in any browser
if (!user.broadcaster && user.name === null) {
const b = document.createElement("b");
b.innerText = user.tag;
const chat_user_name_tag = document.createElement("sup");
chat_user_name_tag.classList.add("chat-name__tag");
chat_user_name_tag.innerText = user.tag;
chat_user_name_tag.innerHTML = b.outerHTML;
chat_user_name.insertAdjacentElement("beforeend", chat_user_name_tag);
}
return chat_user_name;
@ -167,7 +229,20 @@ const create_chat_user_components = (user) => {
chat_user_tripcode.innerHTML = user.tripcode.digest;
}
return [chat_user_name, chat_user_tripcode_nbsp, chat_user_tripcode];
let result;
if (!user.broadcaster) {
result = [];
} else {
const chat_user_insignia = document.createElement("b");
chat_user_insignia.classList.add("chat-insignia")
chat_user_insignia.title = "Broadcaster";
chat_user_insignia.innerText = "##";
const chat_user_insignia_nbsp = document.createElement("span");
chat_user_insignia_nbsp.innerHTML = "&nbsp;"
result = [chat_user_insignia, chat_user_insignia_nbsp];
}
result.push(...[chat_user_name, chat_user_tripcode_nbsp, chat_user_tripcode]);
return result;
}
const create_and_add_chat_message = (object) => {
const chat_message = create_chat_message(object);
@ -283,26 +358,26 @@ const update_user_tripcodes = (token_hash=null) => {
for (const this_token_hash of token_hashes) {
const tripcode = users[this_token_hash].tripcode;
if (tripcode === null) {
if (!to_ignore_display.has(token_hash)) {
if (!to_ignore_display.has(this_token_hash)) {
stylesheet_tripcode_display.insertRule(
`[data-token-hash="${this_token_hash}"] > .for-tripcode { display: none; }`,
stylesheet_tripcode_display.cssRules.length,
);
}
if (!to_ignore_colors.has(token_hash)) {
if (!to_ignore_colors.has(this_token_hash)) {
stylesheet_tripcode_colors.insertRule(
`[data-token-hash="${this_token_hash}"] > .tripcode { background-color: initial; color: initial; }`,
stylesheet_tripcode_colors.cssRules.length,
);
}
} else {
if (!to_ignore_display.has(token_hash)) {
if (!to_ignore_display.has(this_token_hash)) {
stylesheet_tripcode_display.insertRule(
`[data-token-hash="${this_token_hash}"] > .for-tripcode { display: inline; }`,
stylesheet_tripcode_display.cssRules.length,
);
}
if (!to_ignore_colors.has(token_hash)) {
if (!to_ignore_colors.has(this_token_hash)) {
stylesheet_tripcode_colors.insertRule(
`[data-token-hash="${this_token_hash}"] > .tripcode { background-color: ${tripcode.background_color}; color: ${tripcode.foreground_color}; }`,
stylesheet_tripcode_colors.cssRules.length,
@ -349,16 +424,16 @@ chat_form_captcha_image.addEventListener("error", (event) => {
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;
event.preventDefault();
if (chat_form_captcha_image.dataset.reloadable !== undefined) {
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));
}
chat_form_submit.disabled = true;
chat_form_captcha_image.alt = "Waiting...";
chat_form_captcha_image.removeAttribute("title");
chat_form_captcha_image.removeAttribute("data-reloadable");
chat_form_captcha_image.removeAttribute("src");
const payload = {type: "captcha"};
ws.send(JSON.stringify(payload));
});
const enable_captcha = (digest) => {
chat_form_captcha_digest.value = digest;
@ -386,9 +461,9 @@ const disable_captcha = () => {
}
const set_title = (title) => {
const element = document.createElement("h1");
element.innerText = title.replaceAll(/\r?\n/g, " ");
info_title.innerHTML = element.outerHTML;
const h1 = document.createElement("h1");
h1.innerText = title.replaceAll(/\r?\n/g, " ");
info_title.innerHTML = h1.outerHTML;
}
const update_uptime = () => {
@ -493,6 +568,7 @@ const on_websocket_message = (event) => {
case "error":
console.log("ws error", receipt);
chat_form_submit.disabled = false;
chat_appearance_form_submit.disabled = false;
break;
case "init":
@ -546,6 +622,14 @@ const on_websocket_message = (event) => {
update_user_tripcodes();
update_users_list()
// appearance form default values
const user = users[TOKEN_HASH];
if (user.name !== null) {
chat_appearance_form_name.setAttribute("value", user.name);
}
chat_appearance_form_name.setAttribute("placeholder", default_name[user.broadcaster]);
chat_appearance_form_color.setAttribute("value", user.color);
// insert new messages
const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1];
const last_seq = last === null ? null : parseInt(last.dataset.seq);
@ -583,6 +667,11 @@ const on_websocket_message = (event) => {
case "ack":
console.log("ws ack", receipt);
if (receipt.notice !== null) {
show_notice(receipt.notice);
}
const existing_nonce = chat_form_nonce.value;
if (receipt.clear && receipt.nonce === existing_nonce) {
chat_form_comment.value = "";
@ -592,6 +681,7 @@ const on_websocket_message = (event) => {
chat_form_nonce.value = receipt.next;
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
chat_form_submit.disabled = false;
break;
case "message":
@ -630,6 +720,41 @@ const on_websocket_message = (event) => {
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
break;
case "appearance":
console.log("ws appearance", receipt);
if (receipt.errors === undefined) {
if (receipt.name !== null) {
chat_appearance_form_name.setAttribute("value", receipt.name);
}
chat_appearance_form_color.setAttribute("value", receipt.color);
chat_appearance_form_result.innerHTML = receipt.result;
} else {
const ul = document.createElement("ul");
for (const error of receipt.errors) {
const li = document.createElement("li");
li.innerText = error[0];
for (const tuple of error.slice(1)) {
const mark = document.createElement("mark");
mark.innerText = tuple[0];
li.insertAdjacentText("beforeend", " ");
li.insertAdjacentElement("beforeend", mark);
li.insertAdjacentText("beforeend", tuple[1]);
}
ul.insertAdjacentElement("beforeend", li);
}
const result = document.createElement("div");
result.innerText = "Errors:";
result.insertAdjacentElement("beforeend", ul);
chat_appearance_form_result.innerHTML = result.innerHTML;
}
chat_appearance_form_submit.disabled = false;
chat_appearance_form.removeAttribute("data-hidden");
chat_form_settings.style.backgroundColor = "#4f4f53";
break;
default:
console.log("incomprehensible websocket message", receipt);
}
@ -644,13 +769,13 @@ const connect_websocket = () => {
return;
}
chat_live_ball.style.borderColor = "gold";
chat_live_status.innerHTML = "<span data-verbose='false'>Waiting...</span> <span data-verbose='true'>Connecting to chat...</span>";
chat_live_status.innerHTML = "<span data-verbose='true'>Connecting to chat...</span><span data-verbose='false'>&middot;&middot;&middot;</span>";
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.innerHTML = "<span>Connected<span data-verbose='true'> to chat</span></span>";
chat_live_status.innerHTML = "<span><span data-verbose='true'>Connected to chat</span><span data-verbose='false'>&check;</span></span>";
// 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.
@ -666,7 +791,7 @@ const connect_websocket = () => {
console.log("websocket close", event);
chat_form_submit.disabled = true;
chat_live_ball.style.borderColor = "maroon";
chat_live_status.innerHTML = "<span data-verbose='false'>Failed to connect</span> <span data-verbose='true'>Disconnected from chat</span>";
chat_live_status.innerHTML = "<span data-verbose='true'>Disconnected from chat</span><span data-verbose='false'>&times;</span>";
if (!ws.successor) {
ws.successor = true;
setTimeout(connect_websocket, websocket_backoff);
@ -698,7 +823,6 @@ stream.addEventListener("error", (event) => {
});
/* override js-only chat form */
const chat_form = document.getElementById("chat-form_js");
const chat_form_nonce = document.getElementById("chat-form_js__nonce");
const chat_form_comment = document.getElementById("chat-form_js__comment");
const chat_form_submit = document.getElementById("chat-form_js__submit");
@ -710,6 +834,18 @@ chat_form.addEventListener("submit", (event) => {
ws.send(JSON.stringify(payload));
});
/* override js-only appearance form */
const chat_appearance_form_submit = document.getElementById("appearance-form_js__row__submit");
chat_appearance_form.addEventListener("submit", (event) => {
event.preventDefault();
const form = Object.fromEntries(new FormData(chat_appearance_form));
const payload = {type: "appearance", form: form};
chat_appearance_form_submit.disabled = true;
chat_appearance_form_password.value = "";
chat_appearance_form_result.innerText = "";
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;

4
anonstream/static/settings.svg ノーマルファイル
ファイルの表示

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="243.55pt" height="243.55pt" version="1.1" viewBox="0 0 243.55 243.55" xmlns="http://www.w3.org/2000/svg">
<path d="m104.65 0-8.2461 29.598c-7.6914 2.1172-14.992 5.2305-21.777 9.0898l-27.062-15.012-23.891 23.891 15.012 27.062c-3.8594 6.7852-6.9727 14.086-9.0898 21.777l-29.598 8.2461v34.25l29.598 8.2461c2.1172 7.6914 5.2305 14.992 9.0898 21.777l-15.012 27.062 23.891 23.891 27.062-15.012c6.7852 3.8594 14.086 6.9727 21.777 9.0898l8.2461 29.598h34.25l8.2461-29.598c7.6914-2.1172 14.992-5.2305 21.777-9.0898l27.062 15.012 23.891-23.891-15.012-27.062c3.8594-6.7812 6.9727-14.086 9.0898-21.777l29.598-8.2461v-34.25l-29.598-8.2461c-2.1172-7.6914-5.2305-14.992-9.0898-21.777l15.012-27.062-23.891-23.891-27.062 15.012c-6.7852-3.8594-14.086-6.9727-21.777-9.0898l-8.2461-29.598zm17.125 74.418c26.156 0 47.359 21.203 47.359 47.359s-21.203 47.359-47.359 47.359-47.359-21.203-47.359-47.359 21.203-47.359 47.359-47.359z" fill="#bbbbbf"/>
</svg>

変更後

幅:  |  高さ:  |  サイズ: 984 B

ファイルの表示

@ -107,9 +107,7 @@ noscript {
#chat__toggle {
opacity: 0;
position: absolute;
top: calc(0.5rem + 1px);
left: calc(0.5rem + 4px);
margin: 0;
pointer-events: none;
}
#chat__toggle:checked ~ #chat__body > #chat__body__messages,
#chat__toggle:not(:checked) ~ #chat__body > #chat__body__users {
@ -192,6 +190,9 @@ noscript {
.chat-message__time {
color: #b2b2b3;
font-size: 10pt;
cursor: default;
}
.chat-insignia {
cursor: help;
}
.chat-name {
@ -256,18 +257,19 @@ noscript {
#chat-users_nojs {
height: 100%;
}
#chat__form {
position: relative;
}
#chat-form_js {
display: grid;
grid-template-columns: 1fr min-content min-content 5rem;
grid-template-columns: 1fr min-content min-content min-content 5rem;
grid-template-rows: auto var(--button-height);
grid-gap: 0.375rem;
margin: 0 0.5rem 0.5rem 0.5rem;
}
#chat-form_js__submit {
grid-column: 2 / span 1;
padding: 0 0.5rem 0.5rem 0.5rem;
position: relative;
}
#chat-form_js__comment {
grid-column: 1 / span 4;
grid-column: 1 / span 5;
background-color: #434347;
border-radius: 4px;
border: 2px solid transparent;
@ -296,16 +298,100 @@ noscript {
#chat-form_js__captcha-answer {
width: 8ch;
}
#chat-form_js__submit {
#chat-form_js__settings {
align-self: center;
padding: 5px;
box-sizing: border-box;
border-radius: 3px;
color: var(--text-color);
grid-column: 4;
}
#chat-form_js__settings:hover {
background-color: #434347;
}
#chat-form_js__submit {
grid-column: 5;
}
#chat-form_js:not([data-captcha]) > #chat-form_js__captcha-image,
#chat-form_js:not([data-captcha]) > #chat-form_js__captcha-answer {
display: none;
}
#chat-form_js:not([data-notice]) > #chat-form_js__notice {
display: none;
}
#chat-form_js__notice {
position: absolute;
width: 100%;
background: linear-gradient(#23232700, #2323277f 8%, #232327);
height: 100%;
display: grid;
z-index: 1;
}
#chat-form_js__notice__button {
color: inherit;
border-color: black;
background-color: #232327;
border-radius: 4px;
text-align: center;
margin: auto;
display: grid;
grid-gap: 0.375rem;
padding: 0.625rem 1.25rem;
box-shadow: 0 0 12px black;
cursor: pointer;
}
#chat-form_js__notice__button__header {
font-size: 14pt;
line-height: 1.5;
}
#chat-form_nojs {
height: 13ch;
}
#appearance-form_js {
position: absolute;
bottom: 3rem;
padding: 0.5rem;
margin: 0 1rem;
width: calc(100% - 2rem);
box-sizing: border-box;
background: #343437df;
border: 2px outset #434347;
border-radius: 4px;
display: grid;
grid-template-columns: min-content 1fr min-content;
grid-template-rows: 1fr 1fr auto;
grid-gap: 0.375rem;
}
#appearance-form_js[data-hidden] {
display: none;
}
#appearance-form_js__label-name,
#appearance-form_js__label-tripcode {
align-self: center;
}
#appearance-form_js__name,
#appearance-form_js__password {
min-width: 12ch;
}
#appearance-form_js__row {
grid-column: 1 / span 3;
grid-row: 3;
display: grid;
grid-template-columns: auto 4rem;
align-items: end;
}
#appearance-form_js__row__result {
font-weight: bold;
font-size: 11pt;
}
#appearance-form_js__row__result > ul {
margin: 0;
padding-left: 1.125rem;
font-size: 10pt;
}
#appearance-form_js__row__submit {
min-height: 1.75rem;
}
#chat-live {
position: relative;
font-size: 9pt;

ファイルの表示

@ -3,9 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="content-security-policy" content="default-src 'none'; connect-src 'self'; img-src 'self'; frame-src 'self'; media-src 'self'; script-src 'self'; style-src 'self' 'nonce-{{ csp }}';">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" type="text/css">
</head>
<body id="both" data-token="{{ user.token }}" data-token-hash="{{ user.token_hash }}">
<body id="both" data-token="{{ user.token }}" data-token-hash="{{ user.token_hash }}" data-csp="{{ csp }}">
<article id="stream">
<noscript><iframe id="stream_nojs" name="stream_nojs" src="{{ url_for('nojs_stream', token=user.token) }}"></iframe></noscript>
</article>

ファイルの表示

@ -1,6 +1,7 @@
{%
macro appearance(
user,
insignia_class,
name_class,
tag_class,
tripcode_nbsp_class='for-tripcode',
@ -8,15 +9,19 @@
)
%}
{{- '' -}}
<span class="{{ name_class }}" style="color:{{ user.color }};">
{%- if user.broadcaster -%}
<b class="{{ insignia_class }}" title="Broadcaster">##</b>
{{- '&nbsp;' | safe -}}
{%- endif -%}
<span class="{{ name_class }}">
{{- user.name or get_default_name(user) -}}
{%- if not user.broadcaster and user.name is none -%}
<sup class="{{ tag_class }}">{{ user.tag }}</sup>
<sup class="{{ tag_class }}"><b>{{ user.tag }}</b></sup>
{%- endif -%}
</span>
{%- if user.tripcode -%}
<span class="{{ tripcode_nbsp_class }}">&nbsp;</span>
{{- '' -}}
<span class="{{ tripcode_class }}" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>
<span class="{{ tripcode_class }}">{{ user.tripcode.digest }}</span>
{%- endif -%}
{% endmacro %}

ファイルの表示

@ -3,7 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
<meta http-equiv="content-security-policy" content="default-src 'none'; img-src 'self'; style-src 'nonce-{{ csp }}';">
<style nonce="{{ csp }}">
:root {
--link-color: #42a5d7;
--padding-size: 0.5rem;
@ -41,6 +42,8 @@
padding: 0;
}
#tripcode {
background-color: {{ user.tripcode.background_color }};
color: {{ user.tripcode.foreground_color }};
cursor: pointer;
}
.x {
@ -56,6 +59,7 @@
height: 100%;
box-sizing: border-box;
align-items: center;
cursor: pointer;
}
#notice h2 {
margin: 0;
@ -73,7 +77,7 @@
grid-gap: 0.375rem;
}
#chat-form__exit,
#appearance-form__exit,
#appearance-form__buttons__exit,
#appearance-form__label-name,
#appearance-form__label-password {
font-size: 11pt;
@ -120,6 +124,7 @@
}
#appearance-form {
display: grid;
grid-auto-rows: 1fr 1fr 2rem;
grid-auto-columns: min-content 1fr min-content;
}
@ -172,46 +177,48 @@
display: none;
}
#appearance-form {
#toggle {
opacity: 0;
position: absolute;
pointer-events: none;
}
#chat-form__exit > label,
#appearance-form__buttons__exit > label {
padding: 1px;
}
#toggle:focus-visible ~ #chat-form > #chat-form__exit > label,
#toggle:focus-visible ~ #appearance-form #appearance-form__buttons__exit > label {
padding: 0;
border: 1px dotted;
}
#notice-radio {
display: none;
}
#appearance:target ~ #appearance-form {
display: grid;
}
#appearance:target ~ #chat-form {
#toggle:checked ~ #chat-form,
#toggle:not(:checked) ~ #appearance-form {
display: none;
}
#chat:target ~ #appearance-form {
#notice-radio:checked + #notice,
#notice-radio:not(:checked) ~ #chat-form,
#notice-radio:not(:checked) ~ #appearance-form {
display: none;
}
{% if state.notice %}
#chat-form {
display: none;
}
#chat:target ~ #chat-form {
display: grid;
}
#chat:target ~ #notice,
#appearance:target ~ #notice {
display: none;
}
{% endif %}
</style>
</head>
<body>
<div id="chat"></div>
<div id="appearance"></div>
<input id="toggle" type="checkbox" {% if not prefer_chat_form %}checked {% endif %}accesskey="x">
{% if state.notice %}
<a id="notice" {% if state.verbose %}class="verbose" {% endif %}{% if prefer_chat_form %}href="#chat"{% else %}href="#appearance"{% endif %}>
<input id="notice-radio" type="radio" accesskey="z">
<label id="notice" for="notice-radio"{% if state.verbose %} class="verbose"{% endif %}>
<header><h2>{{ state.notice }}</h2></header>
<small>Click to dismiss</small>
</a>
</label>
{% endif %}
<form id="chat-form" action="{{ url_for('nojs_submit_message', token=user.token) }}" method="post">
<input type="hidden" name="nonce" value="{{ nonce }}">
<textarea id="chat-form__comment" name="comment" maxlength="512" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1">{{ state.comment }}</textarea>
<textarea id="chat-form__comment" name="comment" maxlength="512" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1" autofocus accesskey="m">{{ state.comment }}</textarea>
<input id="chat-form__submit" type="submit" value="Chat" tabindex="4" accesskey="p">
<div id="chat-form__exit"><a href="#appearance">Settings</a></div>
<div id="chat-form__exit"><label for="toggle" class="pseudolink">Settings</label></div>
{% if digest %}
<input type="hidden" name="captcha-digest" value="{{ digest }}">
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_chat_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="Captcha failed to load" title="Click for a new captcha" tabindex="2">
@ -226,11 +233,11 @@
<input id="password-toggle" name="set-tripcode" type="checkbox" accesskey="s">
<input id="cleared-toggle" name="clear-tripcode" type="checkbox"{% if user.tripcode != none %} accesskey="c"{% endif %}>
<div id="password-column">
{% if user.tripcode == none %}
{% if user.tripcode is 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 }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</label>
<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>
<div id="cleared" class="tripcode">(cleared)</div>
<label id="hide-cleared" for="cleared-toggle" class="pseudolink">undo</label>
@ -239,7 +246,7 @@
<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="appearance-form__buttons">
<div id="appearance-form__exit"><a href="#chat">Return to chat</a></div>
<div id="appearance-form__buttons__exit"><label for="toggle" class="pseudolink">Return to chat</label></div>
<input type="submit" value="Update">
</div>
</form>

ファイルの表示

@ -4,9 +4,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="content-security-policy" content="default-src 'none'; style-src 'nonce-{{ csp }}';">
<meta http-equiv="refresh" content="4">
<meta http-equiv="refresh" content="5; url={{ url_for('nojs_chat_messages_redirect', token=user.token) }}">
<style>
<style nonce="{{ csp }}">
html {
height: 100%;
}
@ -100,6 +101,9 @@
.chat-message__time {
color: #b2b2b3;
font-size: 10pt;
cursor: default;
}
.chat-message__insignia {
cursor: help;
}
.chat-message__name {
@ -124,6 +128,20 @@
font-size: 9pt;
cursor: default;
}
{% for token in messages | map(attribute='token') | list | unique %}
{% with user = users_by_token[token] %}
[data-token-hash="{{ user.token_hash }}"] > .chat-message__name {
color: {{ user.color }};
}
{% if user.tripcode %}
[data-token-hash="{{ user.token_hash }}"] > .tripcode {
background-color: {{ user.tripcode.background_color }};
color: {{ user.tripcode.foreground_color }};
}
{% endif %}
{% endwith %}
{% endfor %}
</style>
</head>
<body>
@ -141,7 +159,7 @@
{% with user = users_by_token[message.token] %}
<time class="chat-message__time" datetime="{{ message.date }}T{{ message.time_seconds }}Z" title="{{ message.date }} {{ message.time_seconds }}">{{ message.time_minutes }}</time>
{{- '&nbsp;' | safe -}}
{{ appearance(user, name_class='chat-message__name', tag_class='chat-message__name__tag') }}
{{ appearance(user, insignia_class='chat-message__insignia', name_class='chat-message__name', tag_class='chat-message__name__tag') }}
{{- ': ' -}}
<span class="chat-message__markup">{{ message.markup }}</span>
{% endwith %}

ファイルの表示

@ -4,8 +4,9 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="content-security-policy" content="default-src 'none'; style-src 'nonce-{{ csp }}';">
<meta http-equiv="refresh" content="6">
<style>
<style nonce="{{ csp }}">
html {
min-height: 100%;
}
@ -61,6 +62,9 @@
.user {
line-height: 1.4375;
}
.user__insignia {
cursor: help;
}
.user__name {
font-weight: bold;
cursor: default;
@ -77,6 +81,18 @@
font-size: 9pt;
cursor: default;
}
{% for user in users_watching + users_notwatching %}
[data-token-hash="{{ user.token_hash }}"] > .user__name {
color: {{ user.color }};
}
{% if user.tripcode %}
[data-token-hash="{{ user.token_hash }}"] > .tripcode {
background-color: {{ user.tripcode.background_color }};
color: {{ user.tripcode.foreground_color }};
}
{% endif %}
{% endfor %}
</style>
</head>
<body>
@ -90,8 +106,8 @@
<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') -}}
<li class="user" data-token-hash="{{ user.token_hash }}">
{{- appearance(user_listed, insignia_class='user__insignia', name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
</li>
{% endfor %}
@ -100,8 +116,8 @@
<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') -}}
<li class="user" data-token-hash="{{ user.token_hash }}">
{{- appearance(user_listed, insignia_class='user__insignia', name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
</li>
{% endfor %}

ファイルの表示

@ -3,8 +3,9 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="content-security-policy" content="default-src 'none'; style-src 'nonce-{{ csp }}';">
<meta http-equiv="refresh" content="6">
<style>
<style nonce="{{ csp }}">
body {
overflow-y: auto;
margin: 0.75ch 1.25ch;

ファイルの表示

@ -3,7 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
<meta http-equiv="content-security-policy" content="default-src 'none'; media-src 'self'; style-src 'nonce-{{ csp }}';">
<style nonce="{{ csp }}">
html {
height: 100%;
}

ファイルの表示

@ -4,7 +4,7 @@ from math import inf
from quart import current_app
from anonstream.wrappers import try_except_log, with_timestamp
from anonstream.helpers.user import get_presence, Presence
from anonstream.helpers.user import get_default_name, get_presence, Presence
from anonstream.helpers.captcha import check_captcha_digest, Answer
from anonstream.helpers.tripcode import generate_tripcode
from anonstream.utils.colour import color_to_colour, get_contrast, NotAColor
@ -69,6 +69,8 @@ def try_change_appearance(user, name, color, password, want_tripcode):
def change_name(user, name, dry_run=False):
if dry_run:
if name == get_default_name(user):
name = None
if name is not None:
if len(name) == 0:
raise BadAppearance('Name was empty')
@ -91,7 +93,7 @@ def change_color(user, color, dry_run=False):
if contrast < min_contrast:
raise BadAppearance(
'Colour had insufficient contrast:',
(f'{contrast:.2f}', f'/{min_contrast}'),
(f'{contrast:.2f}', f'/{min_contrast:.2f}'),
)
else:
user['color'] = color

7
anonstream/utils/security.py ノーマルファイル
ファイルの表示

@ -0,0 +1,7 @@
import secrets
def generate_csp():
'''
Generate a random Content Secuity Policy nonce.
'''
return secrets.token_urlsafe(16)

ファイルの表示

@ -1,3 +1,7 @@
from enum import Enum
WS = Enum('WS', names=('MESSAGE, CAPTCHA, APPEARANCE'))
class Malformed(Exception):
pass
@ -19,13 +23,27 @@ def parse_websocket_data(receipt):
comment = get(str, form, 'comment')
digest = get(str, form, 'captcha-digest', '')
answer = get(str, form, 'captcha-answer', '')
return nonce, comment, digest, answer
return WS.MESSAGE, (nonce, comment, digest, answer)
case 'appearance':
raise NotImplemented
form = get(dict, receipt, 'form')
name = get(str, form, 'name').strip()
if len(name) == 0:
name = None
color = get(str, form, 'color')
password = get(str, form, 'password')
#match get(str | None, form, 'want-tripcode'):
# case '0':
# want_tripcode = False
# case '1':
# want_tripcode = True
# case _:
# want_tripcode = None
want_tripcode = bool(password)
return WS.APPEARANCE, (name, color, password, want_tripcode)
case 'captcha':
return None
return WS.CAPTCHA, ()
case _:
raise Malformed('malformed type')

ファイルの表示

@ -6,9 +6,9 @@ from quart import current_app, websocket
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
from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha, try_change_appearance
from anonstream.utils.chat import generate_nonce
from anonstream.utils.websocket import parse_websocket_data, Malformed
from anonstream.utils.websocket import parse_websocket_data, Malformed, WS
CONFIG = current_app.config
@ -41,7 +41,7 @@ async def websocket_inbound(queue, user):
finally:
see(user)
try:
parsed = parse_websocket_data(receipt)
receipt_type, parsed = parse_websocket_data(receipt)
except Malformed as e:
error , *_ = e.args
payload = {
@ -49,12 +49,14 @@ async def websocket_inbound(queue, user):
'because': error,
}
else:
match parsed:
case [nonce, comment, digest, answer]:
payload = handle_inbound_message(user, *parsed)
case None:
payload = handle_inbound_captcha(user)
match receipt_type:
case WS.MESSAGE:
handle = handle_inbound_message
case WS.APPEARANCE:
handle = handle_inbound_appearance
case WS.CAPTCHA:
handle = handle_inbound_captcha
payload = handle(user, *parsed)
queue.put_nowait(payload)
@ -64,6 +66,22 @@ def handle_inbound_captcha(user):
'digest': get_random_captcha_digest_for(user),
}
def handle_inbound_appearance(user, name, color, password, want_tripcode):
errors = try_change_appearance(user, name, color, password, want_tripcode)
if errors:
return {
'type': 'appearance',
'errors': [error.args for error in errors],
}
else:
return {
'type': 'appearance',
'result': 'Changed appearance',
'name': user['name'],
'color': user['color'],
#'tripcode': user['tripcode'],
}
def handle_inbound_message(user, nonce, comment, digest, answer):
try:
verification_happened = verify(user, digest, answer)

ファイルの表示

@ -45,6 +45,7 @@ max_comment_length = 512
max_name_length = 24
min_name_contrast = 3.0
background_color = "#232327"
legacy_tripcode_algorithm = false
[flood]
duration = 20.0