コミットを比較
2 コミット
作成者 | SHA1 | 日付 |
---|---|---|
n9k | 0c6792894a | |
n9k | a1862b9080 |
|
@ -5,7 +5,7 @@ Recipe for livestreaming over Tor
|
|||
## Repo
|
||||
|
||||
The canonical location of this repo is
|
||||
<https://gitler.moe/ninya9k/anonstream>.
|
||||
<https://git.076.ne.jp/ninya9k/anonstream>.
|
||||
|
||||
These mirrors also exist:
|
||||
* <https://gitlab.com/ninya9k/anonstream>
|
||||
|
@ -18,7 +18,7 @@ Python with `python --version`.
|
|||
|
||||
Clone the repo:
|
||||
```sh
|
||||
git clone https://gitler.moe/ninya9k/anonstream.git
|
||||
git clone https://git.076.ne.jp/ninya9k/anonstream.git
|
||||
cd anonstream
|
||||
```
|
||||
|
||||
|
@ -136,8 +136,8 @@ anonstream is AGPL 3.0 or later, see
|
|||
* werkzeug <https://github.com/pallets/werkzeug>
|
||||
([BSD 3-Clause][werkzeug])
|
||||
|
||||
[licence]: https://gitler.moe/ninya9k/anonstream/src/branch/master/LICENSES/AGPL-3.0-or-later.md
|
||||
[settings.svg]: https://gitler.moe/ninya9k/anonstream/src/branch/master/anonstream/static/settings.svg
|
||||
[licence]: https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/LICENSES/AGPL-3.0-or-later.md
|
||||
[settings.svg]: https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/anonstream/static/settings.svg
|
||||
|
||||
[aiofiles]: https://github.com/Tinche/aiofiles/blob/master/LICENSE
|
||||
[captcha]: https://github.com/lepture/captcha/blob/master/LICENSE
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
|
||||
from quart_compress import Compress
|
||||
|
@ -11,9 +12,10 @@ from anonstream.config import update_flask_from_toml
|
|||
from anonstream.emote import load_emote_schema
|
||||
from anonstream.quart import Quart
|
||||
from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer
|
||||
from anonstream.utils.locale import validate_locale, Nonconforming
|
||||
from anonstream.utils.user import generate_blank_allowedness
|
||||
|
||||
__version__ = '1.6.9'
|
||||
__version__ = '1.6.6'
|
||||
|
||||
def create_app(toml_config):
|
||||
app = Quart('anonstream', static_folder=None)
|
||||
|
@ -54,6 +56,22 @@ def create_app(toml_config):
|
|||
except (OSError, json.JSONDecodeError) as e:
|
||||
raise AssertionError(f'couldn\'t load emote schema: {e!r}') from e
|
||||
|
||||
# Read locales
|
||||
app.locales = {}
|
||||
for lang in app.config['LOCALE_OFFERED']:
|
||||
filepath = os.path.join(app.config['LOCALE_DIRECTORY'], f'{lang}.json')
|
||||
with open(filepath) as fp:
|
||||
locale = json.load(fp)
|
||||
try:
|
||||
validate_locale(locale)
|
||||
except Nonconforming as e:
|
||||
error, *_ = e.args
|
||||
assert False, f'error in locale {lang!r}: {error}'
|
||||
else:
|
||||
app.locales[lang] = locale
|
||||
app.lang = app.config['LOCALE_DEFAULT']
|
||||
app.locales[None] = app.locales[app.lang]
|
||||
|
||||
# State for tasks
|
||||
app.users_update_buffer = set()
|
||||
app.stream_title = None
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import argparse
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import time
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from quart import current_app
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import secrets
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import time
|
||||
|
@ -50,34 +50,24 @@ def add_chat_message(user, nonce, comment, ignore_empty=False):
|
|||
user['linespan'],
|
||||
))
|
||||
if total_recent_linespan > CONFIG['FLOOD_LINE_THRESHOLD']:
|
||||
raise Rejected(
|
||||
f'Chat overuse in the last '
|
||||
f'{CONFIG["FLOOD_LINE_DURATION"]:.0f} seconds'
|
||||
)
|
||||
raise Rejected('message_ratelimited', CONFIG['FLOOD_LINE_THRESHOLD'])
|
||||
|
||||
# Check message
|
||||
message_id = generate_nonce_hash(nonce)
|
||||
if message_id in MESSAGES_BY_ID:
|
||||
raise Rejected('Discarded suspected duplicate message')
|
||||
raise Rejected('message_suspected_duplicate')
|
||||
if len(comment) == 0:
|
||||
raise Rejected('Message was empty')
|
||||
raise Rejected('message_empty')
|
||||
if len(comment.strip()) == 0:
|
||||
raise Rejected('Message was practically empty')
|
||||
raise Rejected('message_practically_empty')
|
||||
if len(comment) > CONFIG['CHAT_COMMENT_MAX_LENGTH']:
|
||||
raise Rejected(
|
||||
f'Message exceeded {CONFIG["CHAT_COMMENT_MAX_LENGTH"]} chars'
|
||||
)
|
||||
raise Rejected('message_too_long', CONFIG['CHAT_COMMENT_MAX_LENGTH'])
|
||||
|
||||
if comment.count('\n') + 1 > CONFIG['CHAT_COMMENT_MAX_LINES']:
|
||||
raise Rejected(
|
||||
f'Message exceeded {CONFIG["CHAT_COMMENT_MAX_LINES"]} lines'
|
||||
)
|
||||
raise Rejected('message_too_many_lines', CONFIG['CHAT_COMMENT_MAX_LINES'])
|
||||
linespan = get_approx_linespan(comment)
|
||||
if linespan > CONFIG['CHAT_COMMENT_MAX_LINES']:
|
||||
raise Rejected(
|
||||
f'Message would span {CONFIG["CHAT_COMMENT_MAX_LINES"]} '
|
||||
f'or more lines'
|
||||
)
|
||||
raise Rejected('message_too_many_apparent_lines', CONFIG['CHAT_COMMENT_MAX_LINES'])
|
||||
|
||||
# Record linespan
|
||||
linespan_tuple = (timestamp, linespan)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import os
|
||||
|
@ -40,6 +40,7 @@ def toml_to_flask_sections(config):
|
|||
toml_to_flask_section_captcha,
|
||||
toml_to_flask_section_nojs,
|
||||
toml_to_flask_section_emote,
|
||||
toml_to_flask_section_locale,
|
||||
)
|
||||
for toml_to_flask_section in TOML_TO_FLASK_SECTIONS:
|
||||
yield toml_to_flask_section(config)
|
||||
|
@ -171,3 +172,12 @@ def toml_to_flask_section_emote(config):
|
|||
return {
|
||||
'EMOTE_SCHEMA': cfg['schema'],
|
||||
}
|
||||
|
||||
def toml_to_flask_section_locale(config):
|
||||
cfg = config['locale']
|
||||
assert cfg['default'] in cfg['offered']
|
||||
return {
|
||||
'LOCALE_DEFAULT': cfg['default'],
|
||||
'LOCALE_OFFERED': cfg['offered'],
|
||||
'LOCALE_DIRECTORY': cfg['directory'],
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
class ControlSocketExit(Exception):
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from anonstream.control.spec import ParseException, Parsed
|
||||
from anonstream.control.spec.common import Str
|
||||
from anonstream.control.spec.methods.allowedness import SPEC as SPEC_ALLOWEDNESS
|
||||
from anonstream.control.spec.methods.chat import SPEC as SPEC_CHAT
|
||||
from anonstream.control.spec.methods.config import SPEC as SPEC_CONFIG
|
||||
from anonstream.control.spec.methods.emote import SPEC as SPEC_EMOTE
|
||||
from anonstream.control.spec.methods.help import SPEC as SPEC_HELP
|
||||
from anonstream.control.spec.methods.quit import SPEC as SPEC_QUIT
|
||||
from anonstream.control.spec.methods.title import SPEC as SPEC_TITLE
|
||||
from anonstream.control.spec.methods.tripcode import SPEC as SPEC_TRIPCODE
|
||||
from anonstream.control.spec.methods.user import SPEC as SPEC_USER
|
||||
|
||||
SPEC = Str({
|
||||
|
@ -21,8 +19,6 @@ SPEC = Str({
|
|||
'user': SPEC_USER,
|
||||
'allowednesss': SPEC_ALLOWEDNESS,
|
||||
'emote': SPEC_EMOTE,
|
||||
'config': SPEC_CONFIG,
|
||||
'tripcode': SPEC_TRIPCODE,
|
||||
})
|
||||
|
||||
async def parse(request):
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
class ParseException(Exception):
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import itertools
|
||||
import json
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.chat import delete_chat_messages
|
||||
from anonstream.control.exceptions import CommandFailed
|
||||
|
@ -13,8 +10,6 @@ from anonstream.control.spec.common import Str, End, Args, ArgsJsonString, ArgsU
|
|||
from anonstream.control.spec.utils import get_item, json_dumps_contiguous
|
||||
from anonstream.chat import add_chat_message, Rejected
|
||||
|
||||
MESSAGES = current_app.messages
|
||||
|
||||
class ArgsSeqs(Args):
|
||||
def consume(self, words, index):
|
||||
seqs = []
|
||||
|
@ -38,12 +33,13 @@ class ArgsSeqs(Args):
|
|||
async def cmd_chat_help():
|
||||
normal = ['chat', 'help']
|
||||
response = (
|
||||
'Usage: chat {show | delete SEQS | add USER NONCE COMMENT}\n'
|
||||
'Usage: chat delete SEQS\n'
|
||||
'Commands:\n'
|
||||
' chat show......................show all chat messages\n'
|
||||
#' chat show [MESSAGES]...........show chat messages\n'
|
||||
' chat delete SEQS...............delete chat messages\n'
|
||||
' chat add USER NONCE COMMENT....add chat message\n'
|
||||
'Definitions:\n'
|
||||
#' MESSAGES...................undefined\n'
|
||||
' SEQS.......................=SEQ [SEQ...]\n'
|
||||
' SEQ........................a chat message\'s seq, base-10 integer\n'
|
||||
' USER.......................={token TOKEN | hash HASH}\n'
|
||||
|
@ -54,11 +50,6 @@ async def cmd_chat_help():
|
|||
)
|
||||
return normal, response
|
||||
|
||||
async def cmd_chat_show():
|
||||
normal = ['chat', 'show']
|
||||
response = json.dumps(tuple(MESSAGES), separators=(',', ':')) + '\n'
|
||||
return normal, response
|
||||
|
||||
async def cmd_chat_delete(*seqs):
|
||||
delete_chat_messages(seqs)
|
||||
normal = ['chat', 'delete', *map(str, seqs)]
|
||||
|
@ -83,7 +74,6 @@ async def cmd_chat_add(user, nonce, comment):
|
|||
SPEC = Str({
|
||||
None: End(cmd_chat_help),
|
||||
'help': End(cmd_chat_help),
|
||||
'show': End(cmd_chat_show),
|
||||
'delete': ArgsSeqs(End(cmd_chat_delete)),
|
||||
'add': ArgsUser(ArgsJsonString(ArgsJsonString(End(cmd_chat_add)))),
|
||||
})
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.control.exceptions import CommandFailed
|
||||
from anonstream.control.spec.common import Str, End, ArgsString
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
||||
async def cmd_config_help():
|
||||
normal = ['config', 'help']
|
||||
response = (
|
||||
'Usage: config show OPTION\n'
|
||||
'Commands:\n'
|
||||
' config show OPTION....show entry in app.config\n'
|
||||
'Definitions:\n'
|
||||
' OPTION................app.config key, re:[A-Z0-9_]+\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
async def cmd_config_show(option):
|
||||
if option in {'SECRET_KEY', 'SECRET_KEY_STRING'}:
|
||||
raise CommandFailed('not going to show our secret key')
|
||||
try:
|
||||
value = CONFIG[option]
|
||||
except KeyError:
|
||||
raise CommandFailed(f'no config option with key {option!r}')
|
||||
try:
|
||||
value_json = json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
raise CommandFailed(f'value is not json serializable')
|
||||
normal = ['config', 'show']
|
||||
response = value_json + '\n'
|
||||
return normal, response
|
||||
|
||||
SPEC = Str({
|
||||
None: End(cmd_config_help),
|
||||
'help': End(cmd_config_help),
|
||||
'show': ArgsString(End(cmd_config_show)),
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
@ -16,7 +16,7 @@ EMOTES = current_app.emotes
|
|||
async def cmd_emote_help():
|
||||
normal = ['emote', 'help']
|
||||
response = (
|
||||
'Usage: emote {show | reload}\n'
|
||||
'Usage: emote [show | reload]\n'
|
||||
'Commands:\n'
|
||||
' emote show........show all current emotes\n'
|
||||
' emote reload......try to reload the emote schema (existing messages are not modified)\n'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from anonstream.control.spec.common import Str, End
|
||||
|
@ -19,17 +19,15 @@ async def cmd_help():
|
|||
' user eyes USER [show]..........show a list of active video responses\n'
|
||||
' user eyes USER delete EYES_ID..end a video response\n'
|
||||
' user add VERIFIED TOKEN........add new user\n'
|
||||
' chat show......................show a list of all chat messages\n'
|
||||
' chat delete SEQS...............delete a set of chat messages\n'
|
||||
' chat add USER NONCE COMMENT....add a chat message\n'
|
||||
#' chat show MESSAGES.............show a list of messages\n'
|
||||
' chat delete SEQS...............delete a set of messages\n'
|
||||
' chat add USER NONCE COMMENT....add chat message\n'
|
||||
' allowedness [show].............show the current allowedness\n'
|
||||
' allowedness setdefault BOOLEAN.set the default allowedness\n'
|
||||
' allowedness add SET STRING.....add to the blacklist/whitelist\n'
|
||||
' allowedness remove SET STRING..remove from the blacklist/whitelist\n'
|
||||
' emote show.....................show all current emotes\n'
|
||||
' emote reload...................try reloading the emote schema\n'
|
||||
' config show OPTION.............show app config option\n'
|
||||
' tripcode generate PASSWORD.....show tripcode for given password\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from anonstream.control.spec.common import Str, End
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
@ -12,7 +12,7 @@ from anonstream.stream import get_stream_title, set_stream_title
|
|||
async def cmd_title_help():
|
||||
normal = ['title', 'help']
|
||||
response = (
|
||||
'Usage: title {show | set TITLE}\n'
|
||||
'Usage: title [show | set TITLE]\n'
|
||||
'Commands:\n'
|
||||
' title [show].......show the stream title\n'
|
||||
' title set TITLE....set the stream title to TITLE\n'
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
||||
from quart import current_app
|
||||
|
||||
from anonstream.control.exceptions import CommandFailed
|
||||
from anonstream.control.spec.common import Str, End, ArgsJsonString
|
||||
from anonstream.control.spec.utils import json_dumps_contiguous
|
||||
from anonstream.helpers.tripcode import generate_tripcode
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
||||
async def cmd_tripcode_help():
|
||||
normal = ['tripcode', 'help']
|
||||
response = (
|
||||
'Usage: tripcode generate PASSWORD\n'
|
||||
'Commands:\n'
|
||||
' tripcode generate PASSWORD....show tripcode for given password\n'
|
||||
'Definitions:\n'
|
||||
' PASSWORD................json string, max length in config.toml (`chat.max_tripcode_password_length`)\n'
|
||||
)
|
||||
return normal, response
|
||||
|
||||
async def cmd_tripcode_generate(password):
|
||||
if len(password) > CONFIG['CHAT_TRIPCODE_PASSWORD_MAX_LENGTH']:
|
||||
raise CommandFailed(
|
||||
f'password exceeded maximum configured length of '
|
||||
f'{CONFIG["CHAT_TRIPCODE_PASSWORD_MAX_LENGTH"]} '
|
||||
f'characters'
|
||||
)
|
||||
tripcode = generate_tripcode(password)
|
||||
normal = ['tripcode', 'generate', json_dumps_contiguous(password)]
|
||||
response = json.dumps(tripcode) + '\n'
|
||||
return normal, response
|
||||
|
||||
SPEC = Str({
|
||||
None: End(cmd_tripcode_help),
|
||||
'help': End(cmd_tripcode_help),
|
||||
'generate': ArgsJsonString(End(cmd_tripcode_generate)),
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
@ -58,8 +58,10 @@ async def cmd_user_get(user, attr):
|
|||
except (TypeError, ValueError) as e:
|
||||
raise CommandFailed('value is not representable in json') from e
|
||||
normal = [
|
||||
'user', 'get',
|
||||
'token', json_dumps_contiguous(user['token']),
|
||||
'user',
|
||||
'get',
|
||||
'token',
|
||||
json_dumps_contiguous(user['token']),
|
||||
attr,
|
||||
]
|
||||
response = value_json + '\n'
|
||||
|
@ -72,8 +74,10 @@ async def cmd_user_set(user, attr, value):
|
|||
if attr in USER_WEBSOCKET_ATTRS:
|
||||
USERS_UPDATE_BUFFER.add(user['token'])
|
||||
normal = [
|
||||
'user', 'set',
|
||||
'token', json_dumps_contiguous(user['token']),
|
||||
'user',
|
||||
'set',
|
||||
'token',
|
||||
json_dumps_contiguous(user['token']),
|
||||
attr,
|
||||
json_dumps_contiguous(value),
|
||||
]
|
||||
|
@ -82,9 +86,11 @@ async def cmd_user_set(user, attr, value):
|
|||
|
||||
async def cmd_user_eyes_show(user):
|
||||
normal = [
|
||||
'user', 'eyes',
|
||||
'token', json_dumps_contiguous(user['token']),
|
||||
'show',
|
||||
'user',
|
||||
'eyes',
|
||||
'token',
|
||||
json_dumps_contiguous(user['token']),
|
||||
'show'
|
||||
]
|
||||
response = json.dumps(user['eyes']['current']) + '\n'
|
||||
return normal, response
|
||||
|
@ -95,9 +101,12 @@ async def cmd_user_eyes_delete(user, eyes_id):
|
|||
except KeyError:
|
||||
pass
|
||||
normal = [
|
||||
'user', 'eyes',
|
||||
'token', json_dumps_contiguous(user['token']),
|
||||
'delete', str(eyes_id),
|
||||
'user',
|
||||
'eyes',
|
||||
'token',
|
||||
json_dumps_contiguous(user['token']),
|
||||
'delete',
|
||||
str(eyes_id),
|
||||
]
|
||||
response = ''
|
||||
return normal, response
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
from quart import current_app
|
||||
|
||||
LOCALES = current_app.locales
|
||||
|
||||
def get_lang_and_locale_from(context, burrow=(), validate=True):
|
||||
lang = context.args.get('lang')
|
||||
locale = LOCALES.get(lang)
|
||||
if locale is None:
|
||||
if validate:
|
||||
lang = None
|
||||
locale = LOCALES[None]
|
||||
for key in burrow:
|
||||
locale = locale[key]
|
||||
return lang, locale
|
||||
|
||||
def get_lang_from(context, validate=True):
|
||||
lang, locale = get_lang_and_locale_from(context, validate=validate)
|
||||
return lang
|
||||
|
||||
def get_locale_from(context, burrow=()):
|
||||
lang, locale = get_lang_and_locale_from(context)
|
||||
return locale
|
|
@ -1,7 +1,7 @@
|
|||
# This file is pretty much entirely based on a snippet from asgi.py in
|
||||
# the Quart repository (MIT, see README.md). That means it takes on the
|
||||
# MIT licence I guess(???) If not then it's the same as every other file
|
||||
# by me: 2022 n9k <https://gitler.moe/ninya9k>, AGPL 3.0 or any later
|
||||
# by me: 2022 n9k <https://git.076.ne.jp/ninya9k>, AGPL 3.0 or any later
|
||||
# version.
|
||||
|
||||
import asyncio
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import anonstream.routes.error
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import math
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from quart import current_app, request, render_template, abort, make_response, redirect, url_for, send_from_directory
|
||||
from werkzeug.exceptions import Forbidden, NotFound, TooManyRequests
|
||||
|
||||
from anonstream.access import add_failure, pop_failure
|
||||
from anonstream.captcha import get_captcha_image, get_random_captcha_digest
|
||||
from anonstream.locale import get_lang_and_locale_from, get_lang_from, get_locale_from
|
||||
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, TooManyEyes, ensure_allowedness, Blacklisted, SecretClub
|
||||
|
@ -21,38 +21,41 @@ from anonstream.wrappers import with_timestamp
|
|||
|
||||
CAPTCHA_SIGNER = current_app.captcha_signer
|
||||
STATIC_DIRECTORY = current_app.root_path / 'static'
|
||||
LANG = current_app.lang
|
||||
|
||||
@current_app.route('/')
|
||||
@with_user_from(
|
||||
request,
|
||||
fallback_to_token=True,
|
||||
ignore_allowedness=True,
|
||||
redundant_token_redirect=True,
|
||||
)
|
||||
@with_user_from(request, fallback_to_token=True, ignore_allowedness=True)
|
||||
async def home(timestamp, user_or_token):
|
||||
lang, locale = get_lang_and_locale_from(request, burrow=('anonstream',))
|
||||
match user_or_token:
|
||||
case str() | None as token:
|
||||
failure_id = request.args.get('failure', type=int)
|
||||
failure = pop_failure(failure_id)
|
||||
response = await render_template(
|
||||
'captcha.html',
|
||||
csp=generate_csp(),
|
||||
token=token,
|
||||
request_lang=get_lang_from(request, validate=False),
|
||||
locale=locale['captcha'],
|
||||
digest=get_random_captcha_digest(),
|
||||
failure=pop_failure(failure_id),
|
||||
failure=locale['internal'].get(failure),
|
||||
)
|
||||
case dict() as user:
|
||||
try:
|
||||
ensure_allowedness(user, timestamp=timestamp)
|
||||
except Blacklisted:
|
||||
raise Forbidden('You have been blacklisted.')
|
||||
raise Forbidden(locale['error']['blacklisted'])
|
||||
except SecretClub:
|
||||
# TODO allow changing tripcode
|
||||
raise Forbidden('You have not been whitelisted.')
|
||||
raise Forbidden(locale['error']['not_whitelisted'])
|
||||
else:
|
||||
response = await render_template(
|
||||
'home.html',
|
||||
csp=generate_csp(),
|
||||
user=user,
|
||||
lang=lang,
|
||||
default_lang=LANG,
|
||||
locale=locale['home'],
|
||||
version=current_app.version,
|
||||
)
|
||||
return response
|
||||
|
@ -60,27 +63,22 @@ async def home(timestamp, user_or_token):
|
|||
@current_app.route('/stream.mp4')
|
||||
@with_user_from(request)
|
||||
async def stream(timestamp, user):
|
||||
locale = get_locale_from(request)['anonstream']['error']
|
||||
if not is_online():
|
||||
raise NotFound('The stream is offline.')
|
||||
raise NotFound(locale['offline'])
|
||||
else:
|
||||
try:
|
||||
eyes_id = create_eyes(user, tuple(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.'
|
||||
)
|
||||
error = TooManyRequests(locale['ratelimit'] % retry_after)
|
||||
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.'
|
||||
)
|
||||
raise TooManyRequests(locale['limit'] % n_eyes)
|
||||
else:
|
||||
@with_timestamp(precise=True)
|
||||
def segment_read_hook(timestamp, uri):
|
||||
|
@ -103,7 +101,7 @@ async def stream(timestamp, user):
|
|||
@current_app.route('/login')
|
||||
@auth_required
|
||||
async def login():
|
||||
return redirect(url_for('home'), 303)
|
||||
return redirect(url_for('home', lang=get_lang_from(request)), 303)
|
||||
|
||||
@current_app.route('/captcha.jpg')
|
||||
@with_user_from(request, fallback_to_token=True)
|
||||
|
@ -125,22 +123,20 @@ async def access(timestamp, user_or_token):
|
|||
answer = form.get('answer', '')
|
||||
match check_captcha_digest(CAPTCHA_SIGNER, digest, answer):
|
||||
case Answer.MISSING:
|
||||
failure_id = add_failure('Captcha is required')
|
||||
failure_id = add_failure('captcha_required')
|
||||
case Answer.BAD:
|
||||
failure_id = add_failure('Captcha was incorrect')
|
||||
failure_id = add_failure('captcha_incorrect')
|
||||
case Answer.EXPIRED:
|
||||
failure_id = add_failure('Captcha has expired')
|
||||
failure_id = add_failure('captcha_expired')
|
||||
case Answer.OK:
|
||||
failure_id = None
|
||||
user = generate_and_add_user(timestamp, token, verified=True)
|
||||
if failure_id is not None:
|
||||
response = redirect(url_for('home', token=token, failure=failure_id), 303)
|
||||
else:
|
||||
response = redirect(url_for('home', token=user['token']), 303)
|
||||
response.headers['Set-Cookie'] = f'token={quote(user["token"])}; path=/'
|
||||
case dict() as user:
|
||||
response = redirect(url_for('home', token=user['token']), 303)
|
||||
return response
|
||||
token = user['token']
|
||||
failure_id = None
|
||||
lang = get_lang_from(request, validate=failure_id is None)
|
||||
url = url_for('home', token=token, lang=lang, failure=failure_id)
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.route('/static/<path:filename>')
|
||||
@with_user_from(request)
|
||||
|
|
|
@ -1,8 +1,16 @@
|
|||
from quart import current_app, render_template
|
||||
|
||||
from quart import current_app, render_template, request
|
||||
from werkzeug.exceptions import default_exceptions
|
||||
|
||||
from anonstream.locale import get_locale_from
|
||||
|
||||
for error in default_exceptions:
|
||||
async def handle(error):
|
||||
return await render_template('error.html', error=error), error.code
|
||||
if error.description == error.__class__.description:
|
||||
error.description = None
|
||||
return (
|
||||
await render_template(
|
||||
'error.html', error=error,
|
||||
locale=get_locale_from(request)['http'],
|
||||
), error.code
|
||||
)
|
||||
current_app.register_error_handler(error, handle)
|
||||
|
|
|
@ -1,21 +1,20 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from quart import current_app, request, render_template, redirect, url_for, escape, Markup
|
||||
|
||||
from anonstream.captcha import get_random_captcha_digest_for
|
||||
from anonstream.chat import add_chat_message, Rejected
|
||||
from anonstream.locale import get_lang_and_locale_from, get_lang_from, get_locale_from
|
||||
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, reading
|
||||
from anonstream.routes.wrappers import with_user_from, render_template_with_etag
|
||||
from anonstream.helpers.chat import get_scrollback
|
||||
from anonstream.helpers.user import get_default_name
|
||||
from anonstream.utils.chat import generate_nonce, should_show_initial_date
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
from anonstream.utils.security import generate_csp
|
||||
from anonstream.utils.user import concatenate_for_notice
|
||||
|
||||
CONFIG = current_app.config
|
||||
MESSAGES = current_app.messages
|
||||
USERS_BY_TOKEN = current_app.users_by_token
|
||||
|
||||
@current_app.route('/stream.html')
|
||||
|
@ -26,6 +25,7 @@ async def nojs_stream(timestamp, user):
|
|||
csp=generate_csp(),
|
||||
user=user,
|
||||
online=is_online(),
|
||||
locale=get_locale_from(request)['anonstream']['stream'],
|
||||
)
|
||||
|
||||
@current_app.route('/info.html')
|
||||
|
@ -38,6 +38,7 @@ async def nojs_info(timestamp, user):
|
|||
{'csp': generate_csp()},
|
||||
refresh=CONFIG['NOJS_REFRESH_INFO'],
|
||||
user=user,
|
||||
locale=get_locale_from(request)['anonstream']['info'],
|
||||
viewership=viewership,
|
||||
uptime=uptime,
|
||||
title=await get_stream_title(),
|
||||
|
@ -48,23 +49,27 @@ async def nojs_info(timestamp, user):
|
|||
@with_user_from(request)
|
||||
async def nojs_chat_messages(timestamp, user):
|
||||
reading(user)
|
||||
messages = get_scrollback(MESSAGES)
|
||||
return await render_template_with_etag(
|
||||
'nojs_chat_messages.html',
|
||||
{'csp': generate_csp()},
|
||||
refresh=CONFIG['NOJS_REFRESH_MESSAGES'],
|
||||
user=user,
|
||||
users_by_token=USERS_BY_TOKEN,
|
||||
messages=messages,
|
||||
locale=get_locale_from(request)['anonstream']['chat'],
|
||||
messages=get_scrollback(current_app.messages),
|
||||
timeout=CONFIG['NOJS_TIMEOUT_CHAT'],
|
||||
get_default_name=get_default_name,
|
||||
show_initial_date=should_show_initial_date(timestamp, messages),
|
||||
)
|
||||
|
||||
@current_app.route('/chat/messages')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_messages_redirect(timestamp, user):
|
||||
url = url_for('nojs_chat_messages', token=user['token'], _anchor='end')
|
||||
url = url_for(
|
||||
'nojs_chat_messages',
|
||||
token=user['token'],
|
||||
lang=get_lang_from(request),
|
||||
_anchor='end',
|
||||
)
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.route('/chat/users.html')
|
||||
|
@ -76,6 +81,7 @@ async def nojs_chat_users(timestamp, user):
|
|||
{'csp': generate_csp()},
|
||||
refresh=CONFIG['NOJS_REFRESH_USERS'],
|
||||
user=user,
|
||||
locale=get_locale_from(request)['anonstream']['chat'],
|
||||
get_default_name=get_default_name,
|
||||
users_watching=users_by_presence[Presence.WATCHING],
|
||||
users_notwatching=users_by_presence[Presence.NOTWATCHING],
|
||||
|
@ -85,6 +91,7 @@ async def nojs_chat_users(timestamp, user):
|
|||
@current_app.route('/chat/form.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_form(timestamp, user):
|
||||
lang, locale = get_lang_and_locale_from(request)
|
||||
state_id = request.args.get('state', type=int)
|
||||
state = pop_state(user, state_id)
|
||||
prefer_chat_form = request.args.get('landing') != 'appearance'
|
||||
|
@ -92,8 +99,10 @@ async def nojs_chat_form(timestamp, user):
|
|||
'nojs_chat_form.html',
|
||||
csp=generate_csp(),
|
||||
user=user,
|
||||
state=state,
|
||||
prefer_chat_form=prefer_chat_form,
|
||||
state=state,
|
||||
lang=lang,
|
||||
locale=locale['anonstream'],
|
||||
nonce=generate_nonce(),
|
||||
digest=get_random_captcha_digest_for(user),
|
||||
default_name=get_default_name(user),
|
||||
|
@ -113,7 +122,12 @@ async def nojs_chat_form_redirect(timestamp, user):
|
|||
)
|
||||
else:
|
||||
state_id = None
|
||||
url = url_for('nojs_chat_form', token=user['token'], state=state_id)
|
||||
url = url_for(
|
||||
'nojs_chat_form',
|
||||
token=user['token'],
|
||||
lang=get_lang_from(request),
|
||||
state=state_id,
|
||||
)
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.post('/chat/message')
|
||||
|
@ -127,10 +141,10 @@ async def nojs_submit_message(timestamp, user):
|
|||
try:
|
||||
verification_happened = verify(user, digest, answer)
|
||||
except BadCaptcha as e:
|
||||
notice, *_ = e.args
|
||||
string, *args = e.args
|
||||
state_id = add_state(
|
||||
user,
|
||||
notice=notice,
|
||||
notice=[(string, args)],
|
||||
comment=comment[:CONFIG['CHAT_COMMENT_MAX_LENGTH']],
|
||||
)
|
||||
else:
|
||||
|
@ -146,10 +160,10 @@ async def nojs_submit_message(timestamp, user):
|
|||
)
|
||||
message_was_added = seq is not None
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
string, *args = e.args
|
||||
state_id = add_state(
|
||||
user,
|
||||
notice=notice,
|
||||
notice=[(string, args)],
|
||||
comment=comment[:CONFIG['CHAT_COMMENT_MAX_LENGTH']],
|
||||
)
|
||||
else:
|
||||
|
@ -160,6 +174,7 @@ async def nojs_submit_message(timestamp, user):
|
|||
url = url_for(
|
||||
'nojs_chat_form',
|
||||
token=user['token'],
|
||||
lang=get_lang_from(request),
|
||||
landing='chat',
|
||||
state=state_id,
|
||||
)
|
||||
|
@ -188,16 +203,17 @@ async def nojs_submit_appearance(timestamp, user):
|
|||
# Change appearance (iff form data was good)
|
||||
errors = try_change_appearance(user, name, color, password, want_tripcode)
|
||||
if errors:
|
||||
notice = Markup('<br>').join(
|
||||
concatenate_for_notice(*error.args) for error in errors
|
||||
)
|
||||
notice = []
|
||||
for string, *args in (error.args for error in errors):
|
||||
notice.append((string, args))
|
||||
else:
|
||||
notice = 'Changed appearance'
|
||||
notice = [('appearance_changed', ())]
|
||||
|
||||
state_id = add_state(user, notice=notice, verbose=len(errors) > 1)
|
||||
state_id = add_state(user, notice=notice)
|
||||
url = url_for(
|
||||
'nojs_chat_form',
|
||||
token=user['token'],
|
||||
lang=get_lang_from(request),
|
||||
landing='appearance' if errors else 'chat',
|
||||
state=state_id,
|
||||
)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
|
@ -8,11 +8,12 @@ import string
|
|||
from functools import wraps
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from quart import current_app, request, make_response, render_template, redirect, url_for, Markup
|
||||
from quart import current_app, request, make_response, render_template, request, url_for, escape, Markup
|
||||
from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from anonstream.broadcast import broadcast
|
||||
from anonstream.locale import get_lang_and_locale_from, get_locale_from
|
||||
from anonstream.user import ensure_allowedness, Blacklisted, SecretClub
|
||||
from anonstream.helpers.user import generate_user
|
||||
from anonstream.utils.user import generate_token, Presence
|
||||
|
@ -53,18 +54,11 @@ def auth_required(f):
|
|||
async def wrapper(*args, **kwargs):
|
||||
if check_auth(request):
|
||||
return await f(*args, **kwargs)
|
||||
hint = (
|
||||
'The broadcaster should log in with the credentials printed in '
|
||||
'their terminal.'
|
||||
)
|
||||
locale = get_locale_from(request)['anonstream']['error']
|
||||
if request.authorization is None:
|
||||
description = hint
|
||||
description = locale['broadcaster_should_log_in']
|
||||
else:
|
||||
description = Markup(
|
||||
f'Wrong username or password. Refresh the page to try again. '
|
||||
f'<br>'
|
||||
f'{hint}'
|
||||
)
|
||||
description = locale['wrong_username_or_password']
|
||||
error = Unauthorized(description)
|
||||
response = await current_app.handle_http_exception(error)
|
||||
response = await make_response(response)
|
||||
|
@ -87,12 +81,7 @@ def generate_and_add_user(
|
|||
USERS_UPDATE_BUFFER.add(token)
|
||||
return user
|
||||
|
||||
def with_user_from(
|
||||
context,
|
||||
fallback_to_token=False,
|
||||
ignore_allowedness=False,
|
||||
redundant_token_redirect=False,
|
||||
):
|
||||
def with_user_from(context, fallback_to_token=False, ignore_allowedness=False):
|
||||
def with_user_from_context(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
|
@ -112,11 +101,11 @@ def with_user_from(
|
|||
|
||||
# Reject invalid tokens
|
||||
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>'
|
||||
))
|
||||
locale = get_locale_from(context)
|
||||
args = (
|
||||
Markup(f'<br><code>{RE_TOKEN.pattern}</code>'),
|
||||
)
|
||||
raise BadRequest(escape(locale['invalid_token']) % args)
|
||||
|
||||
# Only logged in broadcaster may have the broadcaster's token
|
||||
if (
|
||||
|
@ -124,27 +113,16 @@ def with_user_from(
|
|||
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')}\" target=\"_top\">"
|
||||
f"click here"
|
||||
f"</a> "
|
||||
f"and log in with the credentials printed in their "
|
||||
f"terminal when they started anonstream."
|
||||
))
|
||||
|
||||
# If token from the client's cookie is same as the token in the URL
|
||||
# query string, the client supports cookies. If we want, we can
|
||||
# redirect the client to this same URL path but with the token
|
||||
# parameter removed, since we'll pick up their token from their
|
||||
# cookie anyway.
|
||||
if (
|
||||
redundant_token_redirect
|
||||
and token_from_context is not None
|
||||
and token_from_args == token_from_cookie
|
||||
):
|
||||
return redirect(context.path, 303)
|
||||
lang, locale = get_lang_and_locale_from(
|
||||
context, burrow=('anonstream', 'error'),
|
||||
)
|
||||
args = (
|
||||
Markup(f'''<a href="{url_for('login', lang=lang)}" target="_top">'''),
|
||||
Markup(f'''</a>'''),
|
||||
)
|
||||
raise Unauthorized(
|
||||
escape(locale['impostor']) % args
|
||||
)
|
||||
|
||||
# Create response
|
||||
user = USERS_BY_TOKEN.get(token)
|
||||
|
@ -153,19 +131,25 @@ def with_user_from(
|
|||
user['last']['seen'] = timestamp
|
||||
user['headers'] = tuple(context.headers)
|
||||
if not ignore_allowedness:
|
||||
assert_allowedness(timestamp, user)
|
||||
assert_allowedness(context, timestamp, user)
|
||||
if user is not None and user['verified'] is not None:
|
||||
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)}\" target=\"_top\">"
|
||||
f"Click here."
|
||||
f"</a>"
|
||||
))
|
||||
lang, locale = get_lang_and_locale_from(
|
||||
context, burrow=('anonstream', 'error'),
|
||||
)
|
||||
args = (
|
||||
Markup(f'''<a href="{url_for('home', token=token, lang=lang)}" target="_top">'''),
|
||||
Markup(f'''</a>'''),
|
||||
)
|
||||
if user is None:
|
||||
string = locale['captcha']
|
||||
else:
|
||||
string = locale['captcha_again']
|
||||
raise Forbidden(escape(string) % args)
|
||||
else:
|
||||
if user is not None:
|
||||
user['last']['seen'] = timestamp
|
||||
|
@ -178,7 +162,7 @@ def with_user_from(
|
|||
headers=tuple(context.headers),
|
||||
)
|
||||
if not ignore_allowedness:
|
||||
assert_allowedness(timestamp, user)
|
||||
assert_allowedness(context, timestamp, user)
|
||||
response = await f(timestamp, user, *args, **kwargs)
|
||||
|
||||
# Set cookie
|
||||
|
@ -246,10 +230,12 @@ def etag_conditional(f):
|
|||
|
||||
return wrapper
|
||||
|
||||
def assert_allowedness(timestamp, user):
|
||||
def assert_allowedness(context, timestamp, user):
|
||||
try:
|
||||
ensure_allowedness(user, timestamp=timestamp)
|
||||
except Blacklisted as e:
|
||||
raise Forbidden('You have been blacklisted.')
|
||||
locale = get_locale_from(context)['anonstream']['error']
|
||||
raise Forbidden(locale['blacklisted'])
|
||||
except SecretClub as e:
|
||||
raise Forbidden('You have not been whitelisted.')
|
||||
locale = get_locale_from(context)['anonstream']['error']
|
||||
raise Forbidden(locale['whitelisted'])
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
|
|
@ -7,21 +7,24 @@
|
|||
const TOKEN = document.body.dataset.token;
|
||||
const TOKEN_HASH = document.body.dataset.tokenHash;
|
||||
|
||||
/* language */
|
||||
const LANG = document.firstElementChild.lang;
|
||||
|
||||
/* Content Security Policy nonce */
|
||||
const CSP = document.body.dataset.csp;
|
||||
|
||||
/* insert js-only markup */
|
||||
const jsmarkup_stream_video = '<video id="stream__video" autoplay controls></video>'
|
||||
const jsmarkup_stream_offline = '<header id="stream__offline"><h1>[offline]</h1></header>'
|
||||
const jsmarkup_stream_offline = '<header id="stream__offline"><h1 data-string="offline">[offline]</h1></header>'
|
||||
const jsmarkup_info = '<div id="info_js" data-js="true"></div>';
|
||||
const jsmarkup_info_float = '<aside id="info_js__float"></aside>';
|
||||
const jsmarkup_info_float_button = '<button id="info_js__float__button" accesskey="r">Reload stream</button>';
|
||||
const jsmarkup_info_float_button = '<button id="info_js__float__button" accesskey="r" data-string="reload_stream">Reload stream</button>';
|
||||
const jsmarkup_info_float_viewership = '<div id="info_js__float__viewership"></div>';
|
||||
const jsmarkup_info_float_uptime = '<div id="info_js__float__uptime"></div>';
|
||||
const jsmarkup_info_title = '<header id="info_js__title"></header>';
|
||||
const jsmarkup_chat_messages = `\
|
||||
<ol id="chat-messages_js" data-js="true"></ol>
|
||||
<button id="chat-messages-unlock">Chat scroll paused. Click to resume.</button>`;
|
||||
<button id="chat-messages-unlock" data-string="chat_scroll_paused">Chat scroll paused. Click to resume.</button>`;
|
||||
const jsmarkup_chat_users = `\
|
||||
<article id="chat-users_js">
|
||||
<h5 id="chat-users_js__watching-header"></h5>
|
||||
|
@ -37,31 +40,31 @@ const jsmarkup_chat_form = `\
|
|||
<div id="chat-live">
|
||||
<span id="chat-live__ball"></span>
|
||||
<span id="chat-live__status">
|
||||
<span data-verbose="true">Not connected to chat</span>
|
||||
<span data-verbose="true" data-string="not_connected_to_chat">Not connected to chat</span>
|
||||
<span data-verbose="false">×</span>
|
||||
</span>
|
||||
</div>
|
||||
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
|
||||
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled data-string="chat" data-string-attr="value">
|
||||
<input id="chat-form_js__captcha-digest" type="hidden" name="captcha-digest" disabled>
|
||||
<input id="chat-form_js__captcha-image" type="image" width="72" height="30">
|
||||
<input id="chat-form_js__captcha-answer" name="captcha-answer" placeholder="Captcha" disabled>
|
||||
<input id="chat-form_js__settings" type="image" src="/static/settings.svg" width="28" height="28" alt="Settings">
|
||||
<input id="chat-form_js__captcha-answer" name="captcha-answer" placeholder="Captcha" disabled data-string="captcha" data-string-attr="placeholder">
|
||||
<input id="chat-form_js__settings" type="image" src="/static/settings.svg" width="28" height="28" alt="Settings" data-string="settings" data-string-attr="alt">
|
||||
<article id="chat-form_js__notice">
|
||||
<button id="chat-form_js__notice__button" type="button">
|
||||
<header id="chat-form_js__notice__button__header"></header>
|
||||
<small>Click to dismiss</small>
|
||||
<small data-string="click_to_dismiss">Click to dismiss</small>
|
||||
</button>
|
||||
</article>
|
||||
</form>
|
||||
<form id="appearance-form_js" data-hidden="">
|
||||
<span id="appearance-form_js__label-name">Name:</span>
|
||||
<span id="appearance-form_js__label-name" data-string="name">Name:</span>
|
||||
<input id="appearance-form_js__name" name="name">
|
||||
<input id="appearance-form_js__color" type="color" name="color">
|
||||
<span id="appearance-form_js__label-tripcode">Tripcode:</span>
|
||||
<input id="appearance-form_js__password" type="password" name="password" placeholder="(tripcode password)">
|
||||
<span id="appearance-form_js__label-tripcode" data-string="tripcode">Tripcode:</span>
|
||||
<input id="appearance-form_js__password" type="password" name="password" placeholder="(tripcode password)" data-string="tripcode_password" data-string-attr="placeholder">
|
||||
<div id="appearance-form_js__row">
|
||||
<article id="appearance-form_js__row__result"></article>
|
||||
<input id="appearance-form_js__row__submit" type="submit" value="Update">
|
||||
<input id="appearance-form_js__row__submit" type="submit" value="Update" data-string="update" data-string-attr="value">
|
||||
</div>
|
||||
</form>`;
|
||||
|
||||
|
@ -189,7 +192,6 @@ const create_chat_message = (object) => {
|
|||
chat_message.classList.add("chat-message");
|
||||
chat_message.dataset.seq = object.seq;
|
||||
chat_message.dataset.tokenHash = object.token_hash;
|
||||
chat_message.dataset.date = object.date;
|
||||
|
||||
const chat_message_time = document.createElement("time");
|
||||
chat_message_time.classList.add("chat-message__time");
|
||||
|
@ -248,7 +250,7 @@ const create_chat_user_components = (user) => {
|
|||
} else {
|
||||
const chat_user_insignia = document.createElement("b");
|
||||
chat_user_insignia.classList.add("chat-insignia")
|
||||
chat_user_insignia.title = "Broadcaster";
|
||||
chat_user_insignia.title = locale.broadcaster || "Broadcaster";
|
||||
chat_user_insignia.innerText = "##";
|
||||
const chat_user_insignia_nbsp = document.createElement("span");
|
||||
chat_user_insignia_nbsp.innerHTML = " "
|
||||
|
@ -257,67 +259,26 @@ const create_chat_user_components = (user) => {
|
|||
result.push(...[chat_user_name, chat_user_tripcode_nbsp, chat_user_tripcode]);
|
||||
return result;
|
||||
}
|
||||
const zeropad = (n) => ("0" + n).slice(-2);
|
||||
const datestamp = () => {
|
||||
const date = new Date();
|
||||
return `${date.getUTCFullYear()}-${zeropad(date.getUTCMonth() + 1)}-${zeropad(date.getUTCDate())}`;
|
||||
}
|
||||
const create_and_add_chat_message = (object) => {
|
||||
// date
|
||||
last_chat_message = chat_messages.querySelector(".chat-message:last-of-type");
|
||||
if (last_chat_message === null || last_chat_message.dataset.date !== object.date) {
|
||||
const chat_date = document.createElement("li");
|
||||
chat_date.classList.add("chat-date");
|
||||
chat_date.dataset.date = object.date;
|
||||
|
||||
const chat_date_hr = document.createElement("hr");
|
||||
const chat_date_div = document.createElement("div");
|
||||
|
||||
const chat_date_div_time = document.createElement("time");
|
||||
chat_date_div_time.datetime = object.date;
|
||||
chat_date_div_time.innerText = object.date;
|
||||
|
||||
chat_date_div.insertAdjacentElement("beforeend", chat_date_div_time);
|
||||
chat_date.insertAdjacentElement("beforeend", chat_date_hr);
|
||||
chat_date.insertAdjacentElement("beforeend", chat_date_div);
|
||||
if (last_chat_message === null && object.date === datestamp())
|
||||
chat_date.dataset.hidden = "";
|
||||
chat_messages.insertAdjacentElement("beforeend", chat_date);
|
||||
}
|
||||
|
||||
// message
|
||||
const chat_message = create_chat_message(object);
|
||||
chat_messages.insertAdjacentElement("beforeend", chat_message);
|
||||
const first_chat_message = chat_messages.querySelector(".chat-message");
|
||||
if (first_chat_message !== null) {
|
||||
const first_chat_date = chat_messages.querySelector(".chat-date");
|
||||
if (first_chat_date !== null && first_chat_date.hasAttribute("data-hidden") && (object.date !== first_chat_message.dataset.date || object.date !== datestamp()))
|
||||
first_chat_date.removeAttribute("data-hidden");
|
||||
while (chat_messages.children.length > max_chat_scrollback) {
|
||||
chat_messages.children[0].remove();
|
||||
}
|
||||
const string_seqs = new Set();
|
||||
for (const this_chat_message of chat_messages.querySelectorAll(".chat-message")) {
|
||||
if (chat_messages.querySelectorAll(".chat-message").length - string_seqs.size > max_chat_scrollback)
|
||||
string_seqs.add(this_chat_message.dataset.seq);
|
||||
else
|
||||
break;
|
||||
}
|
||||
delete_chat_messages({string_seqs});
|
||||
}
|
||||
const delete_chat_messages = ({string_seqs, keep=false}) => {
|
||||
const keep_dates = new Set();
|
||||
for (const chat_message of chat_messages.querySelectorAll(".chat-message")) {
|
||||
if (string_seqs.has(chat_message.dataset.seq) === keep)
|
||||
keep_dates.add(chat_message.dataset.date);
|
||||
const delete_chat_messages = (seqs) => {
|
||||
string_seqs = new Set(seqs.map(n => n.toString()));
|
||||
to_delete = [];
|
||||
for (const chat_message of chat_messages.children) {
|
||||
if (string_seqs.has(chat_message.dataset.seq))
|
||||
to_delete.push(chat_message);
|
||||
}
|
||||
const to_delete = [];
|
||||
for (const child of chat_messages.children) {
|
||||
if (child.classList.contains("chat-date") && !keep_dates.has(child.dataset.date) || child.classList.contains("chat-message") && string_seqs.has(child.dataset.seq) !== keep)
|
||||
to_delete.push(child);
|
||||
for (const chat_message of to_delete) {
|
||||
chat_message.remove();
|
||||
}
|
||||
for (const element of to_delete)
|
||||
element.remove();
|
||||
}
|
||||
|
||||
let locale = {};
|
||||
let users = {};
|
||||
let stats = null;
|
||||
let stats_received = null;
|
||||
|
@ -383,7 +344,7 @@ const get_user_name = ({user=null, token_hash}) => {
|
|||
}
|
||||
const update_user_names = (token_hash=null) => {
|
||||
const token_hashes = token_hash === null ? Object.keys(users) : [token_hash];
|
||||
for (const chat_message of chat_messages.querySelectorAll(".chat-message")) {
|
||||
for (const chat_message of chat_messages.children) {
|
||||
const this_token_hash = chat_message.dataset.tokenHash;
|
||||
if (token_hashes.includes(this_token_hash)) {
|
||||
const user = users[this_token_hash];
|
||||
|
@ -465,7 +426,7 @@ const update_user_tripcodes = (token_hash=null) => {
|
|||
}
|
||||
|
||||
// update inner texts
|
||||
for (const chat_message of chat_messages.querySelectorAll(".chat-message")) {
|
||||
for (const chat_message of chat_messages.children) {
|
||||
const this_token_hash = chat_message.dataset.tokenHash;
|
||||
const tripcode = users[this_token_hash].tripcode;
|
||||
if (token_hashes.includes(this_token_hash)) {
|
||||
|
@ -481,23 +442,23 @@ const chat_form_captcha_answer = document.getElementById("chat-form_js__captcha-
|
|||
chat_form_captcha_image.addEventListener("loadstart", (event) => {
|
||||
chat_form_captcha_image.removeAttribute("title");
|
||||
chat_form_captcha_image.removeAttribute("data-reloadable");
|
||||
chat_form_captcha_image.alt = "Loading...";
|
||||
chat_form_captcha_image.alt = locale.loading || "Loading...";
|
||||
});
|
||||
chat_form_captcha_image.addEventListener("load", (event) => {
|
||||
chat_form_captcha_image.removeAttribute("alt");
|
||||
chat_form_captcha_image.dataset.reloadable = "";
|
||||
chat_form_captcha_image.title = "Click for a new captcha";
|
||||
chat_form_captcha_image.title = locale.click_for_a_new_captcha || "Click for a new captcha";
|
||||
});
|
||||
chat_form_captcha_image.addEventListener("error", (event) => {
|
||||
chat_form_captcha_image.alt = "Captcha failed to load";
|
||||
chat_form_captcha_image.alt = locale.captcha_failed_to_load || "Captcha failed to load";
|
||||
chat_form_captcha_image.dataset.reloadable = "";
|
||||
chat_form_captcha_image.title = "Click for a new captcha";
|
||||
chat_form_captcha_image.title = locale.click_for_a_new_captcha || "Click for a new captcha";
|
||||
});
|
||||
chat_form_captcha_image.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (chat_form_captcha_image.dataset.reloadable !== undefined) {
|
||||
chat_form_submit.disabled = true;
|
||||
chat_form_captcha_image.alt = "Waiting...";
|
||||
chat_form_captcha_image.alt = locale.waiting || "Waiting...";
|
||||
chat_form_captcha_image.removeAttribute("title");
|
||||
chat_form_captcha_image.removeAttribute("data-reloadable");
|
||||
chat_form_captcha_image.removeAttribute("src");
|
||||
|
@ -561,7 +522,7 @@ const update_uptime = () => {
|
|||
setInterval(update_uptime, 1000); // always update uptime
|
||||
|
||||
const update_viewership = () => {
|
||||
info_viewership.innerText = stats === null ? "" : `${stats.viewership} viewers`;
|
||||
info_viewership.innerText = stats === null ? "" : (locale.viewers || "{0} viewers").replace('{0}', stats.viewership);
|
||||
}
|
||||
|
||||
const update_stats = () => {
|
||||
|
@ -607,7 +568,7 @@ const update_users_list = () => {
|
|||
}
|
||||
if (is_you) {
|
||||
const you = document.createElement("span");
|
||||
you.innerText = " (You)";
|
||||
you.innerText = locale.you || " (You)";
|
||||
chat_user.insertAdjacentElement("beforeend", you);
|
||||
}
|
||||
chat_users_sublist.insertAdjacentElement("beforeend", chat_user);
|
||||
|
@ -627,8 +588,8 @@ const update_users_list = () => {
|
|||
}
|
||||
|
||||
// show correct numbers
|
||||
chat_users_watching_header.innerText = `Watching (${watching})`;
|
||||
chat_users_notwatching_header.innerText = `Not watching (${notwatching})`;
|
||||
chat_users_watching_header.innerText = (locale.watching || "Watching ({0})").replace("{0}", watching);
|
||||
chat_users_notwatching_header.innerText = (locale.not_watching || "Not watching ({0})").replace("{0}", notwatching);
|
||||
}
|
||||
|
||||
const show_offline_screen = () => {
|
||||
|
@ -651,6 +612,21 @@ const on_websocket_message = async (event) => {
|
|||
console.log("ws init", receipt);
|
||||
|
||||
pingpong_period = receipt.pingpong;
|
||||
|
||||
// update locale & put localized strings in js-inserted elements
|
||||
locale = receipt.locale;
|
||||
for (element of document.querySelectorAll('[data-string]')) {
|
||||
const string = element.dataset.string;
|
||||
if (locale[string] !== undefined) {
|
||||
const attr = element.dataset.stringAttr;
|
||||
if (attr === undefined)
|
||||
element.innerText = locale[string];
|
||||
else
|
||||
element[attr] = locale[string];
|
||||
}
|
||||
}
|
||||
|
||||
// stream title
|
||||
set_title(receipt.title);
|
||||
|
||||
// update stats (uptime/viewership)
|
||||
|
@ -665,11 +641,6 @@ const on_websocket_message = async (event) => {
|
|||
info_button.dataset.visible = "";
|
||||
}
|
||||
|
||||
// form input maxlengths
|
||||
chat_form_comment.maxLength = receipt.maxlength.comment;
|
||||
chat_appearance_form_name.maxLength = receipt.maxlength.name;
|
||||
chat_appearance_form_password.maxLength = receipt.maxlength.password;
|
||||
|
||||
// chat form nonce
|
||||
chat_form_nonce.value = receipt.nonce;
|
||||
|
||||
|
@ -680,8 +651,17 @@ const on_websocket_message = async (event) => {
|
|||
chat_form_submit.disabled = false;
|
||||
|
||||
// remove messages the server isn't acknowledging the existence of
|
||||
const string_seqs = new Set(receipt.messages.map(message => message.seq.toString()));
|
||||
delete_chat_messages({string_seqs, keep: true});
|
||||
const seqs = new Set(receipt.messages.map((message) => {return message.seq;}));
|
||||
const to_delete = [];
|
||||
for (const chat_message of chat_messages.children) {
|
||||
const chat_message_seq = parseInt(chat_message.dataset.seq);
|
||||
if (!seqs.has(chat_message_seq)) {
|
||||
to_delete.push(chat_message);
|
||||
}
|
||||
}
|
||||
for (const chat_message of to_delete) {
|
||||
chat_message.remove();
|
||||
}
|
||||
|
||||
// settings
|
||||
default_name = receipt.default;
|
||||
|
@ -704,7 +684,7 @@ const on_websocket_message = async (event) => {
|
|||
left: 0,
|
||||
top: chat_messages.scrollTopMax,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// appearance form default values
|
||||
|
@ -716,8 +696,7 @@ const on_websocket_message = async (event) => {
|
|||
chat_appearance_form_color.setAttribute("value", user.color);
|
||||
|
||||
// insert new messages
|
||||
const chat_messages_messages = chat_messages.querySelectorAll(".chat-message");
|
||||
const last = chat_messages_messages.length == 0 ? null : chat_messages_messages[chat_messages_messages.length - 1];
|
||||
const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1];
|
||||
const last_seq = last === null ? null : parseInt(last.dataset.seq);
|
||||
for (const message of receipt.messages) {
|
||||
if (message.seq > last_seq) {
|
||||
|
@ -784,7 +763,7 @@ const on_websocket_message = async (event) => {
|
|||
|
||||
case "delete":
|
||||
console.log("ws delete", receipt);
|
||||
delete_chat_messages({string_seqs: new Set(receipt.seqs.map(n => n.toString()))});
|
||||
delete_chat_messages(receipt.seqs);
|
||||
break;
|
||||
|
||||
case "set-users":
|
||||
|
@ -853,7 +832,7 @@ const on_websocket_message = async (event) => {
|
|||
ul.insertAdjacentElement("beforeend", li);
|
||||
}
|
||||
const result = document.createElement("div");
|
||||
result.innerText = "Errors:";
|
||||
result.innerText = locale.errors || "Errors:";
|
||||
result.insertAdjacentElement("beforeend", ul);
|
||||
chat_appearance_form_result.innerHTML = result.innerHTML;
|
||||
}
|
||||
|
@ -890,14 +869,14 @@ const connect_websocket = () => {
|
|||
return;
|
||||
}
|
||||
chat_live_ball.style.borderColor = "gold";
|
||||
chat_live_status.innerHTML = "<span data-verbose='true'>Connecting to chat...</span><span data-verbose='false'>···</span>";
|
||||
chat_live_status.innerHTML = `<span data-verbose='true'>${locale.connecting_to_chat || "Connecting to chat..."}</span><span data-verbose='false'>···</span>`;
|
||||
ws = null;
|
||||
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(TOKEN)}`);
|
||||
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(TOKEN)}&lang=${encodeURIComponent(LANG)}`);
|
||||
ws.addEventListener("open", (event) => {
|
||||
console.log("websocket open", event);
|
||||
chat_form_submit.disabled = false;
|
||||
chat_live_ball.style.borderColor = "green";
|
||||
chat_live_status.innerHTML = "<span><span data-verbose='true'>Connected to chat</span><span data-verbose='false'>✓</span></span>";
|
||||
chat_live_status.innerHTML = `<span><span data-verbose='true'>${locale.connected_to_chat || "Connected to chat"}</span><span data-verbose='false'>✓</span></span>`;
|
||||
// When the server is offline, a newly opened websocket can take a second
|
||||
// to close. This timeout tries to ensure the backoff doesn't instantly
|
||||
// (erroneously) reset to 2 seconds in that case.
|
||||
|
@ -913,7 +892,7 @@ const connect_websocket = () => {
|
|||
console.log("websocket close", event);
|
||||
chat_form_submit.disabled = true;
|
||||
chat_live_ball.style.borderColor = "maroon";
|
||||
chat_live_status.innerHTML = "<span data-verbose='true'>Disconnected from chat</span><span data-verbose='false'>×</span>";
|
||||
chat_live_status.innerHTML = `<span data-verbose='true'>${locale.disconnected_from_chat || "Disconnected from chat"}</span><span data-verbose='false'>×</span>`;
|
||||
if (!ws.successor) {
|
||||
ws.successor = true;
|
||||
setTimeout(connect_websocket, websocket_backoff);
|
||||
|
@ -924,7 +903,7 @@ const connect_websocket = () => {
|
|||
console.log("websocket error", event);
|
||||
chat_form_submit.disabled = true;
|
||||
chat_live_ball.style.borderColor = "maroon";
|
||||
chat_live_status.innerHTML = "<span>Error<span data-verbose='true'> connecting to chat</span></span>";
|
||||
chat_live_status.innerHTML = `<span><span data-verbose='true'>${locale.error_connecting_to_chat || "Error connecting to chat"}</span><span data-verbose='false'>${locale.error_connecting_to_chat_terse || "Error"}</span></span>`;
|
||||
});
|
||||
ws.addEventListener("message", on_websocket_message);
|
||||
}
|
||||
|
@ -941,7 +920,7 @@ info_button.addEventListener("click", (event) => {
|
|||
info_button.removeAttribute("data-visible");
|
||||
});
|
||||
video.addEventListener("error", (event) => {
|
||||
if (video.error !== null && video.networkState === video.NETWORK_NO_SOURCE) {
|
||||
if (video.error !== null && video.error.message === "404: Not Found") {
|
||||
show_offline_screen();
|
||||
}
|
||||
if (stats !== null) {
|
||||
|
@ -1009,14 +988,6 @@ chat_messages_unlock.addEventListener("click", (event) => {
|
|||
chat_messages.scrollTop = chat_messages.scrollTopMax;
|
||||
});
|
||||
|
||||
/* show initial chat date if a day has passed */
|
||||
const show_initial_date = () => {
|
||||
const chat_date = chat_messages.querySelector(".chat-date:first-child");
|
||||
if (chat_date !== null && chat_date.hasAttribute("data-hidden") && chat_date.dataset.date !== datestamp())
|
||||
chat_date.removeAttribute("data-hidden");
|
||||
}
|
||||
setInterval(show_initial_date, 30000);
|
||||
|
||||
/* close websocket after prolonged absence of pings */
|
||||
|
||||
const rotate_websocket = () => {
|
||||
|
|
|
@ -273,29 +273,6 @@ noscript {
|
|||
font-size: 9pt;
|
||||
cursor: default;
|
||||
}
|
||||
.chat-date {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
color: #b2b2b3;
|
||||
cursor: default;
|
||||
}
|
||||
.chat-date[data-hidden] {
|
||||
display: none;
|
||||
}
|
||||
.chat-date > hr {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chat-date > :not(hr) > time {
|
||||
padding: 0 1ch;
|
||||
background-color: #232327;
|
||||
position: relative;
|
||||
}
|
||||
#chat__body__users {
|
||||
background-color: #121214;
|
||||
mask-image: linear-gradient(black calc(100% - 0.625rem), transparent calc(100% - 0.125rem));
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import itertools
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
font-family: sans-serif;
|
||||
font-size: 14pt;
|
||||
display: grid;
|
||||
grid-template-rows: calc(50% - 10vh + 2rem) 1fr;
|
||||
grid-template-rows: calc(50% - 4rem) 1fr;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
|
@ -37,30 +37,27 @@
|
|||
border-radius: 2px;
|
||||
color: #ddd;
|
||||
font-size: 14pt;
|
||||
padding: 5px 6px;
|
||||
padding: 4px 5px;
|
||||
width: 10ch;
|
||||
}
|
||||
input[name="answer"]:hover {
|
||||
background-color: #37373a;
|
||||
transition: 0.25s;
|
||||
}
|
||||
input[name="answer"]:focus {
|
||||
background-color: black;
|
||||
border-color: #3584e4;
|
||||
}
|
||||
input[type="submit"] {
|
||||
}
|
||||
input[type="submit"] {
|
||||
font-size: 14pt;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
p {
|
||||
}
|
||||
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">
|
||||
<form action="{{ url_for('access', token=token, lang=request_lang) }}" method="post">
|
||||
<input type="hidden" name="digest" value="{{ digest }}">
|
||||
<input name="answer" placeholder="Captcha" required autofocus>
|
||||
<input name="answer" placeholder="{{ locale.captcha }}" required autofocus>
|
||||
<input type="submit" value="Submit">
|
||||
{% if failure is not none %}<p>{{ failure }}</p>{% endif %}
|
||||
</form>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ error.code }} {{ error.name }}</title>
|
||||
<title>{{ error.code }} {{ locale[error.code | string] or error.name }}</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #232327;
|
||||
|
@ -63,8 +63,8 @@
|
|||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{{ error.name }}</h1>
|
||||
{% if error.description != error.__class__.description %}
|
||||
<h1>{{ locale[error.code | string] or error.name }}</h1>
|
||||
{% if error.description is not none %}
|
||||
<p>{{ error.description }}</p>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
|
|
@ -3,45 +3,45 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
##}
|
||||
<!doctype html>
|
||||
<html id="nochat">
|
||||
<html id="nochat" lang="{{ lang or default_lang }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; connect-src 'self'; img-src 'self'; frame-src 'self'; media-src 'self'; script-src 'self'; style-src 'self' 'nonce-{{ csp }}';">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" type="text/css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', token=user.token) }}" type="text/css">
|
||||
</head>
|
||||
<body id="both" data-token="{{ user.token }}" data-token-hash="{{ user.token_hash }}" data-csp="{{ csp }}">
|
||||
<article id="stream">
|
||||
<noscript><iframe id="stream_nojs" name="stream_nojs" src="{{ url_for('nojs_stream', token=user.token) }}"></iframe></noscript>
|
||||
</article>
|
||||
<article id="info">
|
||||
<noscript><iframe id="info_nojs" src="{{ url_for('nojs_info', token=user.token) }}" data-js="false"></iframe></noscript>
|
||||
<noscript><iframe id="info_nojs" src="{{ url_for('nojs_info', token=user.token, lang=lang) }}" data-js="false"></iframe></noscript>
|
||||
</article>
|
||||
<aside id="chat">
|
||||
<input id="chat__toggle" type="checkbox">
|
||||
<header id="chat__header">
|
||||
<label id="chat__header__button" for="chat__toggle">Users</label>
|
||||
<h3 id="chat__header__text">Stream chat</h3>
|
||||
<label id="chat__header__button" for="chat__toggle">{{ locale.users }}</label>
|
||||
<h3 id="chat__header__text">{{ locale.stream_chat }}</h3>
|
||||
</header>
|
||||
<article id="chat__body">
|
||||
<section id="chat__body__messages">
|
||||
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat_messages', token=user.token, _anchor='end') }}" data-js="false"></iframe></noscript>
|
||||
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat_messages', token=user.token, lang=lang, _anchor='end') }}" data-js="false"></iframe></noscript>
|
||||
</section>
|
||||
<section id="chat__body__users">
|
||||
<header id="chat-users-header"><h4>Users in chat</h4></header>
|
||||
<noscript><iframe id="chat-users_nojs" src="{{ url_for('nojs_chat_users', token=user.token) }}" data-js="false"></iframe></noscript>
|
||||
<header id="chat-users-header"><h4>{{ locale.users_in_chat }}</h4></header>
|
||||
<noscript><iframe id="chat-users_nojs" src="{{ url_for('nojs_chat_users', token=user.token, lang=lang) }}" data-js="false"></iframe></noscript>
|
||||
</section>
|
||||
</article>
|
||||
<section id="chat__form">
|
||||
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_chat_form', token=user.token) }}" data-js="false"></iframe></noscript>
|
||||
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_chat_form', token=user.token, lang=lang) }}" data-js="false"></iframe></noscript>
|
||||
</section>
|
||||
</aside>
|
||||
<nav id="nav">
|
||||
<a href="#info">info</a>
|
||||
<a href="#chat">chat</a>
|
||||
<a href="#both">both</a>
|
||||
<a href="#info">{{ locale.info }}</a>
|
||||
<a href="#chat">{{ locale.chat }}</a>
|
||||
<a href="#both">{{ locale.both }}</a>
|
||||
</nav>
|
||||
<footer>anonstream {{ version }} — <a href="https://gitler.moe/ninya9k/anonstream" target="_blank">source</a></footer>
|
||||
<script src="{{ url_for('static', filename='anonstream.js') }}" type="text/javascript"></script>
|
||||
<footer>anonstream {{ version }} — <a href="https://git.076.ne.jp/ninya9k/anonstream" target="_blank">{{ locale.source }}</a></footer>
|
||||
<script src="{{ url_for('static', filename='anonstream.js', token=user.token) }}" type="text/javascript"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -136,7 +136,7 @@
|
|||
#appearance-form__buttons {
|
||||
grid-column: 1 / span 3;
|
||||
display: grid;
|
||||
grid-template-columns: auto 5rem;
|
||||
grid-template-columns: auto 6rem;
|
||||
}
|
||||
#password-column {
|
||||
display: grid;
|
||||
|
@ -214,45 +214,50 @@
|
|||
<input id="toggle" type="checkbox" {% if not prefer_chat_form %}checked {% endif %}accesskey="x">
|
||||
{% if state.notice %}
|
||||
<input id="notice-radio" type="radio" accesskey="z">
|
||||
<label id="notice" for="notice-radio"{% if state.verbose %} class="verbose"{% endif %}>
|
||||
<header><h2>{{ state.notice }}</h2></header>
|
||||
<small>Click to dismiss</small>
|
||||
<label id="notice" for="notice-radio"{% if (state.notice | length) > 1 %} class="verbose"{% endif %}>
|
||||
<header><h2>
|
||||
{%- for string, args in state.notice %}
|
||||
{{ locale.internal[string] | escape | format(*args) -}}
|
||||
{% if not loop.last %}<br>{% endif %}
|
||||
{% endfor -%}
|
||||
</h2></header>
|
||||
<small>{{ locale.form.click_to_dismiss }}</small>
|
||||
</label>
|
||||
{% endif %}
|
||||
<form id="chat-form" action="{{ url_for('nojs_submit_message', token=user.token) }}" method="post">
|
||||
<form id="chat-form" action="{{ url_for('nojs_submit_message', token=user.token, lang=lang) }}" method="post">
|
||||
<input type="hidden" name="nonce" value="{{ nonce }}">
|
||||
<textarea id="chat-form__comment" name="comment" maxlength="{{ max_comment_length }}" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1" autofocus accesskey="m">{{ state.comment }}</textarea>
|
||||
<input id="chat-form__submit" type="submit" value="Chat" tabindex="4" accesskey="p">
|
||||
<div id="chat-form__exit"><label for="toggle" class="pseudolink">Settings</label></div>
|
||||
<textarea id="chat-form__comment" name="comment" maxlength="{{ max_comment_length }}" {% if digest is none %}required {% endif %} placeholder="{{ locale.form.send_a_message }}" rows="1" tabindex="1" autofocus accesskey="m">{{ state.comment }}</textarea>
|
||||
<input id="chat-form__submit" type="submit" value="{{ locale.form.chat }}" tabindex="4" accesskey="p">
|
||||
<div id="chat-form__exit"><label for="toggle" class="pseudolink">{{ locale.form.settings }}</label></div>
|
||||
{% if digest %}
|
||||
<input type="hidden" name="captcha-digest" value="{{ digest }}">
|
||||
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_chat_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="Captcha failed to load" title="Click for a new captcha" tabindex="2">
|
||||
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_chat_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="{{ locale.form.captcha_failed_to_load }}" title="{{ locale.form.click_for_a_new_captcha }}" tabindex="2">
|
||||
<input id="chat-form__captcha-answer" name="captcha-answer" required placeholder="Captcha" tabindex="3">
|
||||
{% endif %}
|
||||
</form>
|
||||
<form id="appearance-form" action="{{ url_for('nojs_submit_appearance', token=user.token) }}" method="post">
|
||||
<label id="appearance-form__label-name" for="appearance-form__name">Name:</label>
|
||||
<form id="appearance-form" action="{{ url_for('nojs_submit_appearance', token=user.token, lang=lang) }}" method="post">
|
||||
<label id="appearance-form__label-name" for="appearance-form__name">{{ locale.form.name }}</label>
|
||||
<input id="appearance-form__name" name="name" value="{{ user.name or '' }}" placeholder="{{ default_name }}" maxlength="{{ max_name_length }}">
|
||||
<input type="color" name="color" value="{{ user.color }}">
|
||||
<label id="appearance-form__label-password" for="appearance-form__password">Tripcode:</label>
|
||||
<label id="appearance-form__label-password" for="appearance-form__password">{{ locale.form.tripcode }}</label>
|
||||
<input id="password-toggle" name="set-tripcode" type="checkbox" accesskey="s">
|
||||
<input id="cleared-toggle" name="clear-tripcode" type="checkbox"{% if user.tripcode != none %} accesskey="c"{% endif %}>
|
||||
<div id="password-column">
|
||||
{% if user.tripcode is none %}
|
||||
<span class="tripcode">(no tripcode)</span>
|
||||
<label for="password-toggle" class="show-password pseudolink">set</label>
|
||||
<span class="tripcode">{{ locale.form.no_tripcode }}</span>
|
||||
<label for="password-toggle" class="show-password pseudolink">{{ locale.form.set }}</label>
|
||||
{% else %}
|
||||
<label id="tripcode" for="password-toggle" class="show-password tripcode">{{ user.tripcode.digest }}</label>
|
||||
<label id="show-cleared" for="cleared-toggle" class="pseudolink x">×</label>
|
||||
<div id="cleared" class="tripcode">(cleared)</div>
|
||||
<label id="hide-cleared" for="cleared-toggle" class="pseudolink">undo</label>
|
||||
<div id="cleared" class="tripcode">{{ locale.form.cleared }}</div>
|
||||
<label id="hide-cleared" for="cleared-toggle" class="pseudolink">{{ locale.form.undo }}</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<input id="appearance-form__password" name="password" type="password" placeholder="(tripcode password)" maxlength="{{ max_password_length }}">
|
||||
<input id="appearance-form__password" name="password" type="password" placeholder="{{ locale.form.tripcode_password }}" maxlength="{{ max_password_length }}">
|
||||
<div id="hide-password"><label for="password-toggle" class="pseudolink x">×</label></div>
|
||||
<div id="appearance-form__buttons">
|
||||
<div id="appearance-form__buttons__exit"><label for="toggle" class="pseudolink">Return to chat</label></div>
|
||||
<input type="submit" value="Update">
|
||||
<div id="appearance-form__buttons__exit"><label for="toggle" class="pseudolink">{{ locale.form.return_to_chat }}</label></div>
|
||||
<input type="submit" value="{{ locale.form['update'] }}">
|
||||
</div>
|
||||
</form>
|
||||
</body>
|
||||
|
|
|
@ -144,27 +144,6 @@
|
|||
font-size: 9pt;
|
||||
cursor: default;
|
||||
}
|
||||
.chat-date {
|
||||
transform: rotate(-180deg);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
color: #b2b2b3;
|
||||
cursor: default;
|
||||
}
|
||||
.chat-date > hr {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chat-date > :not(hr) > time {
|
||||
padding: 0 1ch;
|
||||
background-color: #232327;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
{% for token in messages | map(attribute='token') | list | unique %}
|
||||
{% with this_user = users_by_token[token] %}
|
||||
|
@ -186,14 +165,14 @@
|
|||
<div id="notimeout"></div>
|
||||
<aside id="timeout">
|
||||
<a class="button" href="{{ url_for('nojs_chat_messages_redirect', token=user.token) }}">
|
||||
<header>Timed out</header>
|
||||
<small>Click to refresh</small>
|
||||
<header>{{ locale.timed_out }}</header>
|
||||
<small>{{ locale.click_to_refresh }}</small>
|
||||
</a>
|
||||
</aside>
|
||||
<ol id="chat-messages">
|
||||
{% for message in messages | reverse %}
|
||||
{% with this_user = users_by_token[message.token] %}
|
||||
<li class="chat-message" data-seq="{{ message.seq }}" data-token-hash="{{ this_user.token_hash }}" data-date="{{ message.date }}">
|
||||
<li class="chat-message" data-seq="{{ message.seq }}" data-token-hash="{{ this_user.token_hash }}">
|
||||
<time class="chat-message__time" datetime="{{ message.date }}T{{ message.time_seconds }}Z" title="{{ message.date }} {{ message.time_seconds }}">{{ message.time_minutes }}</time>
|
||||
{{- ' ' | safe -}}
|
||||
{{ appearance(this_user, insignia_class='chat-message__insignia', name_class='chat-message__name', tag_class='chat-message__name__tag') }}
|
||||
|
@ -201,22 +180,13 @@
|
|||
<span class="chat-message__markup">{{ message.markup }}</span>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{%
|
||||
if loop.nextitem is defined and loop.nextitem.date != message.date
|
||||
or loop.nextitem is not defined and show_initial_date
|
||||
%}
|
||||
<li class="chat-date" data-date="{{ message.date }}">
|
||||
<hr>
|
||||
<div><time datetime="{{ message.date }}">{{ message.date }}</time></div>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<aside id="timeout-dismiss">
|
||||
<a class="button" href="#notimeout">Hide timeout notice</a>
|
||||
<a class="button" href="#notimeout">{{ locale.hide_timeout_notice }}</a>
|
||||
</aside>
|
||||
<aside id="timeout-alt">
|
||||
<a class="button" href="{{ url_for('nojs_chat_messages_redirect', token=user.token) }}">Click to refresh</a>
|
||||
<a class="button" href="{{ url_for('nojs_chat_messages_redirect', token=user.token) }}">{{ locale.click_to_refresh }}</a>
|
||||
</aside>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -109,27 +109,27 @@
|
|||
<body>
|
||||
<aside id="timeout">
|
||||
<a href="">
|
||||
<header>Timed out</header>
|
||||
<small>Click to refresh</small>
|
||||
<header>{{ locale.timed_out}} </header>
|
||||
<small>{{ locale.click_to_refresh }}</small>
|
||||
</a>
|
||||
</aside>
|
||||
<main id="main">
|
||||
<h5>Watching ({{ users_watching | length }})</h5>
|
||||
<h5>{{ locale.watching | format(users_watching | length) }}</h5>
|
||||
<ul>
|
||||
{% for user_listed in users_watching %}
|
||||
<li class="user" data-token-hash="{{ user_listed.token_hash }}">
|
||||
{{- appearance(user_listed, insignia_class='user__insignia', name_class='user__name', tag_class='user__name__tag') -}}
|
||||
{%- if user.token == user_listed.token %} (You){% endif -%}
|
||||
{%- if user.token == user_listed.token %}{{ locale.you }}{% endif -%}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<br>
|
||||
<h5>Not watching ({{ users_notwatching | length }})</h5>
|
||||
<h5>{{ locale.not_watching | format(users_notwatching | length) }}</h5>
|
||||
<ul>
|
||||
{% for user_listed in users_notwatching %}
|
||||
<li class="user" data-token-hash="{{ user_listed.token_hash }}">
|
||||
{{- appearance(user_listed, insignia_class='user__insignia', name_class='user__name', tag_class='user__name__tag') -}}
|
||||
{%- if user.token == user_listed.token %} (You){% endif -%}
|
||||
{%- if user.token == user_listed.token %}{{ locale.you }}{% endif -%}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
|
@ -144,13 +144,13 @@
|
|||
{% if user.presence != Presence.WATCHING %}
|
||||
<form id="float__form" action="{{ url_for('nojs_stream') }}" target="stream_nojs">
|
||||
<input type="hidden" name="token" value="{{ user.token }}">
|
||||
<input type="submit" value="Reload stream" accesskey="r">
|
||||
<input type="submit" value="{{ locale.reload_stream }}" accesskey="r">
|
||||
</form>
|
||||
{% endif %}
|
||||
<div id="float__viewership">{{ viewership }} viewers</div>
|
||||
<div id="float__viewership">{{ locale.viewers | format(viewership) }}</div>
|
||||
<div id="float__uptime">
|
||||
<div id="uptime-static"{% if uptime < 360000 %} data-hidden=""{% endif %}>
|
||||
<span id="uptime-static__label">Uptime:</span>
|
||||
<span id="uptime-static__label">{{ locale.uptime }}</span>
|
||||
<span>
|
||||
{%- if uptime >= 3600 -%}
|
||||
{{- uptime | int // 3600 -}}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
{% if online %}
|
||||
<video id="video" src="{{ url_for('stream', token=user.token) }}" autoplay controls></video>
|
||||
{% else %}
|
||||
<header id="offline"><h1>[offline]</h1></header>
|
||||
<header id="offline"><h1>{{ locale.offline }}</h1></header>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import operator
|
||||
|
@ -6,9 +6,8 @@ import time
|
|||
from functools import reduce
|
||||
from math import inf
|
||||
|
||||
from quart import current_app
|
||||
from quart import current_app, Markup
|
||||
|
||||
from anonstream.events import notify_event_sockets
|
||||
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.captcha import check_captcha_digest, Answer
|
||||
|
@ -99,21 +98,6 @@ def try_change_appearance(user, name, color, password, want_tripcode):
|
|||
# Add to the users update buffer
|
||||
USERS_UPDATE_BUFFER.add(user['token'])
|
||||
|
||||
# Notify event sockets that a user's appearance was set
|
||||
# NOTE: Changing appearance is currently NOT ratelimited.
|
||||
# Applications using the event socket API should buffer these
|
||||
# events or do something else to a prevent a potential denial of
|
||||
# service.
|
||||
notify_event_sockets({
|
||||
'type': 'appearance',
|
||||
'event': {
|
||||
'token': user['token'],
|
||||
'name': user['name'],
|
||||
'color': user['color'],
|
||||
'tripcode': user['tripcode'],
|
||||
}
|
||||
})
|
||||
|
||||
return errors
|
||||
|
||||
def change_name(user, name, dry_run=False):
|
||||
|
@ -122,10 +106,11 @@ def change_name(user, name, dry_run=False):
|
|||
name = None
|
||||
if name is not None:
|
||||
if len(name) == 0:
|
||||
raise BadAppearance('Name was empty')
|
||||
raise BadAppearance('name_empty')
|
||||
if len(name) > CONFIG['CHAT_NAME_MAX_LENGTH']:
|
||||
raise BadAppearance(
|
||||
f'Name exceeded {CONFIG["CHAT_NAME_MAX_LENGTH"]} chars'
|
||||
'name_too_long',
|
||||
CONFIG['CHAT_NAME_MAX_LENGTH'],
|
||||
)
|
||||
else:
|
||||
user['name'] = name
|
||||
|
@ -135,16 +120,13 @@ def change_color(user, color, dry_run=False):
|
|||
try:
|
||||
colour = color_to_colour(color)
|
||||
except NotAColor:
|
||||
raise BadAppearance('Invalid CSS color')
|
||||
contrast = get_contrast(
|
||||
CONFIG['CHAT_BACKGROUND_COLOUR'],
|
||||
colour,
|
||||
)
|
||||
raise BadAppearance('colour_invalid_css')
|
||||
contrast = get_contrast(CONFIG['CHAT_BACKGROUND_COLOUR'], colour)
|
||||
min_contrast = CONFIG['CHAT_NAME_MIN_CONTRAST']
|
||||
if contrast < min_contrast:
|
||||
raise BadAppearance(
|
||||
'Colour had insufficient contrast:',
|
||||
(f'{contrast:.2f}', f'/{min_contrast:.2f}'),
|
||||
'colour_insufficient_contrast',
|
||||
Markup(f'<mark>{contrast:.2f}</mark>/{min_contrast:.2f}'),
|
||||
)
|
||||
else:
|
||||
user['color'] = color
|
||||
|
@ -153,8 +135,8 @@ def change_tripcode(user, password, dry_run=False):
|
|||
if dry_run:
|
||||
if len(password) > CONFIG['CHAT_TRIPCODE_PASSWORD_MAX_LENGTH']:
|
||||
raise BadAppearance(
|
||||
f'Password exceeded '
|
||||
f'{CONFIG["CHAT_TRIPCODE_PASSWORD_MAX_LENGTH"]} chars'
|
||||
'password_too_long',
|
||||
CONFIG['CHAT_TRIPCODE_PASSWORD_MAX_LENGTH'],
|
||||
)
|
||||
else:
|
||||
user['tripcode'] = generate_tripcode(password)
|
||||
|
@ -192,11 +174,11 @@ def verify(user, digest, answer):
|
|||
else:
|
||||
match check_captcha_digest(CAPTCHA_SIGNER, digest, answer):
|
||||
case Answer.MISSING:
|
||||
raise BadCaptcha('Captcha is required')
|
||||
raise BadCaptcha('captcha_required')
|
||||
case Answer.BAD:
|
||||
raise BadCaptcha('Captcha was incorrect')
|
||||
raise BadCaptcha('captcha_incorrect')
|
||||
case Answer.EXPIRED:
|
||||
raise BadCaptcha('Captcha has expired')
|
||||
raise BadCaptcha('captcha_expired')
|
||||
case Answer.OK:
|
||||
user['verified'] = True
|
||||
verification_happened = True
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
|
@ -6,7 +6,6 @@ import hashlib
|
|||
import math
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
from quart import escape
|
||||
|
@ -31,15 +30,3 @@ def get_approx_linespan(text):
|
|||
linespan = sum(map(height, text.splitlines()))
|
||||
linespan = linespan if linespan > 0 else 1
|
||||
return linespan
|
||||
|
||||
def should_show_initial_date(timestamp, messages):
|
||||
try:
|
||||
first_message = next(iter(messages))
|
||||
except StopIteration:
|
||||
return False
|
||||
if any(message['date'] != first_message['date'] for message in messages):
|
||||
return True
|
||||
else:
|
||||
latest_date = max(map(lambda message: message['date'], messages))
|
||||
date = datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%d')
|
||||
return date != latest_date
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import re
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
import types
|
||||
|
||||
SPEC = {
|
||||
'anonstream': {
|
||||
'error': {
|
||||
'invalid_token': str,
|
||||
'captcha': str,
|
||||
'captcha_again': str,
|
||||
'impostor': str,
|
||||
'broadcaster_should_log_in': str,
|
||||
'wrong_username_or_password': str,
|
||||
'blacklisted': str,
|
||||
'not_whitelisted': str,
|
||||
'offline': str,
|
||||
'ratelimit': str,
|
||||
'limit': str,
|
||||
},
|
||||
'internal': {
|
||||
'captcha_required': str,
|
||||
'captcha_incorrect': str,
|
||||
'captcha_expired': str,
|
||||
'message_ratelimited': str,
|
||||
'message_suspected_duplicate': str,
|
||||
'message_empty': str,
|
||||
'message_practically_empty': str,
|
||||
'message_too_long': str,
|
||||
'message_too_many_lines': str,
|
||||
'message_too_many_apparent_lines': str,
|
||||
'appearance_changed': str,
|
||||
'name_empty': str,
|
||||
'name_too_long': str,
|
||||
'colour_invalid_css': str,
|
||||
'colour_insufficient_contrast': str,
|
||||
'password_too_long': str,
|
||||
},
|
||||
'captcha': {
|
||||
'captcha_failed_to_load': str,
|
||||
'click_for_a_new_captcha': str,
|
||||
},
|
||||
'home': {
|
||||
'info': str,
|
||||
'chat': str,
|
||||
'both': str,
|
||||
'source': str,
|
||||
'users': str,
|
||||
'users_in_chat': str,
|
||||
'stream_chat': str,
|
||||
},
|
||||
'stream': {
|
||||
'offline': str,
|
||||
},
|
||||
'info': {
|
||||
'viewers': str,
|
||||
'uptime': str,
|
||||
'reload_stream': str,
|
||||
},
|
||||
'chat': {
|
||||
'users': str,
|
||||
'click_to_refresh': str,
|
||||
'hide_timeout_notice': str,
|
||||
'watching': str,
|
||||
'not_watching': str,
|
||||
'you': str,
|
||||
'timed_out': str,
|
||||
},
|
||||
'form': {
|
||||
'click_to_dismiss': str,
|
||||
'send_a_message': str,
|
||||
'captcha': str,
|
||||
'settings': str,
|
||||
'captcha_failed_to_load': str,
|
||||
'click_for_a_new_captcha': str,
|
||||
'chat': str,
|
||||
'name': str,
|
||||
'tripcode': str,
|
||||
'no_tripcode': str,
|
||||
'set': str,
|
||||
'cleared': str,
|
||||
'undo': str,
|
||||
'tripcode_password': str,
|
||||
'return_to_chat': str,
|
||||
'update': str,
|
||||
},
|
||||
'js': {
|
||||
'offline': str,
|
||||
'reload_stream': str,
|
||||
'chat_scroll_paused': str,
|
||||
'not_connected': str,
|
||||
'broadcaster': str,
|
||||
'loading': str,
|
||||
'click_for_a_new_captcha': str,
|
||||
'viewers': str,
|
||||
'you': str,
|
||||
'watching': str,
|
||||
'not_watching': str,
|
||||
'errors': str,
|
||||
'connecting_to_chat': str,
|
||||
'connected_to_chat': str,
|
||||
'disconnected_from_chat': str,
|
||||
'error_connecting_to_chat': str,
|
||||
'error_connecting_to_chat_terse': str,
|
||||
}
|
||||
},
|
||||
'http': {
|
||||
'400': str | None,
|
||||
'401': str | None,
|
||||
'403': str | None,
|
||||
'404': str | None,
|
||||
'405': str | None,
|
||||
'410': str | None,
|
||||
'500': str | None,
|
||||
}
|
||||
}
|
||||
|
||||
class Nonconforming(Exception):
|
||||
pass
|
||||
|
||||
def _conform_to_spec(data, spec, level=()):
|
||||
assert isinstance(spec, dict), \
|
||||
f'bad locale spec at {level}: must be {dict}, not {type(spec)}'
|
||||
if not isinstance(data, dict):
|
||||
raise Nonconforming(
|
||||
f'object at {level} must be dict, not {type(data)}'
|
||||
)
|
||||
missing_keys = set(spec.keys()) - set(data.keys())
|
||||
if missing_keys:
|
||||
raise Nonconforming(f'dict at {level} is missing keys {missing_keys}')
|
||||
extra_keys = set(data.keys()) - set(spec.keys())
|
||||
if extra_keys:
|
||||
raise Nonconforming(f'dict at {level} has extra keys {extra_keys}')
|
||||
for key, subspec in spec.items():
|
||||
subdata = data[key]
|
||||
if isinstance(subspec, dict):
|
||||
_conform_to_spec(subdata, subspec, level + (key,))
|
||||
else:
|
||||
assert isinstance(subspec, type | types.UnionType), \
|
||||
f'bad locale spec at {level + (key,)}: must be {dict | type}, not {type(subspec)}'
|
||||
if not isinstance(subdata, subspec):
|
||||
raise Nonconforming(
|
||||
f'value at {level + (key,)} must be {subspec}, not {type(subdata)}'
|
||||
)
|
||||
|
||||
def validate_locale(locale):
|
||||
return _conform_to_spec(locale, SPEC)
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import secrets
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import base64
|
||||
|
@ -25,17 +25,6 @@ Presence = Enum(
|
|||
def generate_token():
|
||||
return secrets.token_hex(16)
|
||||
|
||||
def concatenate_for_notice(string, *tuples):
|
||||
if not tuples:
|
||||
return string
|
||||
markup = Markup(
|
||||
''.join(
|
||||
f' <mark>{escape(x)}</mark>{escape(y)}'
|
||||
for x, y in tuples
|
||||
)
|
||||
)
|
||||
return string + markup
|
||||
|
||||
def trilean(presence):
|
||||
match presence:
|
||||
case Presence.WATCHING:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from enum import Enum
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
|
@ -9,6 +9,7 @@ from quart import current_app, websocket
|
|||
from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
|
||||
from anonstream.captcha import get_random_captcha_digest_for
|
||||
from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected
|
||||
from anonstream.locale import get_locale_from
|
||||
from anonstream.user import get_all_users_for_websocket, see, reading, verify, deverify, BadCaptcha, try_change_appearance, ensure_allowedness, AllowednessException
|
||||
from anonstream.wrappers import with_timestamp, get_timestamp
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
|
@ -36,11 +37,7 @@ async def websocket_outbound(queue, user):
|
|||
'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'],
|
||||
'digest': get_random_captcha_digest_for(user),
|
||||
'pingpong': CONFIG['TASK_BROADCAST_PING'],
|
||||
'maxlength': {
|
||||
'comment': CONFIG['CHAT_COMMENT_MAX_LENGTH'],
|
||||
'name': CONFIG['CHAT_NAME_MAX_LENGTH'],
|
||||
'password': CONFIG['CHAT_TRIPCODE_PASSWORD_MAX_LENGTH'],
|
||||
},
|
||||
'locale': get_locale_from(websocket)['anonstream']['js'],
|
||||
})
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
|
@ -131,7 +128,7 @@ def handle_inbound_appearance(timestamp, queue, user, name, color, password, wan
|
|||
else:
|
||||
return {
|
||||
'type': 'appearance',
|
||||
'result': 'Changed appearance',
|
||||
'result': 'Changed appearance' " [THIS STRING STILL HARDCODED]",
|
||||
'name': user['name'],
|
||||
'color': user['color'],
|
||||
#'tripcode': user['tripcode'],
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import time
|
||||
|
|
2
asgi.py
2
asgi.py
|
@ -1,4 +1,4 @@
|
|||
# SPDX-FileCopyrightText: 2022 n9k <https://gitler.moe/ninya9k>
|
||||
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
secret_key = "place secret key here"
|
||||
|
||||
[locale]
|
||||
default = "en"
|
||||
offered = ["en", "de"]
|
||||
directory = "l10n/"
|
||||
|
||||
[socket.control]
|
||||
enabled = true
|
||||
address = "control.sock"
|
||||
|
@ -26,6 +31,7 @@ file_cache_lifetime = 0.5
|
|||
|
||||
[access]
|
||||
captcha = true
|
||||
hide_offered_locales = 0 #"don't" "from-new" "from-everyone"
|
||||
|
||||
[captcha]
|
||||
lifetime = 1800
|
||||
|
|
|
@ -91,4 +91,4 @@ stream/stream.m3u8
|
|||
```
|
||||
|
||||
[hwaccel]: https://trac.ffmpeg.org/wiki/HWAccelIntro
|
||||
[plaintext]: https://gitler.moe/ninya9k/anonstream/raw/branch/master/doc/guide/OBS.md
|
||||
[plaintext]: https://git.076.ne.jp/ninya9k/anonstream/raw/branch/master/doc/guide/OBS.md
|
||||
|
|
|
@ -128,6 +128,6 @@ systemd you can alternatively do `# systemctl reload tor`. If
|
|||
everything went well, the directory will have been created and your
|
||||
onion address will be in `$HIDDEN_SERVICE_DIR/hostname`.
|
||||
|
||||
[readme]: https://gitler.moe/ninya9k/anonstream/src/branch/master/README.md#setup
|
||||
[readme]: https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/README.md#setup
|
||||
[tor]: https://gitlab.torproject.org/tpo/core/tor
|
||||
[torrc]: https://support.torproject.org/#tbb-editing-torrc
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
{
|
||||
"anonstream": {
|
||||
"error": {
|
||||
"invalid_token": "invalid_token%s",
|
||||
"captcha": "captcha%s%s",
|
||||
"captcha_again": "captcha_again%s%s",
|
||||
"impostor": "impostor%s%s",
|
||||
"broadcaster_should_log_in": "broadcaster_should_log_in",
|
||||
"wrong_username_or_password": "wrong_username_or_password%s",
|
||||
"blacklisted": "Du wurdest ge-blacklist-et.",
|
||||
"not_whitelisted": "Du wurdest nicht ge-whitelist-et.",
|
||||
"offline": "Der Stream ist offline.",
|
||||
"ratelimit": "Du hast den Stream bereits vor kurzem angefragt. Versuche es erneut in %.1f Sekunden.",
|
||||
"limit": "Du hast eine Stream-Verbindung %d mal gleichzeitig angefragt. Beende eine Verbindung bevor du eine neue Anfrage versuchst."
|
||||
},
|
||||
"internal": {
|
||||
"captcha_required": "Captcha benötigt",
|
||||
"captcha_incorrect": "Falsches Captcha",
|
||||
"captcha_expired": "Captcha abgelaufen",
|
||||
"message_ratelimited": "Chat-Überlastung in den letzten %.0f Sekunden",
|
||||
"message_suspected_duplicate": "Verworfen, Duplikat vermutet",
|
||||
"message_empty": "Die Nachricht war leer",
|
||||
"message_practically_empty": "Die Nachricht war praktisch leer",
|
||||
"message_too_long": "Nachricht hat %d Zeichen überschritten",
|
||||
"message_too_many_lines": "Nachricht hat %d Zeilen überschritten",
|
||||
"message_too_many_apparent_lines": "Nachricht würde %d oder mehr Zeilen umfassen",
|
||||
"appearance_changed": "Aussehen geändert",
|
||||
"name_empty": "Namensfeld war leer",
|
||||
"name_too_long": "Name überschreitet %d Zeichen",
|
||||
"colour_invalid_css": "Ungültige CSS-Farbe",
|
||||
"colour_insufficient_contrast": "Farbe hat nicht ausreichend Kontrast: %s",
|
||||
"password_too_long": "Passwort überschreitet %d Zeichen"
|
||||
},
|
||||
"captcha": {
|
||||
"captcha_failed_to_load": "Captcha konnte nicht geladen werden",
|
||||
"click_for_a_new_captcha": "Klick für ein neues Captcha"
|
||||
},
|
||||
"home": {
|
||||
"info": "Info",
|
||||
"chat": "Chat",
|
||||
"both": "Beide",
|
||||
"users": "Benutzer",
|
||||
"stream_chat": "Stream-Chat",
|
||||
"users_in_chat": "Benutzer im Chat",
|
||||
"source": "Quelltext"
|
||||
},
|
||||
"stream": {
|
||||
"offline": "[offline]"
|
||||
},
|
||||
"info": {
|
||||
"viewers": "%d Zuschauer",
|
||||
"uptime": "Zeit:",
|
||||
"reload_stream": "Stream neu laden"
|
||||
},
|
||||
"chat": {
|
||||
"users": "Benutzer",
|
||||
"click_to_refresh": "Klicken zum aktualisieren",
|
||||
"hide_timeout_notice": "Auszeitnachricht ausblenden",
|
||||
"watching": "Zuschauer (%d)",
|
||||
"not_watching": "Inaktive Zuschauer (%d)",
|
||||
"you": " (Du)",
|
||||
"timed_out": "Zeitüberschreitung"
|
||||
},
|
||||
"form": {
|
||||
"click_to_dismiss": "Klicken zum Ausblenden",
|
||||
"send_a_message": "Schreib eine Nachricht...",
|
||||
"captcha": "Captcha",
|
||||
"settings": "Einstellungen",
|
||||
"captcha_failed_to_load": "Captcha konnte nicht geladen werden",
|
||||
"click_for_a_new_captcha": "Klick für ein neues Captcha",
|
||||
"chat": "Chat",
|
||||
"name": "Name:",
|
||||
"tripcode": "Tripcode:",
|
||||
"no_tripcode": "(kein Tripcode)",
|
||||
"set": "setzen",
|
||||
"cleared": "(geleert)",
|
||||
"undo": "zurück",
|
||||
"tripcode_password": "(Tripcode-Passwort)",
|
||||
"return_to_chat": "Zurück zum Chat",
|
||||
"update": "Aktualisieren"
|
||||
},
|
||||
"js": {
|
||||
"offline": "[offline]",
|
||||
"reload_stream": "Stream neuladen",
|
||||
"chat_scroll_paused": "Chat-Rollen pausiert. Klick zum Wiederaufnehmen.",
|
||||
"not_connected": "Nicht mit dem Chat verbunden",
|
||||
"broadcaster": "Sender",
|
||||
"loading": "Läd...",
|
||||
"click_for_a_new_captcha": "Klick für ein neues Captcha",
|
||||
"viewers": "{0} Zuschauer",
|
||||
"you": " (Du)",
|
||||
"watching": "Zuschauer ({0})",
|
||||
"not_watching": "Inaktive Zuschauer ({0})",
|
||||
"errors": "Fehler:",
|
||||
"connecting_to_chat": "Baue Verbindung zum Chat auf...",
|
||||
"connected_to_chat": "Verbunden mit dem Chat",
|
||||
"disconnected_from_chat": "Verbindung zum Chat getrennt",
|
||||
"error_connecting_to_chat": "Fehler bei der Verbindung zum Chat",
|
||||
"error_connecting_to_chat_terse": "Fehler"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"400": null,
|
||||
"401": null,
|
||||
"403": null,
|
||||
"404": null,
|
||||
"405": null,
|
||||
"410": null,
|
||||
"500": null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
{
|
||||
"anonstream": {
|
||||
"error": {
|
||||
"invalid_token": "Your token contains disallowed characters or is too long. Tokens must match this regular expression: %s",
|
||||
"captcha": "You have not solved the access captcha. %sClick here.%s",
|
||||
"captcha_again": "You must solve the access captcha again because you have been away. %sClick here.%s",
|
||||
"impostor": "You are using the broadcaster's token but you are not logged in. The broadcaster should %sclick here%s and log in with the credentials printed in their terminal when they started anonstream.",
|
||||
"broadcaster_should_log_in": "The broadcaster should log in with the credentials printed in their terminal.",
|
||||
"wrong_username_or_password": "Wrong username or password. Refresh the page to try again. %sThe broadcaster should log in with the credentials printed in their terminal.",
|
||||
"blacklisted": "You have been blacklisted.",
|
||||
"not_whitelisted": "You have not been whitelisted.",
|
||||
"offline": "The stream is offline.",
|
||||
"ratelimit": "You have requested the stream recently. Try again in %.1f seconds.",
|
||||
"limit": "You have made %d concurrent requests for the stream. End one of those before making a new request."
|
||||
},
|
||||
"internal": {
|
||||
"captcha_required": "Captcha required",
|
||||
"captcha_incorrect": "Captcha incorrect",
|
||||
"captcha_expired": "Captcha expired",
|
||||
"message_ratelimited": "Chat overuse in the last %.0f seconds",
|
||||
"message_suspected_duplicate": "Discarded suspected duplicate message",
|
||||
"message_empty": "Message was empty",
|
||||
"message_practically_empty": "Message was practically empty",
|
||||
"message_too_long": "Message exceeded %d chars",
|
||||
"message_too_many_lines": "Message exceeded %d lines",
|
||||
"message_too_many_apparent_lines": "Message would span %d or more lines",
|
||||
"appearance_changed": "Changed appearance",
|
||||
"name_empty": "Name was empty",
|
||||
"name_too_long": "Name exceeded %d chars",
|
||||
"colour_invalid_css": "Invalid CSS color",
|
||||
"colour_insufficient_contrast": "Colour had insufficient contrast: %s",
|
||||
"password_too_long": "Password exceeded %d chars"
|
||||
},
|
||||
"captcha": {
|
||||
"captcha_failed_to_load": "Captcha failed to load",
|
||||
"click_for_a_new_captcha": "Click for a new captcha"
|
||||
},
|
||||
"home": {
|
||||
"info": "info",
|
||||
"chat": "chat",
|
||||
"both": "both",
|
||||
"users": "Users",
|
||||
"stream_chat": "Stream chat",
|
||||
"users_in_chat": "Users in chat",
|
||||
"source": "source"
|
||||
},
|
||||
"stream": {
|
||||
"offline": "[offline]"
|
||||
},
|
||||
"info": {
|
||||
"viewers": "%d viewers",
|
||||
"uptime": "Uptime:",
|
||||
"reload_stream": "Reload stream"
|
||||
},
|
||||
"chat": {
|
||||
"users": "Users",
|
||||
"click_to_refresh": "Click to refresh",
|
||||
"hide_timeout_notice": "Hide timeout notice",
|
||||
"watching": "Watching (%d)",
|
||||
"not_watching": "Not watching (%d)",
|
||||
"you": " (You)",
|
||||
"timed_out": "Timed out"
|
||||
},
|
||||
"form": {
|
||||
"click_to_dismiss": "Click to dismiss",
|
||||
"send_a_message": "Send a message...",
|
||||
"captcha": "Captcha",
|
||||
"settings": "Settings",
|
||||
"captcha_failed_to_load": "Captcha failed to load",
|
||||
"click_for_a_new_captcha": "Click for a new captcha",
|
||||
"chat": "Chat",
|
||||
"name": "Name:",
|
||||
"tripcode": "Tripcode:",
|
||||
"no_tripcode": "(no tripcode)",
|
||||
"set": "set",
|
||||
"cleared": "(cleared)",
|
||||
"undo": "undo",
|
||||
"tripcode_password": "(tripcode password)",
|
||||
"return_to_chat": "Return to chat",
|
||||
"update": "Update"
|
||||
},
|
||||
"js": {
|
||||
"offline": "[offline]",
|
||||
"reload_stream": "Reload stream",
|
||||
"chat_scroll_paused": "Chat scroll paused. Click to resume.",
|
||||
"not_connected": "Not connected to chat",
|
||||
"broadcaster": "Broadcaster",
|
||||
"loading": "Loading...",
|
||||
"click_for_a_new_captcha": "Click for a new captcha",
|
||||
"viewers": "{0} viewers",
|
||||
"you": " (You)",
|
||||
"watching": "Watching ({0})",
|
||||
"not_watching": "Not watching ({0})",
|
||||
"errors": "Errors:",
|
||||
"connecting_to_chat": "Connecting to chat...",
|
||||
"connected_to_chat": "Connected to chat",
|
||||
"disconnected_from_chat": "Disconnected from chat",
|
||||
"error_connecting_to_chat": "Error connecting to chat",
|
||||
"error_connecting_to_chat_terse": "Error"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"400": "Bad Request",
|
||||
"401": "Unauthorized",
|
||||
"403": "Forbidden",
|
||||
"404": "Not Found",
|
||||
"405": "Method Not Allowed",
|
||||
"410": "Gone",
|
||||
"500": "Internal Server Error"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"anonstream": {
|
||||
},
|
||||
"http": {
|
||||
"400": "Плохой запрос",
|
||||
"401": "Неавторизованно",
|
||||
"403": "Запрещено",
|
||||
"404": "Не найден",
|
||||
"405": "Метод не разрешён",
|
||||
"410": null,
|
||||
"500": "Внутренняя ошибка сервера"
|
||||
}
|
||||
}
|
読み込み中…
新しいイシューから参照