Merge branch 'dev'
このコミットが含まれているのは:
コミット
4992317ec9
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
**/
|
**/
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
|
|
||||||
|
|
読み込み中…
新しいイシューから参照