diff --git a/config.toml b/config.toml index 93689e1..22f9abc 100644 --- a/config.toml +++ b/config.toml @@ -6,5 +6,12 @@ fonts = [ "/usr/share/fonts/truetype/tlwg/TlwgMono.ttf" ] +[chat] +host_default_name = "Broadcaster" +anon_default_name = "Anonymous" + +[secrets] +tripcode_salt = "" # put a random value here for secure tripcodes + [stream] hls_time = 2 diff --git a/website/concatenate.py b/website/concatenate.py index 40ceb1c..8813e43 100644 --- a/website/concatenate.py +++ b/website/concatenate.py @@ -5,7 +5,7 @@ from website.utils.stream import _is_segment, _segment_number, get_segments, is_ SEGMENT = 'stream{number}.m4s' CORRUPTING_SEGMENT = 'corrupt.m4s' -STREAM_TIMEOUT = lambda: CONFIG['stream']['hls_time'] * 2 # consider the stream offline after this many seconds without a new segment +STREAM_TIMEOUT = lambda: CONFIG['stream']['hls_time'] * 2 + 2 # consider the stream offline after this many seconds without a new segment def resolve_segment_offset(segment_offset=1): ''' @@ -23,7 +23,7 @@ def get_next_segment(after, start_segment): raise SegmentUnavailable(f'stream went offline') start = time.time() while True: - time.sleep(1) + time.sleep(0.5) segments = get_segments() if after == None: try: @@ -70,7 +70,7 @@ class ConcatenatedSegments: def __init__(self, start_number, segment_hook=None, corrupt_hook=None, should_close_connection=None): # start at this segment, after SEGMENT_INIT self.start_number = start_number - # run this function after sending each segment + # run this function before sending each segment (if we do it after then if someone gets the most of a segment but then stops, that wouldn't be counted, before = 0 viewers means nobody is retrieving the stream, after = slightly more accurate viewer count but 0 viewers doesn't necessarily mean nobody is retrieving the stream) self.segment_hook = segment_hook or (lambda n: None) # run this function when we send the corrupting segment self.corrupt_hook = corrupt_hook or (lambda: None) @@ -83,6 +83,7 @@ class ConcatenatedSegments: self._closed = False self.segment_read_offset = 0 self.segment = next(self.segments) + self.segment_hook(_segment_number(self.segment)) def _read(self, n): chunk = b'' @@ -104,14 +105,9 @@ class ConcatenatedSegments: break self.segment_read_offset = 0 - try: - next_segment = next(self.segments) - except SegmentUnavailable: - self.segment_hook(_segment_number(self.segment)) - raise - else: - self.segment_hook(_segment_number(self.segment)) - self.segment = next_segment + next_segment = next(self.segments) + self.segment_hook(_segment_number(next_segment)) + self.segment = next_segment return chunk def read(self, n): diff --git a/website/constants.py b/website/constants.py index 46aed72..2a5d8bd 100644 --- a/website/constants.py +++ b/website/constants.py @@ -27,9 +27,6 @@ CHAT_TIMEOUT = 3 # seconds between chat messages FLOOD_PERIOD = 20 # seconds FLOOD_THRESHOLD = 4 # messages in FLOOD_PERIOD seconds -HOST_DEFAULT_NICKNAME = 'Broadcaster' -ANON_DEFAULT_NICKNAME = 'Anonymous' - CHAT_MAX_STORAGE = 1024 CHAT_SCROLLBACK = 100 MESSAGE_MAX_LENGTH = 256 @@ -39,7 +36,7 @@ CAPTCHA_LIFETIME = 3600 VIEWER_ABSENT_THRESHOLD = 86400 -BACKGROUND_COLOUR = b'\x22\x22\x22' +BACKGROUND_COLOUR = b'\x23\x23\x23' # the same as in platform.css BROADCASTER_COLOUR = b'\xff\x82\x80' SEGMENT_INIT = 'init.mp4' diff --git a/website/routes.py b/website/routes.py index 24abc3c..13ffab1 100644 --- a/website/routes.py +++ b/website/routes.py @@ -1,4 +1,4 @@ -from flask import current_app, render_template, send_from_directory, request, abort, Response, redirect, url_for, make_response, send_file +from flask import current_app, render_template, send_from_directory, request, abort, redirect, url_for, make_response, send_file from werkzeug import wrap_file import os import time @@ -11,7 +11,7 @@ import toml import website.chat as chat import website.viewership as viewership import website.utils.stream as stream -from website.constants import DIR_STATIC, DIR_STATIC_EXTERNAL, VIDEOJS_ENABLED_BY_DEFAULT, SEGMENT_INIT, CHAT_SCROLLBACK, BROADCASTER_COLOUR, BROADCASTER_TOKEN, SEGMENTS_DIR, VIEW_COUNTING_PERIOD, CONFIG, CONFIG_FILE, NOTES, N_NONE, MESSAGE_MAX_LENGTH +from website.constants import DIR_STATIC, DIR_STATIC_EXTERNAL, VIDEOJS_ENABLED_BY_DEFAULT, SEGMENT_INIT, CHAT_SCROLLBACK, BROADCASTER_COLOUR, BROADCASTER_TOKEN, SEGMENTS_DIR, VIEW_COUNTING_PERIOD, CONFIG, CONFIG_FILE, NOTES, N_NONE, MESSAGE_MAX_LENGTH, BACKGROUND_COLOUR from website.concatenate import ConcatenatedSegments, resolve_segment_offset RE_WHITESPACE = re.compile(r'\s+') @@ -46,7 +46,7 @@ def index(token=None): online=online, start_number=resolve_segment_offset() if online else 0, hls_time=CONFIG['stream']['hls_time']) - response = Response(response) # TODO: add a view of the chat only, either as an arg here or another route + response = make_response(response) # TODO: add a view of the chat only, either as an arg here or another route response.set_cookie('token', token) return response @@ -101,6 +101,11 @@ def segment_arbitrary(n): viewership.video_was_corrupted.remove(token) except KeyError: pass + + # only send segments that are listed in stream.m3u8 + # this stops old segments from previous streams being sent + if f'stream{n}.m4s' not in stream.get_segments(): + return abort(404) viewership.view_segment(n, token) response = send_from_directory(SEGMENTS_DIR, f'stream{n}.m4s', add_etags=False) response.headers['Cache-Control'] = 'no-cache' @@ -128,7 +133,13 @@ def segments(): except FileNotFoundError: return abort(404) - response = send_file(concatenated_segments, mimetype='video/mp4', add_etags=False) + def generate(): + while True: + chunk = concatenated_segments.read(8192) + if chunk == b'': + return + yield chunk + response = current_app.response_class(generate(), mimetype='video/mp4') response.headers['Cache-Control'] = 'no-store' response.set_cookie('token', token) return response @@ -153,6 +164,7 @@ def chat_iframe(): default_nickname=viewership.default_nickname, broadcaster=token == BROADCASTER_TOKEN, broadcaster_colour=BROADCASTER_COLOUR, + background_colour=BACKGROUND_COLOUR, debug=request.args.get('debug'), RE_WHITESPACE=RE_WHITESPACE, len=len, @@ -205,7 +217,7 @@ def comment_iframe(token=None): nickname=viewers[token]['nickname'], viewer=viewers[token], show_settings=preset.get('show_settings', False)) - response = Response(response) + response = make_response(response) response.set_cookie('token', token) return response @@ -297,6 +309,7 @@ def users(): broadcaster=token == BROADCASTER_TOKEN, debug=request.args.get('debug'), broadcaster_colour=BROADCASTER_COLOUR, + background_colour=BACKGROUND_COLOUR, len=len) @current_app.route('/static/radial.apng') diff --git a/website/templates/chat-iframe.html b/website/templates/chat-iframe.html index 1011c96..89e172e 100644 --- a/website/templates/chat-iframe.html +++ b/website/templates/chat-iframe.html @@ -11,11 +11,20 @@ .rotate {transform: rotate(-180deg);} .reverse {direction: rtl;} - .comment {color: white;padding:4px 0;margin-top:-4px;margin-bottom:calc(-4px + 0.375em);overflow:hidden;} - .date {color:gray;font-size:75%;text-align:center;border-bottom:1px solid #333;margin:0.5em 0 0.75em 0;} + .comment {color:white;padding:3px 2px;overflow:hidden;} + .comment:hover{background-color:#333;border-radius:4px;} + .date {color:gray;font-size:75%;text-align:center;border-bottom:1px solid #333;margin:0.5em 0 0.75em 0;cursor:default;} - .name {font-weight: bold;unicode-bidi: isolate;} - sup {margin: 0 -0.25em 0 0.125em;font-size: 90%;font-family:monospace;} + .name {color:var(--name-color);font-weight:bold;unicode-bidi:isolate;} + .name:hover{ + background-color: var(--name-bg-color); + text-shadow: 0 0 1px {{ background_colour }}; + border-radius: 2px; + padding: 2px; + margin: -2px; + cursor: default; + } + sup {margin-right: 0.125em;font-size: 90%;font-family:monospace;} .tripcode { margin-left: 0.5em; padding: 0 5px; @@ -28,16 +37,16 @@ .barrier {display:inline-block;margin-right:0.5em;} .message {overflow-wrap: break-word;unicode-bidi: isolate;} .camera {transform: scaleX(-1);text-shadow: 0px 0px 6px #{{ broadcaster_colour.hex() }};cursor: help;margin-right:0.25em;word-break: keep-all;} - .time {font-size: 80%;color: gray;vertical-align:middle;} + .time {font-size: 80%;color: gray;vertical-align:middle;cursor:default;} {% if include_user_list %} #users {color:white;} .group {margin-bottom:1.5em;} .group-name {margin-bottom:0.25em;} - .person {margin-left: 0.5em;} + .person {margin: 0 0 2px 0.5em;} {% endif %} - input[type="submit"] {padding-left:0.25em;padding-right:0.25em;margin-bottom:0.5em;} + input[type="submit"] {padding:0 0.25em;margin-bottom:0.5em;} .refresh { color: white; background-color: gray; @@ -51,6 +60,8 @@ box-shadow: 0px 2px 4px black; margin: 0; } + .refresh::after {content: "Manual refresh required";} + input[type="checkbox"]{vertical-align:middle;} .unhide { animation: unhide 0s forwards 30s; height: 0; @@ -78,6 +89,12 @@ margin-bottom: 1.25em; } } + td{width:100vw;} + + /* for text-based browsers */ + .textonly { + display: none; + } @@ -90,42 +107,47 @@ {% endif %} - - + + {% for message in messages %} {% if message.get('special') == 'date' %}
{{ message['content'] }}
{% else %} -
+
+ + {% endif %} {% endfor %} + +
{% if not message['hidden'] %} {% if broadcaster %} {% endif %} - {{ message['time'] }}
{{ message['time'] }} 🎥{{ RE_WHITESPACE.sub(chr(160), message['viewer']['nickname'] or default_nickname(message['viewer']['token'])) }}{% with tag = message['viewer']['nickname'] == None and not message['viewer']['broadcaster'] %}{% if tag %}{{ message['viewer']['tag'] }}{% endif %}{{ RE_WHITESPACE.sub(chr(160), message['viewer']['nickname'] or default_nickname(message['viewer']['token'])) }}{% with tag = message['viewer']['nickname'] == None and not message['viewer']['broadcaster'] %}{% if tag %}{{ message['viewer']['tag'] }}{% endif %}
{{ message['viewer']['tripcode']['string'] }}
<{{ message['viewer']['tripcode']['string'] }}>
{{ message['text'] }} + >
{{ message['text'] }} {% endif %} - +
{% if broadcaster %} {% endif %} -
Manual refresh required
+
{% if include_user_list %} +
== List of users ==
-
Manual refresh required
+
{% with person = people['broadcaster'] %} @@ -133,9 +155,10 @@
Broadcaster
- 🎥{{ person['nickname'] or default_nickname(person['token']) }}{% if person['tripcode']['string'] %}
{{ person['tripcode']['string'] }}
{% endif %} + 🎥{{ person['nickname'] or default_nickname(person['token']) }}{% if person['tripcode']['string'] %} <{{ person['tripcode']['string'] }}>{% endif %}
+
{% endif %} {% endwith %} {% if broadcaster %} @@ -147,18 +170,20 @@
Users watching ({{ len(people['watching']) }})
{% for person in people['watching'] %}
- {% if broadcaster %}{% endif %}{{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %}
{{ person['tripcode']['string'] }}
{% endif %}{% endwith %}{% if person['token'] == token %}(You){% endif %} + {% if broadcaster %}{% endif %}{{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %} <{{ person['tripcode']['string'] }}>{% endif %}{% endwith %}{% if person['token'] == token %}(You){% endif %}
{% endfor %}
+
Users not watching ({{ len(people['not_watching']) }})
{% for person in people['not_watching'] %}
- {% if broadcaster %}{% endif %}{{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %}
{{ person['tripcode']['string'] }}
{% endif %}{% endwith %}{% if person['token'] == token %}(You){% endif %} + {% if broadcaster %}{% endif %}{{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %} <{{ person['tripcode']['string'] }}>{% endif %}{% endwith %}{% if person['token'] == token %}(You){% endif %}
{% endfor %}
+
{% if broadcaster %} @@ -170,7 +195,7 @@
{% for person in people['banned'] %}
- {{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %}
{{ person['tripcode']['string'] }}
{% endif %}{% endwith %} + {{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %} <{{ person['tripcode']['string'] }}>{% endif %}{% endwith %}
{% endfor %}
diff --git a/website/templates/comment-iframe.html b/website/templates/comment-iframe.html index cf41e3c..95f9aa5 100644 --- a/website/templates/comment-iframe.html +++ b/website/templates/comment-iframe.html @@ -151,6 +151,28 @@ display: none; } + #to-settings::after { + content: "⚙️"; + } + #to-chat::after { + content: "💬"; + } + .empty-tripcode-container::after { + content: "(no tripcode)"; + } + #cancel-password::after { + content: "×"; + } + #remove-tripcode::after { + content: "×"; + } + #blank-tripcode::after { + content: "(cleared)"; + } + #undo-remove-tripcode::after { + content: "undo"; + } + #tripcode { padding: 0 5px; border-radius: 6px; @@ -166,6 +188,11 @@ .pseudolink:active, .pseudolink:hover { color: #00b6f0; } + + /* for text-based browsers */ + .textonly { + display: none; + } @@ -173,46 +200,55 @@
+
Send a message:
- + {% if captcha %} - + +
Captcha:
{% endif %}
+
+
Nickname:
+
+ Set tripcode: +
+ Remove tripcode: +
Password (to set tripcode):
{% if viewer['tripcode']['string'] %} - (cleared) + {% else %} {% endif %}
- +
diff --git a/website/templates/users-iframe.html b/website/templates/users-iframe.html index cbece9c..5e6295b 100644 --- a/website/templates/users-iframe.html +++ b/website/templates/users-iframe.html @@ -5,10 +5,18 @@ body {color:white;margin-top: 0;margin-bottom: 0;} .group {margin-bottom:1.5em;} .group-name {margin-bottom:0.25em;} - .person {margin-left: 0.5em;} + .person {margin: 0 0 2px 0.5em;} .camera {transform: scaleX(-1);text-shadow: 0px 0px 6px #{{ broadcaster_colour.hex() }};cursor: help;margin-right:0.25em;} - .name {font-weight: bold;unicode-bidi: isolate;} - sup {margin: 0 -0.25em 0 0.125em;font-size: 90%;font-family:monospace;} + .name {color:var(--name-color);font-weight: bold;unicode-bidi: isolate;} + .name:hover{ + background: 4px var(--name-bg-color); + text-shadow: 0 0 1px {{ background_colour }}; + border-radius: 2px; + padding: 2px 4px; + margin: -2px -4px; + cursor: default; + } + sup {margin-right: 0.125em;font-size: 90%;font-family:monospace;} .tripcode { margin-left: 0.5em; padding: 0 5px; @@ -34,6 +42,7 @@ box-shadow: 0px 2px 4px black; margin: 0; } + .unhide-margin { animation: unhide-margin 0s forwards 30s; height: 0; @@ -48,11 +57,16 @@ margin-bottom: 1.25em; } } + + /* for text-based browsers */ + .textonly { + display: none; + } -
Manual refresh required
+
{% with person = people['broadcaster'] %} @@ -60,9 +74,10 @@
Broadcaster
- 🎥{{ person['nickname'] or default_nickname(person['token']) }}{% if person['tripcode']['string'] %}
{{ person['tripcode']['string'] }}
{% endif %} + 🎥{{ person['nickname'] or default_nickname(person['token']) }}{% if person['tripcode']['string'] %} <{{ person['tripcode']['string'] }}>{% endif %}
+
{% endif %} {% endwith %} {% if broadcaster %} @@ -74,18 +89,20 @@
Users watching ({{ len(people['watching']) }})
{% for person in people['watching'] %}
- {% if broadcaster %}{% endif %}{{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %}
{{ person['tripcode']['string'] }}
{% endif %}{% endwith %}{% if person['token'] == token %}(You){% endif %} + {% if broadcaster %}{% endif %}{{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %} <{{ person['tripcode']['string'] }}>{% endif %}{% endwith %}{% if person['token'] == token %} (You){% endif %}
{% endfor %}
+
Users not watching ({{ len(people['not_watching']) }})
{% for person in people['not_watching'] %}
- {% if broadcaster %}{% endif %}{{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %}
{{ person['tripcode']['string'] }}
{% endif %}{% endwith %}{% if person['token'] == token %}(You){% endif %} + {% if broadcaster %}{% endif %}{{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %} <{{ person['tripcode']['string'] }}>{% endif %}{% endwith %}{% if person['token'] == token %}(You){% endif %}
{% endfor %}
+
{% if broadcaster %}
@@ -97,7 +114,7 @@
{% for person in people['banned'] %}
- {{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %}
{{ person['tripcode']['string'] }}
{% endif %}{% endwith %} + {{ person['nickname'] or default_nickname(person['token']) }}{% with tag = person['nickname'] == None %}{% if tag %}{{ person['tag'] }}{% endif %}{% if person['tripcode']['string'] %}{% if tag %}{% endif %} <{{ person['tripcode']['string'] }}>{% endif %}{% endwith %}
{% endfor %}
diff --git a/website/utils/tripcode.py b/website/utils/tripcode.py index 5b697f2..825ebf5 100644 --- a/website/utils/tripcode.py +++ b/website/utils/tripcode.py @@ -1,16 +1,19 @@ import werkzeug.security import base64 import website.utils.colour -from website.constants import BACKGROUND_COLOUR +from website.constants import BACKGROUND_COLOUR, CONFIG FOREGROUND_COLOURS = (b'\0\0\0', b'\x3f\x3f\x3f', b'\x7f\x7f\x7f', b'\xbf\xbf\xbf', b'\xff\xff\xff') def default(): return {'string': None, 'background_colour': None, 'foreground_colour': None} +def tripcode_salt(): + return CONFIG['secrets']['tripcode_salt'].encode() or b'\0' + def gen_tripcode(password): tripcode = default() - pwhash = werkzeug.security._hash_internal('pbkdf2:sha256', b'\0', password)[0] + pwhash = werkzeug.security._hash_internal('pbkdf2:sha256', tripcode_salt(), password)[0] tripcode_data = bytes.fromhex(pwhash)[:6] tripcode['string'] = base64.b64encode(tripcode_data).decode() tripcode['background_colour'] = website.utils.colour.gen_colour(tripcode_data, BACKGROUND_COLOUR) diff --git a/website/viewership.py b/website/viewership.py index b1e6057..21efd45 100644 --- a/website/viewership.py +++ b/website/viewership.py @@ -5,7 +5,7 @@ import time import website.utils.colour as colour import website.utils.tripcode as tripcode import website.chat as chat -from website.constants import ANON_DEFAULT_NICKNAME, BROADCASTER_COLOUR, BROADCASTER_TOKEN, CONFIG, HOST_DEFAULT_NICKNAME, SEGMENTS_DIR, VIEW_COUNTING_PERIOD, VIEWER_ABSENT_THRESHOLD +from website.constants import BROADCASTER_COLOUR, BROADCASTER_TOKEN, CONFIG, SEGMENTS_DIR, VIEW_COUNTING_PERIOD, VIEWER_ABSENT_THRESHOLD viewers = {} segment_views = {} @@ -35,8 +35,8 @@ preset_comment_iframe = {} def default_nickname(token): if token == BROADCASTER_TOKEN: - return HOST_DEFAULT_NICKNAME - return ANON_DEFAULT_NICKNAME + return CONFIG['chat']['host_default_name'] + return CONFIG['chat']['anon_default_name'] def setdefault(token): if token in viewers or token == None: