Merge branch 'dev'

このコミットが含まれているのは:
n9k 2022-06-22 08:58:12 +00:00
コミット 4992317ec9
23個のファイルの変更443行の追加145行の削除

ファイルの表示

@ -50,6 +50,10 @@ to know what they do:
locations of fonts for the captcha, leaving it blank will use the locations of fonts for the captcha, leaving it blank will use the
default font default font
* `access/captcha`:
if true, users must complete a captcha before accessing the site
proper
Run it: Run it:
```sh ```sh
python -m anonstream python -m anonstream

ファイルの表示

@ -12,7 +12,7 @@ from anonstream.quart import Quart
compress = Compress() compress = Compress()
def create_app(toml_config): def create_app(toml_config):
app = Quart('anonstream') app = Quart('anonstream', static_folder=None)
app.jinja_options['trim_blocks'] = True app.jinja_options['trim_blocks'] = True
app.jinja_options['lstrip_blocks'] = True app.jinja_options['lstrip_blocks'] = True
@ -23,8 +23,8 @@ def create_app(toml_config):
# Compress some responses # Compress some responses
compress.init_app(app) compress.init_app(app)
app.config.update({ app.config.update({
"COMPRESS_MIN_SIZE": 2048, 'COMPRESS_MIN_SIZE': 2048,
"COMPRESS_LEVEL": 9, 'COMPRESS_LEVEL': 9,
}) })
# Global state: messages, users, captchas # Global state: messages, users, captchas
@ -38,6 +38,8 @@ def create_app(toml_config):
app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS']) app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS'])
app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY']) app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY'])
app.failures = OrderedDict()
# State for tasks # State for tasks
app.users_update_buffer = set() app.users_update_buffer = set()
app.stream_title = None app.stream_title = None
@ -77,7 +79,6 @@ def create_app(toml_config):
) )
app.add_background_task(start_event_server) app.add_background_task(start_event_server)
# Create routes and background tasks # Create routes and background tasks
import anonstream.routes import anonstream.routes
import anonstream.tasks import anonstream.tasks

ファイルの表示

