コミットを比較
4 コミット
6ae87be229
...
e5b78db5a2
作成者 | SHA1 | 日付 |
---|---|---|
n9k | e5b78db5a2 | |
n9k | 1ac754bd25 | |
n9k | 476a6002cd | |
n9k | 4c5faf7dba |
|
@ -38,6 +38,8 @@ def create_app(toml_config):
|
|||
app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS'])
|
||||
app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY'])
|
||||
|
||||
app.failures = {}
|
||||
|
||||
# State for tasks
|
||||
app.users_update_buffer = set()
|
||||
app.stream_title = None
|
||||
|
@ -77,7 +79,6 @@ def create_app(toml_config):
|
|||
)
|
||||
app.add_background_task(start_event_server)
|
||||
|
||||
|
||||
# Create routes and background tasks
|
||||
import anonstream.routes
|
||||
import anonstream.tasks
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import time
|
||||
|
||||
from quart import current_app
|
||||
|
||||
FAILURES = current_app.failures
|
||||
|
||||
def add_failure(message):
|
||||
timestamp = time.time_ns() // 1_000_000
|
||||
while timestamp in FAILURES:
|
||||
timestamp += 1
|
||||
FAILURES[timestamp] = message
|
||||
return timestamp
|
||||
|
||||
def pop_failure(failure_id):
|
||||
try:
|
||||
return FAILURES.pop(failure_id)
|
||||
except KeyError:
|
||||
return None
|
|
@ -19,6 +19,7 @@ def update_flask_from_toml(toml_config, flask_config):
|
|||
'AUTH_USERNAME': toml_config['auth']['username'],
|
||||
'AUTH_PWHASH': auth_pwhash,
|
||||
'AUTH_TOKEN': generate_token(),
|
||||
'ACCESS_CAPTCHA': toml_config['access']['captcha'],
|
||||
})
|
||||
for flask_section in toml_to_flask_sections(toml_config):
|
||||
flask_config.update(flask_section)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import anonstream.routes.errors
|
||||
import anonstream.routes.core
|
||||
import anonstream.routes.websocket
|
||||
import anonstream.routes.nojs
|
||||
|
|
|
@ -3,63 +3,90 @@
|
|||
|
||||
import math
|
||||
|
||||
from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort, send_from_directory
|
||||
from werkzeug.exceptions import TooManyRequests
|
||||
from quart import current_app, request, render_template, abort, make_response, redirect, url_for, send_from_directory
|
||||
from werkzeug.exceptions import NotFound, TooManyRequests
|
||||
|
||||
from anonstream.captcha import get_captcha_image
|
||||
from anonstream.access import add_failure, pop_failure
|
||||
from anonstream.captcha import get_captcha_image, get_random_captcha_digest
|
||||
from anonstream.segments import segments, StopSendingSegments
|
||||
from anonstream.stream import is_online, get_stream_uptime
|
||||
from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes
|
||||
from anonstream.routes.wrappers import with_user_from, auth_required, clean_cache_headers
|
||||
from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes, TooManyEyes
|
||||
from anonstream.routes.wrappers import with_user_from, auth_required, clean_cache_headers, generate_and_add_user
|
||||
from anonstream.helpers.captcha import check_captcha_digest, Answer
|
||||
from anonstream.utils.security import generate_csp
|
||||
|
||||
CAPTCHA_SIGNER = current_app.captcha_signer
|
||||
STATIC_DIRECTORY = current_app.root_path / 'static'
|
||||
|
||||
@current_app.route('/')
|
||||
@with_user_from(request)
|
||||
async def home(timestamp, user):
|
||||
return await render_template(
|
||||
'home.html',
|
||||
csp=generate_csp(),
|
||||
user=user,
|
||||
)
|
||||
@with_user_from(request, fallback_to_token=True)
|
||||
async def home(timestamp, user_or_token):
|
||||
match user_or_token:
|
||||
case str() | None:
|
||||
failure_id = request.args.get('failure', type=int)
|
||||
response = await render_template(
|
||||
'captcha.html',
|
||||
csp=generate_csp(),
|
||||
token=user_or_token,
|
||||
digest=get_random_captcha_digest(),
|
||||
failure=pop_failure(failure_id),
|
||||
)
|
||||
case dict():
|
||||
response = await render_template(
|
||||
'home.html',
|
||||
csp=generate_csp(),
|
||||
user=user_or_token,
|
||||
)
|
||||
return response
|
||||
|
||||
@current_app.route('/stream.mp4')
|
||||
@with_user_from(request)
|
||||
async def stream(timestamp, user):
|
||||
if not is_online():
|
||||
return abort(404)
|
||||
|
||||
try:
|
||||
eyes_id = create_eyes(user, dict(request.headers))
|
||||
except RatelimitedEyes as e:
|
||||
retry_after, *_ = e.args
|
||||
return TooManyRequests(), {'Retry-After': math.ceil(retry_after)}
|
||||
except EyesException:
|
||||
return abort(429)
|
||||
|
||||
def segment_read_hook(uri):
|
||||
raise NotFound('The stream is offline.')
|
||||
else:
|
||||
try:
|
||||
renew_eyes(user, eyes_id, just_read_new_segment=True)
|
||||
except EyesException as e:
|
||||
raise StopSendingSegments(f'eyes {eyes_id} not allowed: {e!r}') from e
|
||||
print(f'{uri}: {eyes_id}~{user["token"]}')
|
||||
watching(user)
|
||||
|
||||
generator = segments(segment_read_hook, token=user['token'])
|
||||
response = await make_response(generator)
|
||||
response.headers['Content-Type'] = 'video/mp4'
|
||||
response.timeout = None
|
||||
eyes_id = create_eyes(user, dict(request.headers))
|
||||
except RatelimitedEyes as e:
|
||||
retry_after, *_ = e.args
|
||||
error = TooManyRequests(
|
||||
f'You have requested the stream recently. '
|
||||
f'Try again in {retry_after:.1f} seconds.'
|
||||
)
|
||||
response = await current_app.handle_http_exception(error)
|
||||
response = await make_response(response)
|
||||
response.headers['Retry-After'] = math.ceil(retry_after)
|
||||
raise abort(response)
|
||||
except TooManyEyes as e:
|
||||
n_eyes, *_ = e.args
|
||||
raise TooManyRequests(
|
||||
f'You have made {n_eyes} concurrent requests for the stream. '
|
||||
f'End one of those before making a new request.'
|
||||
)
|
||||
else:
|
||||
def segment_read_hook(uri):
|
||||
try:
|
||||
renew_eyes(user, eyes_id, just_read_new_segment=True)
|
||||
except EyesException as e:
|
||||
raise StopSendingSegments(
|
||||
f'eyes {eyes_id} not allowed: {e!r}'
|
||||
) from e
|
||||
print(f'{uri}: {eyes_id}~{user["token"]}')
|
||||
watching(user)
|
||||
generator = segments(segment_read_hook, token=user['token'])
|
||||
response = await make_response(generator)
|
||||
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'))
|
||||
return redirect(url_for('home'), 303)
|
||||
|
||||
@current_app.route('/captcha.jpg')
|
||||
@with_user_from(request)
|
||||
async def captcha(timestamp, user):
|
||||
@with_user_from(request, fallback_to_token=True)
|
||||
async def captcha(timestamp, user_or_token):
|
||||
digest = request.args.get('digest', '')
|
||||
image = get_captcha_image(digest)
|
||||
if image is None:
|
||||
|
@ -72,3 +99,31 @@ async def captcha(timestamp, user):
|
|||
@clean_cache_headers
|
||||
async def static(timestamp, user, filename):
|
||||
return await send_from_directory(STATIC_DIRECTORY, filename)
|
||||
|
||||
@current_app.post('/access')
|
||||
@with_user_from(request, fallback_to_token=True)
|
||||
async def access(timestamp, user_or_token):
|
||||
match user_or_token:
|
||||
case str() | None:
|
||||
token = user_or_token
|
||||
form = await request.form
|
||||
digest = form.get('digest', '')
|
||||
answer = form.get('answer', '')
|
||||
match check_captcha_digest(CAPTCHA_SIGNER, digest, answer):
|
||||
case Answer.MISSING:
|
||||
failure_id = add_failure('Captcha is required')
|
||||
case Answer.BAD:
|
||||
failure_id = add_failure('Captcha was incorrect')
|
||||
case Answer.EXPIRED:
|
||||
failure_id = add_failure('Captcha has expired')
|
||||
case Answer.OK:
|
||||
failure_id = None
|
||||
user = generate_and_add_user(timestamp, token)
|
||||
if failure_id is not None:
|
||||
url = url_for('home', token=token, failure=failure_id)
|
||||
raise abort(redirect(url, 303))
|
||||
case dict():
|
||||
user = user_or_token
|
||||
url = url_for('home', token=user['token'])
|
||||
return redirect(url, 303)
|
||||
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
from quart import current_app, render_template
|
||||
|
||||
from werkzeug.exceptions import default_exceptions
|
||||
|
||||
for error in default_exceptions:
|
||||
async def handle(error):
|
||||
return await render_template('error.html', error=error), error.code
|
||||
current_app.register_error_handler(error, handle)
|
|
@ -61,7 +61,8 @@ async def nojs_chat_messages(timestamp, user):
|
|||
@current_app.route('/chat/messages')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_messages_redirect(timestamp, user):
|
||||
return redirect(url_for('nojs_chat_messages', token=user['token'], _anchor='end'))
|
||||
url = url_for('nojs_chat_messages', token=user['token'], _anchor='end')
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.route('/chat/users.html')
|
||||
@with_user_from(request)
|
||||
|
@ -109,7 +110,8 @@ async def nojs_chat_form_redirect(timestamp, user):
|
|||
)
|
||||
else:
|
||||
state_id = None
|
||||
return redirect(url_for('nojs_chat_form', token=user['token'], state=state_id))
|
||||
url = url_for('nojs_chat_form', token=user['token'], state=state_id)
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.post('/chat/message')
|
||||
@with_user_from(request)
|
||||
|
@ -151,12 +153,13 @@ async def nojs_submit_message(timestamp, user):
|
|||
if message_was_added:
|
||||
deverify(user)
|
||||
|
||||
return redirect(url_for(
|
||||
url = url_for(
|
||||
'nojs_chat_form',
|
||||
token=user['token'],
|
||||
landing='chat',
|
||||
state=state_id,
|
||||
))
|
||||
)
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.post('/chat/appearance')
|
||||
@with_user_from(request)
|
||||
|
@ -188,9 +191,10 @@ async def nojs_submit_appearance(timestamp, user):
|
|||
notice = 'Changed appearance'
|
||||
|
||||
state_id = add_state(user, notice=notice, verbose=len(errors) > 1)
|
||||
return redirect(url_for(
|
||||
url = url_for(
|
||||
'nojs_chat_form',
|
||||
token=user['token'],
|
||||
landing='appearance' if errors else 'chat',
|
||||
state=state_id,
|
||||
))
|
||||
)
|
||||
return redirect(url, 303)
|
||||
|
|
|
@ -8,7 +8,8 @@ import string
|
|||
from functools import wraps
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from quart import current_app, request, abort, make_response, render_template, request
|
||||
from quart import current_app, request, make_response, render_template, request, url_for, Markup
|
||||
from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from anonstream.broadcast import broadcast
|
||||
|
@ -57,64 +58,98 @@ def auth_required(f):
|
|||
'their terminal.'
|
||||
)
|
||||
if request.authorization is None:
|
||||
body = (
|
||||
f'<!doctype html>\n'
|
||||
f'<p>{hint}</p>\n'
|
||||
)
|
||||
description = hint
|
||||
else:
|
||||
body = (
|
||||
f'<!doctype html>\n'
|
||||
f'<p>Wrong username or password. Refresh the page to try again.</p>\n'
|
||||
f'<p>{hint}</p>\n'
|
||||
description = Markup(
|
||||
f'Wrong username or password. Refresh the page to try again. '
|
||||
f'<br>'
|
||||
f'{hint}'
|
||||
)
|
||||
return body, 401, {'WWW-Authenticate': 'Basic'}
|
||||
|
||||
error = Unauthorized(description)
|
||||
response = await current_app.handle_http_exception(error)
|
||||
response = await make_response(response)
|
||||
response.headers['WWW-Authenticate'] = 'Basic'
|
||||
return response
|
||||
return wrapper
|
||||
|
||||
def with_user_from(context):
|
||||
def generate_and_add_user(timestamp, token=None, broadcaster=False):
|
||||
token = token or generate_token()
|
||||
user = generate_user(
|
||||
timestamp=timestamp,
|
||||
token=token,
|
||||
broadcaster=broadcaster,
|
||||
presence=Presence.NOTWATCHING,
|
||||
)
|
||||
USERS_BY_TOKEN[token] = user
|
||||
USERS_UPDATE_BUFFER.add(token)
|
||||
return user
|
||||
|
||||
def with_user_from(context, fallback_to_token=False):
|
||||
def with_user_from_context(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
timestamp = get_timestamp()
|
||||
|
||||
# Check if broadcaster
|
||||
# Get token
|
||||
broadcaster = check_auth(context)
|
||||
token_from_args = context.args.get('token')
|
||||
token_from_cookie = try_unquote(context.cookies.get('token'))
|
||||
token_from_context = token_from_args or token_from_cookie
|
||||
if broadcaster:
|
||||
token = CONFIG['AUTH_TOKEN']
|
||||
elif CONFIG['ACCESS_CAPTCHA']:
|
||||
token = token_from_context
|
||||
else:
|
||||
token = (
|
||||
context.args.get('token')
|
||||
or try_unquote(context.cookies.get('token'))
|
||||
or generate_token()
|
||||
)
|
||||
if hmac.compare_digest(token, CONFIG['AUTH_TOKEN']):
|
||||
raise abort(401)
|
||||
token = token_from_context or generate_token()
|
||||
|
||||
# Reject invalid tokens
|
||||
if not RE_TOKEN.fullmatch(token):
|
||||
raise abort(400)
|
||||
if isinstance(token, str) and not RE_TOKEN.fullmatch(token):
|
||||
raise BadRequest(Markup(
|
||||
f'Your token contains disallowed characters or is too '
|
||||
f'long. Tokens must match this regular expression: <br>'
|
||||
f'<code>{RE_TOKEN.pattern}</code>'
|
||||
))
|
||||
|
||||
# Update / create user
|
||||
# Only logged in broadcaster may have the broadcaster's token
|
||||
if (
|
||||
not broadcaster
|
||||
and isinstance(token, str)
|
||||
and hmac.compare_digest(token, CONFIG['AUTH_TOKEN'])
|
||||
):
|
||||
raise Unauthorized(Markup(
|
||||
f"You are using the broadcaster's token but you are "
|
||||
f"not logged in. The broadcaster should "
|
||||
f"<a href=\"{url_for('login')}\">click here</a> "
|
||||
f"and log in with the credentials printed in their "
|
||||
f"terminal when they started anonstream."
|
||||
))
|
||||
|
||||
# Create response
|
||||
user = USERS_BY_TOKEN.get(token)
|
||||
if user is not None:
|
||||
see(user)
|
||||
if CONFIG['ACCESS_CAPTCHA'] and not broadcaster:
|
||||
if user is not None:
|
||||
user['last']['seen'] = timestamp
|
||||
response = await f(timestamp, user, *args, **kwargs)
|
||||
elif fallback_to_token:
|
||||
#assert not broadcaster
|
||||
response = await f(timestamp, token, *args, **kwargs)
|
||||
else:
|
||||
raise Forbidden(Markup(
|
||||
f"You have not solved the access captcha. "
|
||||
f"<a href=\"{url_for('home', token=token)}\">"
|
||||
f"Click here."
|
||||
f"</a>"
|
||||
))
|
||||
else:
|
||||
user = generate_user(
|
||||
timestamp=timestamp,
|
||||
token=token,
|
||||
broadcaster=broadcaster,
|
||||
presence=Presence.NOTWATCHING,
|
||||
)
|
||||
USERS_BY_TOKEN[token] = user
|
||||
|
||||
# Add to the users update buffer
|
||||
USERS_UPDATE_BUFFER.add(token)
|
||||
if user is None:
|
||||
user = generate_and_add_user(timestamp, token, broadcaster)
|
||||
response = await f(timestamp, user, *args, **kwargs)
|
||||
|
||||
# Set cookie
|
||||
response = await f(timestamp, user, *args, **kwargs)
|
||||
if try_unquote(context.cookies.get('token')) != token:
|
||||
if token_from_cookie != token:
|
||||
response = await make_response(response)
|
||||
response.headers['Set-Cookie'] = f'token={quote(token)}; path=/'
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; img-src 'self'; style-src 'nonce-{{ csp }}';">
|
||||
<style nonce="{{ csp }}">
|
||||
body {
|
||||
background-color: #232327;
|
||||
color: #ddd;
|
||||
font-family: sans-serif;
|
||||
font-size: 14pt;
|
||||
display: grid;
|
||||
grid-template-rows: calc(50% - 4rem) 1fr;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
img {
|
||||
margin: auto auto 1rem;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
form {
|
||||
margin: 0 auto auto;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
input[name="answer"] {
|
||||
background-color: #47474a;
|
||||
border: 1px solid #777;
|
||||
border-radius: 2px;
|
||||
color: #ddd;
|
||||
font-size: 14pt;
|
||||
padding: 1px 3px;
|
||||
width: 10ch;
|
||||
}
|
||||
input[name="answer"]:hover {
|
||||
background-color: #37373a;
|
||||
transition: 0.25s;
|
||||
}
|
||||
input[type="submit"] {
|
||||
font-size: 14pt;
|
||||
}
|
||||
p {
|
||||
grid-column: 1 / span 2;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="{{ url_for('captcha', digest=digest) }}" width="72" height="30">
|
||||
<form action="{{ url_for('access', token=token) }}" method="post">
|
||||
<input type="hidden" name="digest" value="{{ digest }}">
|
||||
<input name="answer" placeholder="Captcha" required autofocus>
|
||||
<input type="submit" value="Submit">
|
||||
{% if failure is not none %}<p>{{ failure }}</p>{% endif %}
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,42 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ error.code }} {{ error.name }}</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #18181a;
|
||||
color: #ddd;
|
||||
font-family: sans-serif;
|
||||
font-size: 14pt;
|
||||
margin: 24pt 16pt;
|
||||
text-align: center;
|
||||
text-shadow: 2px 0px 1px orangered;
|
||||
}
|
||||
main {
|
||||
margin: auto;
|
||||
max-width: 52rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32pt;
|
||||
}
|
||||
a {
|
||||
color: #42a5d7;
|
||||
}
|
||||
code {
|
||||
background-color: #333;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{{ error.code }} {{ error.name }}</h1>
|
||||
{% if error.description != error.__class__.description %}
|
||||
<p>{{ error.description }}</p>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -259,7 +259,7 @@ def create_eyes(timestamp, user, headers):
|
|||
# Treat eyes as a stack, do not create new eyes if it would
|
||||
# cause the limit to be exceeded
|
||||
if not CONFIG['FLOOD_VIDEO_OVERWRITE']:
|
||||
raise TooManyEyes
|
||||
raise TooManyEyes(len(user['eyes']['current']))
|
||||
# Treat eyes as a queue, expire old eyes upon creating new eyes
|
||||
# if the limit would have been exceeded otherwise
|
||||
elif user['eyes']['current']:
|
||||
|
|
|
@ -24,6 +24,9 @@ stream_initial_buffer = 3
|
|||
file = "title.txt"
|
||||
file_cache_lifetime = 0.5
|
||||
|
||||
[access]
|
||||
captcha = true
|
||||
|
||||
[captcha]
|
||||
lifetime = 1800
|
||||
fonts = []
|
||||
|
|
読み込み中…
新しいイシューから参照