2022-06-16 10:12:37 +09:00
|
|
|
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
2022-03-07 23:51:59 +09:00
|
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
|
2022-06-14 12:33:09 +09:00
|
|
|
import math
|
2022-07-15 01:31:11 +09:00
|
|
|
import re
|
2022-06-14 12:33:09 +09:00
|
|
|
|
2022-06-22 16:52:07 +09:00
|
|
|
from quart import current_app, request, render_template, abort, make_response, redirect, url_for, send_from_directory
|
2022-06-25 14:02:02 +09:00
|
|
|
from werkzeug.exceptions import Forbidden, NotFound, TooManyRequests
|
2022-02-16 18:55:30 +09:00
|
|
|
|
2022-06-22 14:00:43 +09:00
|
|
|
from anonstream.access import add_failure, pop_failure
|
|
|
|
from anonstream.captcha import get_captcha_image, get_random_captcha_digest
|
2022-06-14 09:36:36 +09:00
|
|
|
from anonstream.segments import segments, StopSendingSegments
|
2022-02-22 12:57:48 +09:00
|
|
|
from anonstream.stream import is_online, get_stream_uptime
|
2022-06-25 14:02:02 +09:00
|
|
|
from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes, TooManyEyes, ensure_allowedness, Blacklisted, SecretClub
|
2022-07-15 01:31:11 +09:00
|
|
|
from anonstream.routes.wrappers import with_user_from, auth_required, generate_and_add_user, clean_cache_headers, etag_conditional
|
2022-06-22 14:00:43 +09:00
|
|
|
from anonstream.helpers.captcha import check_captcha_digest, Answer
|
2022-03-07 16:11:49 +09:00
|
|
|
from anonstream.utils.security import generate_csp
|
2022-06-23 12:28:32 +09:00
|
|
|
from anonstream.utils.user import identifying_string
|
2022-06-25 13:28:13 +09:00
|
|
|
from anonstream.wrappers import with_timestamp
|
2022-02-16 18:55:30 +09:00
|
|
|
|
2022-06-22 14:00:43 +09:00
|
|
|
CAPTCHA_SIGNER = current_app.captcha_signer
|
2022-06-19 17:26:38 +09:00
|
|
|
STATIC_DIRECTORY = current_app.root_path / 'static'
|
|
|
|
|
2022-02-16 18:55:30 +09:00
|
|
|
@current_app.route('/')
|
2022-06-25 14:02:02 +09:00
|
|
|
@with_user_from(request, fallback_to_token=True, ignore_allowedness=True)
|
2022-06-22 14:00:43 +09:00
|
|
|
async def home(timestamp, user_or_token):
|
|
|
|
match user_or_token:
|
2022-06-25 14:02:02 +09:00
|
|
|
case str() | None as token:
|
2022-06-22 14:00:43 +09:00
|
|
|
failure_id = request.args.get('failure', type=int)
|
|
|
|
response = await render_template(
|
|
|
|
'captcha.html',
|
|
|
|
csp=generate_csp(),
|
2022-06-25 14:02:02 +09:00
|
|
|
token=token,
|
2022-06-22 14:00:43 +09:00
|
|
|
digest=get_random_captcha_digest(),
|
|
|
|
failure=pop_failure(failure_id),
|
|
|
|
)
|
2022-06-25 14:02:02 +09:00
|
|
|
case dict() as user:
|
|
|
|
try:
|
|
|
|
ensure_allowedness(user, timestamp=timestamp)
|
|
|
|
except Blacklisted:
|
|
|
|
raise Forbidden('You have been blacklisted.')
|
|
|
|
except SecretClub:
|
|
|
|
# TODO allow changing tripcode
|
|
|
|
raise Forbidden('You have not been whitelisted.')
|
|
|
|
else:
|
|
|
|
response = await render_template(
|
|
|
|
'home.html',
|
|
|
|
csp=generate_csp(),
|
|
|
|
user=user,
|
|
|
|
version=current_app.version,
|
|
|
|
)
|
2022-06-22 14:00:43 +09:00
|
|
|
return response
|
2022-02-16 18:55:30 +09:00
|
|
|
|
|
|
|
@current_app.route('/stream.mp4')
|
|
|
|
@with_user_from(request)
|
2022-06-19 17:51:42 +09:00
|
|
|
async def stream(timestamp, user):
|
2022-02-22 12:57:48 +09:00
|
|
|
if not is_online():
|
2022-06-22 16:52:07 +09:00
|
|
|
raise NotFound('The stream is offline.')
|
|
|
|
else:
|
2022-06-14 09:36:36 +09:00
|
|
|
try:
|
2022-06-29 12:03:49 +09:00
|
|
|
eyes_id = create_eyes(user, tuple(request.headers))
|
2022-06-22 16:52:07 +09:00
|
|
|
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:
|
2022-06-25 13:28:13 +09:00
|
|
|
@with_timestamp(precise=True)
|
|
|
|
def segment_read_hook(timestamp, uri):
|
|
|
|
user['last']['seen'] = timestamp
|
2022-06-22 16:52:07 +09:00
|
|
|
try:
|
2022-06-25 13:28:13 +09:00
|
|
|
renew_eyes(timestamp, user, eyes_id, just_read_new_segment=True)
|
2022-06-22 16:52:07 +09:00
|
|
|
except EyesException as e:
|
|
|
|
raise StopSendingSegments(
|
|
|
|
f'eyes {eyes_id} not allowed: {e!r}'
|
|
|
|
) from e
|
2022-06-25 13:28:13 +09:00
|
|
|
else:
|
|
|
|
user['last']['watching'] = timestamp
|
2022-06-23 12:28:32 +09:00
|
|
|
print(f'{uri}: \033[37m{eyes_id}\033[0m~{identifying_string(user)}')
|
|
|
|
generator = segments(segment_read_hook, token=f'\033[35m{user["token"]}\033[0m')
|
2022-06-22 16:52:07 +09:00
|
|
|
response = await make_response(generator)
|
|
|
|
response.headers['Content-Type'] = 'video/mp4'
|
|
|
|
response.timeout = None
|
2022-02-16 18:55:30 +09:00
|
|
|
return response
|
|
|
|
|
|
|
|
@current_app.route('/login')
|
|
|
|
@auth_required
|
|
|
|
async def login():
|
2022-06-22 13:45:32 +09:00
|
|
|
return redirect(url_for('home'), 303)
|
2022-02-20 13:23:32 +09:00
|
|
|
|
|
|
|
@current_app.route('/captcha.jpg')
|
2022-06-22 14:00:43 +09:00
|
|
|
@with_user_from(request, fallback_to_token=True)
|
|
|
|
async def captcha(timestamp, user_or_token):
|
2022-02-20 13:23:32 +09:00
|
|
|
digest = request.args.get('digest', '')
|
|
|
|
image = get_captcha_image(digest)
|
|
|
|
if image is None:
|
|
|
|
return abort(410)
|
|
|
|
else:
|
2022-02-20 18:15:10 +09:00
|
|
|
return image, {'Content-Type': 'image/jpeg'}
|
2022-06-19 17:26:38 +09:00
|
|
|
|
2022-06-22 14:00:43 +09:00
|
|
|
@current_app.post('/access')
|
2022-06-25 14:02:02 +09:00
|
|
|
@with_user_from(request, fallback_to_token=True, ignore_allowedness=True)
|
2022-06-22 14:00:43 +09:00
|
|
|
async def access(timestamp, user_or_token):
|
|
|
|
match user_or_token:
|
2022-06-25 14:02:02 +09:00
|
|
|
case str() | None as token:
|
2022-06-22 14:00:43 +09:00
|
|
|
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
|
2022-06-23 11:53:38 +09:00
|
|
|
user = generate_and_add_user(timestamp, token, verified=True)
|
2022-06-22 14:00:43 +09:00
|
|
|
if failure_id is not None:
|
|
|
|
url = url_for('home', token=token, failure=failure_id)
|
|
|
|
raise abort(redirect(url, 303))
|
2022-06-25 14:02:02 +09:00
|
|
|
case dict() as user:
|
|
|
|
pass
|
2022-06-22 14:00:43 +09:00
|
|
|
url = url_for('home', token=user['token'])
|
|
|
|
return redirect(url, 303)
|
|
|
|
|
2022-06-19 17:26:38 +09:00
|
|
|
@current_app.route('/static/<filename>')
|
|
|
|
@with_user_from(request)
|
2022-07-15 01:31:11 +09:00
|
|
|
@etag_conditional
|
2022-06-19 17:26:38 +09:00
|
|
|
@clean_cache_headers
|
2022-06-19 17:51:42 +09:00
|
|
|
async def static(timestamp, user, filename):
|
2022-07-15 01:49:57 +09:00
|
|
|
response = await send_from_directory(STATIC_DIRECTORY, filename)
|
|
|
|
if filename in {'style.css', 'anonstream.js'}:
|
|
|
|
response.headers['Cache-Control'] = 'no-cache'
|
|
|
|
return response
|