@ -31,7 +31,7 @@ parser = argparse.ArgumentParser(
parser.add_argument( parser.add_argument(
'--config', '-c', '--config', '-c',
metavar='FILE', metavar='FILE',
default=os.environ.get('ANONSTREAM_CONFIG', 'config.toml'), default=os.environ.get('ANONSTREAM_CONFIG', DEFAULT_CONFIG),
help=( help=(
'location of config.toml ' 'location of config.toml '
f'(default: $ANONSTREAM_CONFIG or {want_rel(DEFAULT_CONFIG)})' f'(default: $ANONSTREAM_CONFIG or {want_rel(DEFAULT_CONFIG)})'
@ -48,4 +48,4 @@ args = parser.parse_args()
with open(args.config) as fp: with open(args.config) as fp:
config = toml.load(fp) config = toml.load(fp)
app = create_app(config) app = create_app(config)
uvicorn.run(app, port=args.port) uvicorn.run(app, port=args.port, server_header=False)

23
anonstream/access.py ノーマルファイル
ファイルの表示

@ -0,0 +1,23 @@
import time
from quart import current_app
CONFIG = current_app.config
FAILURES = current_app.failures
def add_failure(message):
timestamp = time.time_ns() // 1_000_000
while timestamp in FAILURES:
timestamp += 1
FAILURES[timestamp] = message
while len(FAILURES) > CONFIG['MAX_FAILURES']:
FAILURES.popitem(last=False)
return timestamp
def pop_failure(failure_id):
try:
return FAILURES.pop(failure_id)
except KeyError:
return None

ファイルの表示

@ -33,8 +33,6 @@ def get_random_captcha_digest():
solution = generate_random_captcha_solution() solution = generate_random_captcha_solution()
digest = generate_captcha_digest(CAPTCHA_SIGNER, salt, solution) digest = generate_captcha_digest(CAPTCHA_SIGNER, salt, solution)
CAPTCHAS[digest] = {'solution': solution} CAPTCHAS[digest] = {'solution': solution}
while len(CAPTCHAS) >= CONFIG['MAX_CAPTCHAS']:
CAPTCHAS.popitem(last=False)
return digest return digest

ファイルの表示

@ -108,7 +108,7 @@ def add_chat_message(user, nonce, comment, ignore_empty=False):
MESSAGES_BY_ID[message_id] = message MESSAGES_BY_ID[message_id] = message
while len(MESSAGES_BY_ID) > CONFIG['MAX_CHAT_MESSAGES']: while len(MESSAGES_BY_ID) > CONFIG['MAX_CHAT_MESSAGES']:
MESSAGES_BY_ID.pop(last=False) MESSAGES_BY_ID.popitem(last=False)
# Notify event sockets that a chat message was added # Notify event sockets that a chat message was added
notify_event_sockets({ notify_event_sockets({

ファイルの表示

@ -19,6 +19,7 @@ def update_flask_from_toml(toml_config, flask_config):
'AUTH_USERNAME': toml_config['auth']['username'], 'AUTH_USERNAME': toml_config['auth']['username'],
'AUTH_PWHASH': auth_pwhash, 'AUTH_PWHASH': auth_pwhash,
'AUTH_TOKEN': generate_token(), 'AUTH_TOKEN': generate_token(),
'ACCESS_CAPTCHA': toml_config['access']['captcha'],
}) })
for flask_section in toml_to_flask_sections(toml_config): for flask_section in toml_to_flask_sections(toml_config):
flask_config.update(flask_section) flask_config.update(flask_section)
@ -83,11 +84,14 @@ def toml_to_flask_section_names(config):
def toml_to_flask_section_memory(config): def toml_to_flask_section_memory(config):
cfg = config['memory'] cfg = config['memory']
assert cfg['states'] >= 0 assert cfg['states'] >= 0
assert cfg['captchas'] >= 1
assert cfg['failures'] >= 0
assert cfg['chat_scrollback'] >= 0 assert cfg['chat_scrollback'] >= 0
assert cfg['chat_messages'] >= cfg['chat_scrollback'] assert cfg['chat_messages'] >= cfg['chat_scrollback']
return { return {
'MAX_STATES': cfg['states'], 'MAX_STATES': cfg['states'],
'MAX_CAPTCHAS': cfg['captchas'], 'MAX_CAPTCHAS': cfg['captchas'],
'MAX_FAILURES': cfg['failures'],
'MAX_CHAT_MESSAGES': cfg['chat_messages'], 'MAX_CHAT_MESSAGES': cfg['chat_messages'],
'MAX_CHAT_SCROLLBACK': cfg['chat_scrollback'], 'MAX_CHAT_SCROLLBACK': cfg['chat_scrollback'],
} }

ファイルの表示

@ -44,6 +44,7 @@ def generate_user(timestamp, token, broadcaster, presence):
'seen': timestamp, 'seen': timestamp,
'watching': -inf, 'watching': -inf,
'eyes': -inf, 'eyes': -inf,
'reading': -inf,
}, },
'presence': presence, 'presence': presence,
'linespan': deque(), 'linespan': deque(),

ファイルの表示

@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k> # SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import anonstream.routes.errors
import anonstream.routes.core import anonstream.routes.core
import anonstream.routes.websocket import anonstream.routes.websocket
import anonstream.routes.nojs import anonstream.routes.nojs

ファイルの表示

@ -3,64 +3,126 @@
import math import math
from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort from quart import current_app, request, render_template, abort, make_response, redirect, url_for, send_from_directory
from werkzeug.exceptions import TooManyRequests 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.segments import segments, StopSendingSegments
from anonstream.stream import is_online, get_stream_uptime from anonstream.stream import is_online, get_stream_uptime
from anonstream.user import watched, create_eyes, renew_eyes, EyesException, RatelimitedEyes from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes, TooManyEyes
from anonstream.routes.wrappers import with_user_from, auth_required 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 from anonstream.utils.security import generate_csp
CAPTCHA_SIGNER = current_app.captcha_signer
STATIC_DIRECTORY = current_app.root_path / 'static'
@current_app.route('/') @current_app.route('/')
@with_user_from(request) @with_user_from(request, fallback_to_token=True)
async def home(user): async def home(timestamp, user_or_token):
return await render_template( match user_or_token:
'home.html', case str() | None:
csp=generate_csp(), failure_id = request.args.get('failure', type=int)
user=user, 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') @current_app.route('/stream.mp4')
@with_user_from(request) @with_user_from(request)
async def stream(user): async def stream(timestamp, user):
if not is_online(): if not is_online():
return abort(404) raise NotFound('The stream is offline.')
else:
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):
try: try:
renew_eyes(user, eyes_id, just_read_new_segment=True) eyes_id = create_eyes(user, dict(request.headers))
except EyesException as e: except RatelimitedEyes as e:
raise StopSendingSegments(f'eyes {eyes_id} not allowed: {e!r}') from e retry_after, *_ = e.args
print(f'{uri}: {eyes_id}~{user["token"]}') error = TooManyRequests(
watched(user) f'You have requested the stream recently. '
f'Try again in {retry_after:.1f} seconds.'
generator = segments(segment_read_hook, token=user['token']) )
response = await make_response(generator) response = await current_app.handle_http_exception(error)
response.headers['Content-Type'] = 'video/mp4' response = await make_response(response)
response.timeout = None 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 return response
@current_app.route('/login') @current_app.route('/login')
@auth_required @auth_required
async def login(): async def login():
return redirect(url_for('home')) return redirect(url_for('home'), 303)
@current_app.route('/captcha.jpg') @current_app.route('/captcha.jpg')
@with_user_from(request) @with_user_from(request, fallback_to_token=True)
async def captcha(user): async def captcha(timestamp, user_or_token):
digest = request.args.get('digest', '') digest = request.args.get('digest', '')
image = get_captcha_image(digest) image = get_captcha_image(digest)
if image is None: if image is None:
return abort(410) return abort(410)
else: else:
return image, {'Content-Type': 'image/jpeg'} return image, {'Content-Type': 'image/jpeg'}
@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)
@current_app.route('/static/<filename>')
@with_user_from(request)
@clean_cache_headers
async def static(timestamp, user, filename):
return await send_from_directory(STATIC_DIRECTORY, filename)

8
anonstream/routes/errors.py ノーマルファイル
ファイルの表示

@ -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)

ファイルの表示

@ -6,7 +6,7 @@ from quart import current_app, request, render_template, redirect, url_for, esca
from anonstream.captcha import get_random_captcha_digest_for from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import add_chat_message, Rejected from anonstream.chat import add_chat_message, Rejected
from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership
from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha, reading
from anonstream.routes.wrappers import with_user_from, render_template_with_etag from anonstream.routes.wrappers import with_user_from, render_template_with_etag
from anonstream.helpers.chat import get_scrollback from anonstream.helpers.chat import get_scrollback
from anonstream.helpers.user import get_default_name from anonstream.helpers.user import get_default_name
@ -19,7 +19,7 @@ USERS_BY_TOKEN = current_app.users_by_token
@current_app.route('/stream.html') @current_app.route('/stream.html')
@with_user_from(request) @with_user_from(request)
async def nojs_stream(user): async def nojs_stream(timestamp, user):
return await render_template( return await render_template(
'nojs_stream.html', 'nojs_stream.html',
csp=generate_csp(), csp=generate_csp(),
@ -29,7 +29,7 @@ async def nojs_stream(user):
@current_app.route('/info.html') @current_app.route('/info.html')
@with_user_from(request) @with_user_from(request)
async def nojs_info(user): async def nojs_info(timestamp, user):
update_presence(user) update_presence(user)
uptime, viewership = get_stream_uptime_and_viewership() uptime, viewership = get_stream_uptime_and_viewership()
return await render_template( return await render_template(
@ -45,7 +45,8 @@ async def nojs_info(user):
@current_app.route('/chat/messages.html') @current_app.route('/chat/messages.html')
@with_user_from(request) @with_user_from(request)
async def nojs_chat_messages(user): async def nojs_chat_messages(timestamp, user):
reading(user)
return await render_template_with_etag( return await render_template_with_etag(
'nojs_chat_messages.html', 'nojs_chat_messages.html',
{'csp': generate_csp()}, {'csp': generate_csp()},
@ -59,12 +60,13 @@ async def nojs_chat_messages(user):
@current_app.route('/chat/messages') @current_app.route('/chat/messages')
@with_user_from(request) @with_user_from(request)
async def nojs_chat_messages_redirect(user): 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') @current_app.route('/chat/users.html')
@with_user_from(request) @with_user_from(request)
async def nojs_chat_users(user): async def nojs_chat_users(timestamp, user):
users_by_presence = get_users_by_presence() users_by_presence = get_users_by_presence()
return await render_template_with_etag( return await render_template_with_etag(
'nojs_chat_users.html', 'nojs_chat_users.html',
@ -79,7 +81,7 @@ async def nojs_chat_users(user):
@current_app.route('/chat/form.html') @current_app.route('/chat/form.html')
@with_user_from(request) @with_user_from(request)
async def nojs_chat_form(user): async def nojs_chat_form(timestamp, user):
state_id = request.args.get('state', type=int) state_id = request.args.get('state', type=int)
state = pop_state(user, state_id) state = pop_state(user, state_id)
prefer_chat_form = request.args.get('landing') != 'appearance' prefer_chat_form = request.args.get('landing') != 'appearance'
@ -99,7 +101,7 @@ async def nojs_chat_form(user):
@current_app.post('/chat/form') @current_app.post('/chat/form')
@with_user_from(request) @with_user_from(request)
async def nojs_chat_form_redirect(user): async def nojs_chat_form_redirect(timestamp, user):
comment = (await request.form).get('comment', '') comment = (await request.form).get('comment', '')
if comment: if comment:
state_id = add_state( state_id = add_state(
@ -108,11 +110,12 @@ async def nojs_chat_form_redirect(user):
) )
else: else:
state_id = None 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') @current_app.post('/chat/message')
@with_user_from(request) @with_user_from(request)
async def nojs_submit_message(user): async def nojs_submit_message(timestamp, user):
form = await request.form form = await request.form
comment = form.get('comment', '') comment = form.get('comment', '')
@ -150,16 +153,17 @@ async def nojs_submit_message(user):
if message_was_added: if message_was_added:
deverify(user) deverify(user)
return redirect(url_for( url = url_for(
'nojs_chat_form', 'nojs_chat_form',
token=user['token'], token=user['token'],
landing='chat', landing='chat',
state=state_id, state=state_id,
)) )
return redirect(url, 303)
@current_app.post('/chat/appearance') @current_app.post('/chat/appearance')
@with_user_from(request) @with_user_from(request)
async def nojs_submit_appearance(user): async def nojs_submit_appearance(timestamp, user):
form = await request.form form = await request.form
# Collect form data # Collect form data
@ -187,9 +191,10 @@ async def nojs_submit_appearance(user):
notice = 'Changed appearance' notice = 'Changed appearance'
state_id = add_state(user, notice=notice, verbose=len(errors) > 1) state_id = add_state(user, notice=notice, verbose=len(errors) > 1)
return redirect(url_for( url = url_for(
'nojs_chat_form', 'nojs_chat_form',
token=user['token'], token=user['token'],
landing='appearance' if errors else 'chat', landing='appearance' if errors else 'chat',
state=state_id, state=state_id,
)) )
return redirect(url, 303)

ファイルの表示

@ -2,25 +2,28 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import asyncio import asyncio
from math import inf
from quart import current_app, websocket from quart import current_app, websocket
from anonstream.user import see from anonstream.user import see, reading
from anonstream.websocket import websocket_outbound, websocket_inbound from anonstream.websocket import websocket_outbound, websocket_inbound
from anonstream.routes.wrappers import with_user_from from anonstream.routes.wrappers import with_user_from
@current_app.websocket('/live') @current_app.websocket('/live')
@with_user_from(websocket) @with_user_from(websocket, fallback_to_token=True)
async def live(user): async def live(timestamp, user_or_token):
queue = asyncio.Queue(maxsize=0) match user_or_token:
user['websockets'][queue] = -inf case str() | None:
await websocket.send_json({'type': 'kick'})
await websocket.close(1001)
case dict() as user:
queue = asyncio.Queue()
user['websockets'][queue] = timestamp
reading(user, timestamp=timestamp)
producer = websocket_outbound(queue, user) producer = websocket_outbound(queue, user)
consumer = websocket_inbound(queue, user) consumer = websocket_inbound(queue, user)
try: try:
await asyncio.gather(producer, consumer) await asyncio.gather(producer, consumer)
finally: finally:
see(user) see(user)
user['websockets'].pop(queue) user['websockets'].pop(queue)

ファイルの表示

@ -5,16 +5,18 @@ import hashlib
import hmac import hmac
import re import re
import string import string
import time
from functools import wraps 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 werkzeug.security import check_password_hash
from anonstream.broadcast import broadcast from anonstream.broadcast import broadcast
from anonstream.user import see from anonstream.user import see
from anonstream.helpers.user import generate_user from anonstream.helpers.user import generate_user
from anonstream.utils.user import generate_token, Presence from anonstream.utils.user import generate_token, Presence
from anonstream.wrappers import get_timestamp
CONFIG = current_app.config CONFIG = current_app.config
MESSAGES = current_app.messages MESSAGES = current_app.messages
@ -31,13 +33,19 @@ TOKEN_ALPHABET = (
) )
RE_TOKEN = re.compile(r'[%s]{1,256}' % re.escape(TOKEN_ALPHABET)) RE_TOKEN = re.compile(r'[%s]{1,256}' % re.escape(TOKEN_ALPHABET))
def try_unquote(string):
if string is None:
return None
else:
return unquote(string)
def check_auth(context): def check_auth(context):
auth = context.authorization auth = context.authorization
return ( return (
auth is not None auth is not None
and auth.type == "basic" and auth.type == 'basic'
and auth.username == CONFIG["AUTH_USERNAME"] and auth.username == CONFIG['AUTH_USERNAME']
and check_password_hash(CONFIG["AUTH_PWHASH"], auth.password) and check_password_hash(CONFIG['AUTH_PWHASH'], auth.password)
) )
def auth_required(f): def auth_required(f):
@ -50,64 +58,98 @@ def auth_required(f):
'their terminal.' 'their terminal.'
) )
if request.authorization is None: if request.authorization is None:
body = ( description = hint
f'<!doctype html>\n'
f'<p>{hint}</p>\n'
)
else: else:
body = ( description = Markup(
f'<!doctype html>\n' f'Wrong username or password. Refresh the page to try again. '
f'<p>Wrong username or password. Refresh the page to try again.</p>\n' f'<br>'
f'<p>{hint}</p>\n' 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 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): def with_user_from_context(f):
@wraps(f) @wraps(f)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
timestamp = int(time.time()) timestamp = get_timestamp()
# Check if broadcaster # Get token
broadcaster = check_auth(context) 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: if broadcaster:
token = CONFIG['AUTH_TOKEN'] token = CONFIG['AUTH_TOKEN']
elif CONFIG['ACCESS_CAPTCHA']:
token = token_from_context
else: else:
token = ( token = token_from_context or generate_token()
context.args.get('token')
or context.cookies.get('token')
or generate_token()
)
if hmac.compare_digest(token, CONFIG['AUTH_TOKEN']):
raise abort(401)
# Reject invalid tokens # Reject invalid tokens
if not RE_TOKEN.fullmatch(token): if isinstance(token, str) and not RE_TOKEN.fullmatch(token):
raise abort(400) 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) user = USERS_BY_TOKEN.get(token)
if user is not None: if CONFIG['ACCESS_CAPTCHA'] and not broadcaster:
see(user) 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: else:
user = generate_user( if user is None:
timestamp=timestamp, user = generate_and_add_user(timestamp, token, broadcaster)
token=token, response = await f(timestamp, user, *args, **kwargs)
broadcaster=broadcaster,
presence=Presence.NOTWATCHING,
)
USERS_BY_TOKEN[token] = user
# Add to the users update buffer
USERS_UPDATE_BUFFER.add(token)
# Set cookie # Set cookie
response = await f(user, *args, **kwargs) if token_from_cookie != token:
if context.cookies.get('token') != token:
response = await make_response(response) response = await make_response(response)
response.headers['Set-Cookie'] = f'token={token}; path=/' response.headers['Set-Cookie'] = f'token={quote(token)}; path=/'
return response return response
return wrapper return wrapper
@ -127,3 +169,28 @@ async def render_template_with_etag(template, deferred_kwargs, **kwargs):
**kwargs, **kwargs,
) )
return rendered_template, {'ETag': etag} return rendered_template, {'ETag': etag}
def clean_cache_headers(f):
@wraps(f)
async def wrapper(*args, **kwargs):
response = await f(*args, **kwargs)
# Remove Last-Modified
try:
response.headers.pop('Last-Modified')
except KeyError:
pass
# Obfuscate ETag
try:
original_etag = response.headers['ETag']
except KeyError:
pass
else:
parts = CONFIG['SECRET_KEY'] + b'etag\0' + original_etag.encode()
tag = hashlib.sha256(parts).hexdigest()
response.headers['ETag'] = f'"{tag}"'
return response
return wrapper

ファイルの表示

@ -1,5 +1,5 @@
/** /**
* SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k] * SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
* SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-License-Identifier: AGPL-3.0-or-later
**/ **/
@ -831,6 +831,11 @@ const on_websocket_message = (event) => {
ws.send(JSON.stringify(payload)); ws.send(JSON.stringify(payload));
break; break;
case "kick":
console.log("ws kick");
window.location.reload();
break;
default: default:
console.log("incomprehensible websocket message", receipt); console.log("incomprehensible websocket message", receipt);
} }

ファイルの表示

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
SPDX-FileCopyrightText: 2016 ulimicon [https://thenounproject.com/unlimicon/] SPDX-FileCopyrightText: 2016 ulimicon <https://thenounproject.com/unlimicon/>
SPDX-License-Identifier: CC-BY-3.0 SPDX-License-Identifier: CC-BY-3.0
--> -->
<svg width="243.55pt" height="243.55pt" version="1.1" viewBox="0 0 243.55 243.55" xmlns="http://www.w3.org/2000/svg"> <svg width="243.55pt" height="243.55pt" version="1.1" viewBox="0 0 243.55 243.55" xmlns="http://www.w3.org/2000/svg">

変更前

幅:  |  高さ:  |  サイズ: 1.1 KiB

変更後

幅:  |  高さ:  |  サイズ: 1.1 KiB

ファイルの表示

@ -1,5 +1,5 @@
/** /**
* SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k] * SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
* SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-License-Identifier: AGPL-3.0-or-later
**/ **/

60
anonstream/templates/captcha.html ノーマルファイル
ファイルの表示

@ -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>

42
anonstream/templates/error.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>

ファイルの表示

@ -7,7 +7,7 @@ from math import inf
from quart import current_app from quart import current_app
from anonstream.wrappers import try_except_log, with_timestamp from anonstream.wrappers import try_except_log, with_timestamp, get_timestamp
from anonstream.helpers.user import get_default_name, 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.captcha import check_captcha_digest, Answer
from anonstream.helpers.tripcode import generate_tripcode from anonstream.helpers.tripcode import generate_tripcode
@ -136,11 +136,18 @@ def delete_tripcode(user):
def see(timestamp, user): def see(timestamp, user):
user['last']['seen'] = timestamp user['last']['seen'] = timestamp
@with_timestamp() def watching(user, timestamp=None):
def watched(timestamp, user): if timestamp is None:
timestamp = get_timestamp()
user['last']['seen'] = timestamp user['last']['seen'] = timestamp
user['last']['watching'] = timestamp user['last']['watching'] = timestamp
def reading(user, timestamp=None):
if timestamp is None:
timestamp = get_timestamp()
user['last']['seen'] = timestamp
user['last']['reading'] = timestamp
@with_timestamp() @with_timestamp()
def get_all_users_for_websocket(timestamp): def get_all_users_for_websocket(timestamp):
return { return {
@ -167,19 +174,16 @@ def verify(user, digest, answer):
@with_timestamp() @with_timestamp()
def deverify(timestamp, user): def deverify(timestamp, user):
if not user['verified']: if user['verified']:
return n_user_messages = 0
for message in reversed(MESSAGES):
n_user_messages = 0 message_sent_ago = timestamp - message['timestamp']
for message in reversed(MESSAGES): if message_sent_ago >= CONFIG['FLOOD_MESSAGE_DURATION']:
message_sent_ago = timestamp - message['timestamp'] break
if message_sent_ago >= CONFIG['FLOOD_MESSAGE_DURATION']: elif message['token'] == user['token']:
break n_user_messages += 1
elif message['token'] == user['token']: if n_user_messages >= CONFIG['FLOOD_MESSAGE_THRESHOLD']:
n_user_messages += 1 user['verified'] = False
if n_user_messages >= CONFIG['FLOOD_MESSAGE_THRESHOLD']:
user['verified'] = False
def _update_presence(timestamp, user): def _update_presence(timestamp, user):
old, user['presence'] = user['presence'], get_presence(timestamp, user) old, user['presence'] = user['presence'], get_presence(timestamp, user)
@ -255,7 +259,7 @@ def create_eyes(timestamp, user, headers):
# Treat eyes as a stack, do not create new eyes if it would # Treat eyes as a stack, do not create new eyes if it would
# cause the limit to be exceeded # cause the limit to be exceeded
if not CONFIG['FLOOD_VIDEO_OVERWRITE']: 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 # Treat eyes as a queue, expire old eyes upon creating new eyes
# if the limit would have been exceeded otherwise # if the limit would have been exceeded otherwise
elif user['eyes']['current']: elif user['eyes']['current']:

ファイルの表示

@ -9,7 +9,7 @@ from quart import current_app, websocket
from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
from anonstream.captcha import get_random_captcha_digest_for from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected 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, try_change_appearance from anonstream.user import get_all_users_for_websocket, see, reading, verify, deverify, BadCaptcha, try_change_appearance
from anonstream.wrappers import with_timestamp from anonstream.wrappers import with_timestamp
from anonstream.utils.chat import generate_nonce from anonstream.utils.chat import generate_nonce
from anonstream.utils.websocket import parse_websocket_data, Malformed, WS from anonstream.utils.websocket import parse_websocket_data, Malformed, WS
@ -75,6 +75,7 @@ async def websocket_inbound(queue, user):
@with_timestamp() @with_timestamp()
def handle_inbound_pong(timestamp, queue, user): def handle_inbound_pong(timestamp, queue, user):
print(f'[pong] {user["token"]}') print(f'[pong] {user["token"]}')
reading(user, timestamp=timestamp)
user['websockets'][queue] = timestamp user['websockets'][queue] = timestamp
return None return None

ファイルの表示

@ -16,13 +16,18 @@ def with_function_call(fn, *fn_args, **fn_kwargs):
def with_constant(x): def with_constant(x):
return with_function_call(lambda: x) return with_function_call(lambda: x)
def with_timestamp(monotonic=False, precise=False): def get_timestamp(monotonic=False, precise=False):
n = 1_000_000_000 n = 1_000_000_000
if monotonic: if monotonic:
fn = precise and time.monotonic or (lambda: time.monotonic_ns() // n) timestamp = precise and time.monotonic() or time.monotonic_ns() // n
else: else:
fn = precise and time.time or (lambda: time.time_ns() // n) timestamp = precise and time.time() or time.time_ns() // n
return with_function_call(fn) return timestamp
def with_timestamp(monotonic=False, precise=False):
def get_timestamp_specific():
return get_timestamp(monotonic=monotonic, precise=precise)
return with_function_call(get_timestamp_specific)
def try_except_log(errors, exception_class): def try_except_log(errors, exception_class):
def try_except_log_specific(f): def try_except_log_specific(f):

ファイルの表示

@ -24,6 +24,9 @@ stream_initial_buffer = 3
file = "title.txt" file = "title.txt"
file_cache_lifetime = 0.5 file_cache_lifetime = 0.5
[access]
captcha = true
[captcha] [captcha]
lifetime = 1800 lifetime = 1800
fonts = [] fonts = []
@ -35,6 +38,7 @@ foreground_color = "#dddddd"
[memory] [memory]
states = 32 states = 32
captchas = 256 captchas = 256
failures = 256
chat_messages = 8192 chat_messages = 8192
chat_scrollback = 256 chat_scrollback = 256