anonstream/website/routes.py

409 行
16 KiB
Python
Raw 通常表示 履歴

from flask import current_app, render_template, send_from_directory, request, abort, redirect, url_for, make_response, send_file
2021-04-13 22:13:40 +09:00
from werkzeug import wrap_file
2021-04-10 02:26:29 +09:00
import os
import time
import secrets
2021-04-12 00:15:25 +09:00
import json
import datetime
2021-05-16 09:54:21 +09:00
import re
import toml
2021-04-10 02:26:29 +09:00
2021-04-13 22:13:40 +09:00
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, BACKGROUND_COLOUR
from website.concatenate import ConcatenatedSegments, resolve_segment_offset
2021-04-10 02:26:29 +09:00
2021-05-16 09:54:21 +09:00
RE_WHITESPACE = re.compile(r'\s+')
2021-04-13 22:13:40 +09:00
viewers = viewership.viewers
2021-04-10 14:42:44 +09:00
2021-04-13 22:13:40 +09:00
def new_token():
return secrets.token_hex(8)
2021-04-10 02:26:29 +09:00
2021-04-15 20:37:04 +09:00
def get_token(form=False):
token = (request.form if form else request.args).get('token')
if token == None or len(token) >= 256 or len(token) < 4:
token = request.cookies.get('token')
if token and (len(token) >= 256 or len(token) < 4):
token = None
return token
2021-04-13 22:13:40 +09:00
@current_app.route('/')
2021-04-10 02:26:29 +09:00
def index(token=None):
2021-04-15 20:37:04 +09:00
token = token or get_token() or new_token()
try:
viewership.video_was_corrupted.remove(token)
except KeyError:
pass
2021-05-15 13:35:48 +09:00
use_videojs = bool(request.args.get('videojs', default=int(VIDEOJS_ENABLED_BY_DEFAULT), type=int))
2021-07-06 19:09:06 +09:00
online = stream.is_online()
2021-04-15 20:37:04 +09:00
viewership.made_request(token)
response = render_template('index.html',
token=token,
use_videojs=use_videojs,
2021-07-06 19:09:06 +09:00
online=online,
start_number=resolve_segment_offset() if online else 0,
hls_time=CONFIG['stream']['hls_time'])
response = make_response(response) # TODO: add a view of the chat only, either as an arg here or another route
2021-04-10 02:26:29 +09:00
response.set_cookie('token', token)
return response
2021-04-13 22:13:40 +09:00
@current_app.route('/broadcaster')
@current_app.auth.login_required
2021-04-10 02:26:29 +09:00
def broadcaster():
2021-04-13 22:13:40 +09:00
return index(token=BROADCASTER_TOKEN)
2021-04-10 02:26:29 +09:00
2021-04-13 22:13:40 +09:00
@current_app.route('/stream.m3u8')
2021-04-10 02:26:29 +09:00
def playlist():
if not stream.is_online():
return abort(404)
token = get_token()
try:
viewership.video_was_corrupted.remove(token)
except KeyError:
pass
viewership.made_request(token)
2021-04-15 20:37:04 +09:00
try:
token_playlist = stream.token_playlist(token)
2021-04-15 20:37:04 +09:00
except FileNotFoundError:
return abort(404)
response = make_response(token_playlist)
response.mimetype = 'application/x-mpegURL'
2021-04-10 02:26:29 +09:00
response.headers['Cache-Control'] = 'no-cache'
return response
2021-04-13 22:13:40 +09:00
@current_app.route(f'/{SEGMENT_INIT}')
def segment_init():
if not stream.is_online():
return abort(404)
2021-04-15 20:37:04 +09:00
token = get_token() or new_token()
try:
viewership.video_was_corrupted.remove(token)
except KeyError:
pass
viewership.made_request(token)
response = send_from_directory(SEGMENTS_DIR, f'init.mp4', add_etags=False)
response.headers['Cache-Control'] = 'no-cache'
response.set_cookie('token', token)
return response
2021-04-13 22:13:40 +09:00
@current_app.route('/stream<int:n>.m4s')
def segment_arbitrary(n):
if not stream.is_online():
return abort(404)
2021-04-15 20:37:04 +09:00
token = get_token()
try:
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)
2021-04-13 22:13:40 +09:00
viewership.view_segment(n, token)
response = send_from_directory(SEGMENTS_DIR, f'stream{n}.m4s', add_etags=False)
2021-04-14 23:31:59 +09:00
response.headers['Cache-Control'] = 'no-cache'
2021-04-10 02:26:29 +09:00
return response
2021-04-13 22:13:40 +09:00
@current_app.route('/stream.mp4')
def segments():
if not stream.is_online():
return abort(404)
token = get_token() or new_token()
try:
viewership.video_was_corrupted.remove(token)
except KeyError:
pass
viewership.made_request(token)
start_number = request.args.get('segment', type=int)
if start_number == None:
start_number = resolve_segment_offset()
try:
concatenated_segments = ConcatenatedSegments(start_number=start_number,
segment_hook=lambda n: viewership.view_segment(n, token, check_exists=False),
corrupt_hook=lambda: viewership.video_was_corrupted.add(token), # lock?
should_close_connection=lambda: not stream.is_online())
except FileNotFoundError:
return abort(404)
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)
2021-04-12 13:32:43 +09:00
return response
2021-04-10 02:26:29 +09:00
2021-04-13 22:13:40 +09:00
@current_app.route('/chat')
2021-04-10 02:26:29 +09:00
def chat_iframe():
token = get_token()
2021-04-15 20:37:04 +09:00
viewership.made_request(token)
include_user_list = bool(request.args.get('users', default=1, type=int))
with viewership.lock: # required because another thread can change chat.messages while we're iterating through it
messages = (message for message in chat.messages if not message['hidden'])
messages = zip(messages, range(CHAT_SCROLLBACK)) # show at most CHAT_SCROLLBACK messages
messages = [message for message, _ in messages]
chat.decorate(messages)
2021-04-15 20:37:04 +09:00
return render_template('chat-iframe.html',
token=token,
messages=messages,
include_user_list=include_user_list,
2021-04-15 20:37:04 +09:00
people=viewership.get_people_list(),
default_nickname=viewership.default_nickname,
broadcaster=token == BROADCASTER_TOKEN,
broadcaster_colour=BROADCASTER_COLOUR,
background_colour=BACKGROUND_COLOUR,
2021-04-15 20:37:04 +09:00
debug=request.args.get('debug'),
2021-05-16 09:54:21 +09:00
RE_WHITESPACE=RE_WHITESPACE,
len=len,
chr=chr)
2021-04-10 02:26:29 +09:00
2021-04-13 22:13:40 +09:00
@current_app.route('/heartbeat')
2021-04-10 02:26:29 +09:00
def heartbeat():
2021-04-15 20:37:04 +09:00
token = get_token()
viewership.made_request(token)
2021-04-13 22:13:40 +09:00
online = stream.is_online()
start_abs, start_rel = stream.get_start(absolute=True, relative=True)
response = {'viewers': viewership.count(),
'online': online,
'current_segment': stream.current_segment(),
'title': stream.get_title(),
'start_abs': start_abs if online else None,
'start_rel': start_rel if online else None}
# commented out because we should be able to tell if we're not receiving the stream already
# if token in viewership.video_was_corrupted:
# response['corrupted'] = True
return response
2021-04-10 02:26:29 +09:00
@current_app.route('/comment-box', methods=['GET', 'POST'])
def comment_iframe(token=None):
2021-04-15 20:37:04 +09:00
token = token or get_token() or new_token()
viewership.made_request(token)
2021-04-10 02:26:29 +09:00
2021-04-10 14:42:44 +09:00
try:
preset = viewership.preset_comment_iframe.pop(token)
2021-04-10 14:42:44 +09:00
except KeyError:
2021-04-13 22:13:40 +09:00
preset = {}
# a new captcha was requested; fill in the message that the user has typed so far
if preset == {} and request.method == 'POST':
message = request.form.get('message', '')
if len(message) < MESSAGE_MAX_LENGTH:
preset['message'] = message
2021-04-10 02:26:29 +09:00
2021-04-13 22:13:40 +09:00
captcha = chat.get_captcha(token)
2021-04-10 02:26:29 +09:00
2021-04-13 22:13:40 +09:00
response = render_template('comment-iframe.html',
token=token,
captcha=captcha,
nonce=chat.new_nonce(),
2021-04-13 22:13:40 +09:00
note=NOTES[preset.get('note', N_NONE)],
message=preset.get('message', ''),
default=viewership.default_nickname(token),
nickname=viewers[token]['nickname'],
2021-04-13 22:13:40 +09:00
viewer=viewers[token],
show_settings=preset.get('show_settings', False))
response = make_response(response)
2021-04-10 02:26:29 +09:00
response.set_cookie('token', token)
return response
2021-04-13 22:13:40 +09:00
@current_app.route('/comment', methods=['POST'])
2021-04-10 02:26:29 +09:00
def comment():
2021-04-15 20:37:04 +09:00
token = get_token(form=True) or new_token()
nonce = request.form.get('nonce')
2021-04-10 02:26:29 +09:00
message = request.form.get('message', '').replace('\r', '').replace('\n', ' ').strip()
c_response = request.form.get('captcha')
c_ciphertext = request.form.get('captcha-ciphertext')
2021-04-10 02:26:29 +09:00
2021-04-15 20:37:04 +09:00
viewership.made_request(token)
failure_reason = chat.comment(message, token, c_response, c_ciphertext, nonce)
2021-04-10 14:42:44 +09:00
viewership.preset_comment_iframe[token] = {'note': failure_reason, 'message': message if failure_reason else ''}
return comment_iframe(token=token)
2021-04-10 02:26:29 +09:00
2021-04-11 03:08:13 +09:00
# TODO: make it so your message that you haven't sent yet stays there when you change your appearance
2021-04-11 17:58:14 +09:00
# ^ This is possible if you use only one form and change buttons to <input type="submit" formaction="/url">
2021-05-15 01:46:24 +09:00
# BUT it's not easy to make sure the formaction is correct when you press enter in any given <input>.
# There could be some other way, idk.
2021-04-13 22:13:40 +09:00
@current_app.route('/settings', methods=['POST'])
2021-04-10 02:26:29 +09:00
def settings():
2021-04-15 20:37:04 +09:00
token = get_token(form=True) or new_token()
2021-04-13 22:13:40 +09:00
nickname = request.form.get('nickname', '')
password = request.form.get('password', '')
2021-04-10 02:26:29 +09:00
2021-04-15 20:37:04 +09:00
viewership.made_request(token)
2021-04-13 22:13:40 +09:00
note, ok = chat.set_nickname(nickname, token)
if ok:
if request.form.get('remove-tripcode'):
note, _ = chat.remove_tripcode(token)
elif request.form.get('set-tripcode'):
note, _ = chat.set_tripcode(password, token)
2021-04-10 14:42:44 +09:00
viewership.preset_comment_iframe[token] = {'note': note, 'show_settings': True}
2021-04-10 14:42:44 +09:00
return redirect(url_for('comment_iframe', token=token))
2021-04-10 02:26:29 +09:00
# TODO: undo hides; optionally show that a comment was hidden; optionally show bans in chat
2021-04-16 00:24:19 +09:00
@current_app.route('/mod/chat', methods=['POST'])
2021-04-13 22:13:40 +09:00
@current_app.auth.login_required
2021-04-16 00:24:19 +09:00
def mod_chat():
2021-04-10 02:26:29 +09:00
message_ids = request.form.getlist('message_id[]')
2021-04-16 00:26:30 +09:00
chat.mod_chat(message_ids, request.form.get('hide'), request.form.get('ban'), request.form.get('ban_purge'))
return f'<meta http-equiv="refresh" content="0;url={url_for("chat_iframe")}"><div style="font-weight:bold;color:white;transform:scaleY(-1);">it is done</div>'
2021-04-10 02:26:29 +09:00
@current_app.route('/mod/users', methods=['POST'])
@current_app.auth.login_required
def mod_users():
tokens = request.form.getlist('token[]')
banned = bool(request.form.get('banned', type=int))
chat.mod_users(tokens, banned=banned)
noscript = bool(request.form.get('noscript', type=int))
return f'<meta http-equiv="refresh" content="0;url={url_for("users") if noscript else url_for("chat_iframe")}"><div style="font-weight:bold;color:white;">it is done</div>'
2021-04-17 02:53:06 +09:00
# TODO: "you're not receiving the stream" message if that token isn't receiving the stream; make sure they don't see it when they first load the page
2021-04-13 22:13:40 +09:00
@current_app.route('/stream-info')
2021-04-10 02:26:29 +09:00
def stream_info():
token = get_token() or new_token()
embed_images = bool(request.args.get('embed', type=int))
2021-04-15 20:37:04 +09:00
viewership.made_request(token)
2021-04-13 22:13:40 +09:00
start_abs, start_rel = stream.get_start(absolute=True, relative=True)
online = stream.is_online()
2021-04-10 02:26:29 +09:00
return render_template('stream-info-iframe.html',
2021-04-13 22:13:40 +09:00
title=stream.get_title(),
viewer_count=viewership.count(),
stream_start_abs_json=json.dumps(start_abs if online else None),
stream_start_rel_json=json.dumps(start_rel if online else None),
2021-04-12 21:07:58 +09:00
stream_uptime=start_rel if online else None,
online=online,
video_was_corrupted=token != None and token in viewership.video_was_corrupted,
embed_images=embed_images,
token=token,
broadcaster_colour=BROADCASTER_COLOUR)
2021-04-15 20:37:04 +09:00
@current_app.route('/users')
def users():
token = get_token()
viewership.made_request(token)
return render_template('users-iframe.html',
token=token,
people=viewership.get_people_list(),
default_nickname=viewership.default_nickname,
broadcaster=token == BROADCASTER_TOKEN,
debug=request.args.get('debug'),
broadcaster_colour=BROADCASTER_COLOUR,
background_colour=BACKGROUND_COLOUR,
len=len)
2021-04-15 20:37:04 +09:00
@current_app.route('/static/radial.apng')
def radial():
response = send_from_directory(DIR_STATIC, 'radial.apng', mimetype='image/png', add_etags=False)
response.headers['Cache-Control'] = 'no-store' # caching this in any way messes with the animation
response.expires = response.date
return response
2021-04-15 20:37:04 +09:00
@current_app.route('/static/<fn>')
def _static(fn):
response = send_from_directory(DIR_STATIC, fn, add_etags=False)
response.headers['Cache-Control'] = 'no-cache'
return response
@current_app.route('/static/external/<fn>')
def third_party(fn):
response = send_from_directory(DIR_STATIC_EXTERNAL, fn, add_etags=False)
response.headers['Cache-Control'] = 'public, max-age=604800, immutable'
response.expires = response.date + datetime.timedelta(days=7)
return response
@current_app.after_request
def add_header(response):
try:
response.headers.pop('Last-Modified')
except KeyError:
pass
return response
@current_app.route('/debug')
@current_app.auth.login_required
def debug():
import copy
# necessary because we store colours as bytes and json can't bytes;
class JSONEncoder(json.encoder.JSONEncoder):
def default(self, obj):
if isinstance(obj, bytes):
return f'#{obj.hex()}'
return json.encoder.JSONEncoder.default(obj)
with viewership.lock:
# necessary because infinities are allowed by json.dumps but browsers don't like it
json_safe_viewers = copy.deepcopy(viewership.viewers)
for token in json_safe_viewers:
for key in json_safe_viewers[token]:
if json_safe_viewers[token][key] == float('-inf'):
json_safe_viewers[token][key] = None
result = {
'viewership': {
'segment_views': viewership.segment_views,
'video_was_corrupted': list(viewership.video_was_corrupted),
'viewers': json_safe_viewers,
'preset_comment_iframe': viewership.preset_comment_iframe
},
'chat': {
'captchas': chat.captchas,
'messages': list(chat.messages),
'nonces': chat.nonces
}
}
response = make_response(json.dumps(result, cls=JSONEncoder).replace('": -Infinity', '": null')) # this is such a horrible hack I apologise that you saw it
response.mimetype = 'application/json'
# so that you are logged in if the very first thing you do is go to /debug, then you go to /
if get_token() != BROADCASTER_TOKEN:
response.set_cookie('token', BROADCASTER_TOKEN)
return response
@current_app.route('/reload')
@current_app.auth.login_required
def reload():
'''
Re-read config.toml
'''
2021-07-17 10:01:28 +09:00
with viewership.lock:
with open(CONFIG_FILE) as fp:
config = toml.load(fp)
for key in config:
CONFIG[key] = config[key]
# this exists for the same reason as in /debug
response = make_response(CONFIG)
if get_token() != BROADCASTER_TOKEN:
response.set_cookie('token', BROADCASTER_TOKEN)
return response
2021-04-13 22:13:40 +09:00
@current_app.route('/teapot')
def teapot():
return {'short': True, 'stout': True}, 418